作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬
在如何学习Java并发编程一章中,我画了一幅图:
到目前为止,我们已经学习了Thread、Runnable/Callable,也一起研究了FutureTask的底层原理,还通过手写山寨ThreadPoolExecutor的方式剖析了线程池的设计思想。也就是说,上图左边关于线程的部分已经学习完毕(CompletableFuture属于线程工具,后面介绍)。
相信大家对前面《从线程间通信聊到阻塞队列》一文都还有印象,它看似和多线程没有直接关系,却是承上启下的关键。阻塞队列是线程池生产消费模型的核心,同时它内部的实现又依赖于ReentrantLock,所以阻塞队列可以说是线程和锁之间的“桥梁”。现在线程基本讲完了,该轮到“锁”了。
什么是锁?
学习多线程是为了提高任务执行效率,但多线程又引入了线程安全问题。所谓线程安全问题,可以简单地理解为数据不一致(与预期不一致)。
什么时候可能出现线程安全问题呢?
当同时满足以下三个条件时,才可能引发线程安全问题:
- 多线程环境
- 有共享数据
- 有多条语句操作共享数据/单条语句本身非原子操作(比如i++虽然是单条语句,但并非原子操作)
比如线程A、B同时对int count进行+1操作(初始值假设为1),在一定的概率下两次操作最终结果可能为2,而不是3。
那么加锁为什么能解决这个问题呢?
如果不考虑原子性、内存屏障等晦涩的名词,加锁之所以能保证线程安全,核心就是“互斥”。所谓互斥,就是字面意思上的互相排斥。这里的“互相”是指谁呢?就是多线程之间!
怎么实现多线程之间的互斥呢?
引入“中间人”即可。
注意,这是个非常简单且伟大的思想。在编程世界中,通过引入“中介”最终解决问题的案例不胜枚举,包括但不限于Spring、MQ。在码农之间,甚至流传着一句话:没有什么问题是引入中间层解决不了的。
而JVM锁其实就是线程和线程彼此的“中间人”,多个线程在操作加锁数据前都必须征求“中间人”的同意:
锁在这里扮演的角色其实就是守门员,是唯一的访问入口,所有的线程都要经过它的拷问。在JDK中,锁的实现机制最常见的就是两种,分别是两个派系:
- synchronized关键字
- AQS(ReentrantLock)
个人觉得synchronized关键字要比AQS难理解,但AQS的源码比较抽象。这里简要介绍一下Java对象内存结构和synchronized关键字的实现原理。
Java对象内存结构
要了解synchronized关键字,首先要知道Java对象的内存结构。强调一遍,是Java对象的内存结构。
它的存在仿佛向我们抛出一个疑问:如果有机会解剖一个Java对象,我们能看到什么?
右上图画了两个对象,只看其中一个即可。我们可以观察到,Java对象内存结构大致分为几块:
- Mark Word(锁相关)
- 元数据指针(class pointer,指向当前实例所属的类)
- 实例数据(instance data,我们平常看到的仅仅是这一块)
- 对齐(padding,和内存对齐有关)
如果此前没有了解过Java对象的内存结构,你可能会感到吃惊:天呐,我还以为Java对象就只有属性和方法!
是的,我们最熟悉实例数据这一块,而且以为只有这一块。也正是这个观念的限制,导致一部分初学者很难理解synchronized。比如初学者经常会疑惑:
- 为什么任何对象都可以作为锁?
- Object对象锁和类锁有什么区别?
- synchronized修饰的普通方法使用的锁是什么?
- synchronized修饰的静态方法使用的锁是什么?
这一切的一切,其实都可以在Java对象内存结构中的Mark Word找到答案:
很多同学可能是第一次看到这幅图,会感到有点懵,没关系,我也很头大,都一样的。这里用思维导图帮大家大致梳理下,方便记忆:
Mark Word包含的信息还是蛮多的,但这里我们只需要简单地把它理解为记录锁信息的标记即可。上图展示的是32位虚拟机下的Java对象内存,如果你仔细数一数,会发现全部bit加起来刚好是32位。64位虚拟机下的结构大同小异,就不特别介绍。
Mark Word从有限的32bit中划分出2bit,专门用作锁标志位,通俗地讲就是标记当前锁的状态。
正因为每个Java对象都有Mark Word,而Mark Word能标记锁状态(把自己当做锁),所以Java中任意对象都可以作为synchronized的锁:
synchronized(person){
}
synchronized(student){
}
所谓的this锁就是当前对象,而Class锁就是当前对象所属类的Class对象,本质也是Java对象。synchronized修饰的普通方法底层使用当前对象作为锁,synchronized修饰的静态方法底层使用Class对象作为锁。
但如果要保证多个线程互斥,最基本的条件是它们使用同一把锁:
对同一份数据加两把不同的锁是没有意义的,实际开发时应该注意避免下面的写法:
synchronized(Person.class){
// 操作count
}
synchronized(person){
// 操作count
}
或者
public synchronized void method1(){
// 操作count
}
public static synchronized void method1(){
// 操作count
}
什么是管程?
粗略回顾完Java的锁之后,该上硬菜了。
首先可以明确的是,管程不是Java专有的概念,也不是某个编程语言独有的概念,而是一种通用的解决方案——一种用于解决并发问题的模型。
引入管程的原因
计算机科班的同学应该对信号量并不陌生,也就是所谓的“信号量与PV操作”,是操作系统层面用来解决同步问题的。但信号量机制的缺点是:
进程自备同步操作,P(S)和V(S)操作大量分散在各个进程中,不易管理,易发生死锁。
1974年和1977年,Hore和Hansen提出了管程。
管程特点:管程封装了同步操作,对进程隐蔽了同步细节,简化了同步功能的调用界面,用户编写并发程序如同编写顺序(串行)程序。
引入管程机制的目的:
- 把分散在各进程中的临界区集中起来进行管理
- 防止进程有意或无意的违法同步操作
- 便于用高级语言来书写程序,也便于程序正确性验证
管程的定义
管程是由自身内部的若干公共变量和所有访问这些公共变量的过程所组成的软件模块。
管程的组成部分
- 管程内部定义的共享变量
- 对数据结构(共享变量)进行操作的一组过程(方法)
- 对管程的数据进行初始化的语句
管程的属性
- 共享性:管程可被系统范围内的进程互斥访问,属于共享资源
- 安全性:管程的局部变量只能由管程的过程访问,不允许进程或其它管程直接访问,管程也不能访问非局部于它的变量
- 互斥性:多个进程对管程的访问是互斥的。任一时刻,管程中只能有一个活跃进程
- 封装性:管程内的数据结构是私有的,只能在管程内使用,管程内的过程也只能使用管程内的数据结构。进程通过调用管程的过程使用临界资源
从上面对管程的描述来看,你会发现管程还挺符合面向对象设计思想的:定义一个共享变量,对外暴露一组操作。Java之所以选择管程,不知道和这点有无关系。
管程在Java中的实现
管程模型并不是一成不变的,它经历了几次变化:Hoare模型(1974年)、Hasen模型(1976年)以及后来的MESA模型。其中,现在广泛应用的是MESA模型,并且Java 管程的实现参考的也是MESA模型。
synchronized锁
大家在学习synchronized时,如果学得深入些,肯定见过这幅图:
我们都知道,Java的任意对象都可以作为锁,这是怎么做到的呢?
实际上,每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁之后( synchronized(user){...} ),该对象头的Mark Word就被设置指向Monitor对象的指针。也就是说,这个Java对象就关联了一个Monitor对象,而Monitor就是Java的管程模型。所以,真正实现锁机制的并不是Java对象本身,而是它背后的管程。
synchronized的管程模型大致分为EntryList、Owner和WaitSet。抢到锁的线程记录为Owner,抢不到锁的线程进入EntryList等待。那WaitSet是啥?大家想一下,抢到锁但在同步代码块中调用wait()的线程该如何处理?其实就是进入了WaitSet。
总结一下synchronized相关的管程模型(Monitor):
- 抢到锁的线程是Owner
- 没抢到锁的进入EntryList
- 抢到锁,但运行过程中因为条件不满足,调用wait()方法后进入WaitSet,等待notify()/notifyAll()
可以粗浅地理解为,管程模型考虑2个问题:
- 没抢到锁时如何处理(等待队列)
- 抢到锁却不满足条件时又如何处理(条件队列)
如果非要再细致一点,再加上一个问题:“如何表示一个线程抢到了锁”。
极客时间王宝令老师在管程:并发编程的万能钥匙中,画了一张图,个人觉得很贴切:
对于synchronize而言:
- 共享变量大概就是锁对象
- 条件变量等待队列只有一个WaitSet
- 入口等待队列就是EntryList
- 对外暴露的方法就是:wait()、notify()和notifyAll()
由于synchronized是隐式锁,所以并没有提供lock()和unlock()操作,JVM底层帮我们自动完成加解锁操作。
ReentrantLock锁
同样是基于管程模型,但synchronized和ReentrantLock底层实现并不相同。synchronized直接依赖于JVM和操作系统,引入了Monitor对象,而ReentrantLock底层则是AQS。
之前模拟阻塞队列时,我们迭代了多个版本。原先用synchronized实现了一版阻塞队列,但由于出现了“生产者唤醒生产者”的乌龙事件,于是把notify()改成了notifyAll(),后面干脆使用ReentrantLock替代了synchronized,而ReentrantLock.Condition也完美了解决了等待队列的问题。
回顾上面对管程模型的定义:
- 共享变量(synchronized基于对象内存的markword,ReentrantLock基于AQS的state变量)
- 对外暴露的操作
- EntryList和WaitSet
你会发现synchronized的Monitor实现,只有一个等待队列,无论基于什么原因发生等待(wait),统统进入WaitSet。这样一来,当A条件满足要去唤醒当初因为A条件而等待的线程时,不得不把其他条件的等待队列也一并唤醒...简而言之,对等待队列的控制不够精确。
Doug Lea通过抽取封装AQS,实现了自己的EntryList,同时支持不同条件的等待队列Condition。获锁的线程如果当前不满足A条件,可以进入A Condition队列等待,不满足B条件则进入B Condition队列等待。唤醒的过程同样清晰,当A条件满足时,去A Condition队列唤醒即可。
和synchronized一样,ReentrantLock其实也是围绕三个问题设计的:“没抢到锁时如何”、“抢到锁却不符合条件时又如何”、“怎样才算抢到锁”
小结
本文粗略介绍了什么是锁、Java对象的内存结构,然后又引出管程模型。Java对象的内存结构与synchronized的管程实现较为密切,而ReentrantLock则是借助AQS,是Doug Lea参考管程模型编写的抽象同步器。
另外,你会发现从系统底层到JVM层面,阻塞无处不在。Monitor的EntryList、WaitSet,AQS的阻塞队列,甚至BlockingQueue本身,其实都是阻塞模型,只不过一个是微观的,一个是宏观的。但归根到底,都能看到管程模型,所以说管程是并发编程的万能钥匙。
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
进群,大家一起学习,一起进步,一起对抗互联网寒冬