在多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况,这个资源我们称之其为临界资源;这种资源可能是:对象、变量、文件等。线程获得执行的cpu时间片的不可控必然会出现临界资源被多个线程访问的情况。这样就会出现线程的安全问题。JUC中对其处理都是一个方法:序列化访问临界资源。即在同一时刻,只能有一个线程访问临界资源。。。我觉得叫互斥读写更容易理解。
隐式锁 synchronized 关键字
不管是java的序列访问资源的方法实现还是其他的实现方法。同步器的本质就是加锁,就看是如何加这个锁。也可以理解在某个地方存在着一个钥匙,只有拿到这个钥匙的人才能进行资源的访问。
synchronized是一种对象锁,内置锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的(可重复加锁与释放锁)。而且其加锁和释放不需要我们自己去控制,减少了我们的编程复杂性。
加锁的方式:
1、同步实例方法,锁是当前实例对象
2、同步类方法,锁是当前类对象
3、同步代码块,锁是括号里面的对象
synchronized通过内部对象Monitor(监视器锁)实现,而监视器锁的真正实现是依赖于操作系统的互斥量(Mutex lock)来实现的。synchronized关键字被编译成字节码后会被翻译成monitor-enter 和monitor-exit 两条指令分别在同步块逻辑代码的起始位置与结束位置。
每一个同步的对象都有一个Monitor。其实是所有的java对象都内置Monitor的。所以我们经常可以用Object对象来当做锁:
此时锁就是由synchronizedLock对象(暂且怎么说)。当线程访问时,需要先在synchronizedLock对象中申请到锁。否则将不能执行同步代码块中的方法。加锁过程如下图所示(获取锁失败流程后面讲到):
说到这,疑问就来了。对象上的锁在哪里,对象是如何记录锁状态的呢?获取锁失败后的线程在哪里等待?answer:锁状态是被记录在每个对象的对象头(Mark Word)中,下面看下对象的内存结构布局:
我们常用的HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等
实例数据:即创建对象时,对象中成员变量,方法等
对齐填充:没有特别的含义,它仅仅起着占位符的作用。对象的大小必须是8字节的整数倍(HotSpot内存管理要求的,以小空间换取速度,不足的也会补齐)
我们现在要开的是对象头里面的MarkWord这一部分内容:Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息。在32位和64位的系统中中分别为32个和64个Bits。以32位的为例:
32个bit在对象不同时期的内存分配的规则如上图。根据上图。下面我们来说一下1.6之后(1.6jdk之前版本是通过直接申请系统互斥量实现的)的java中synchronized的锁是如何进行加锁释放锁以及是如何根据当前线程的竞争激烈程度进行锁升级的。锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。下图吐血推荐锁的升级全过程:(图片比较大,图片点击用链接浏览器查看 https://i-blog.csdnimg.cn/blog_migrate/e9570841cdac828ef07e4bcd147f942a.png)
偏向锁的获取与释放(只有有竞争的时候才会释放)
1、线程1访问需要同步获取锁的代码;
2、查看synchronizedLock的Mark Word的锁状态;
3、如果此时锁标志位是 01而且锁状态是0,证明其锁没有被其他线程占用;
4、修改synchronizedLock 的Mark Word的锁状态为1,并且将线程ID指向自己,此时就完成了从 无锁到偏向锁的状态。在锁释放前面,如果再次重入同步代码,将只比较线程ID是否相同,直 接放行
5、随着同步代码访问频率加快,这个时候在线程1释放锁之前,线程2进来了。首先查看锁对象 中的原线程ID的线程是否存在(线程1不会主动释放偏向锁),如果不存在,则直接把偏向锁标识 设置为0,并且cas把偏向锁线程ID指向自己,锁不会升级。
6、明显此时线程ID还是线程1,证明存在竞争,此时线程2会申请撤销偏向锁(偏向锁的撤销,需要 等待全局安全点,即在这个时间点上没有正在执行的字节码)。
7、线程1在到安全局点时将暂停线程执行。这个时候将会检查线程1的代码执行的进度情况:
1、线程1的代码已经执行到同步代码块外面,直接释放偏向锁,将线程ID置空,偏向锁标志位 置为0,而次此时线程2将重新通过cas获得偏向锁。
2、线程1的代码执行还在同步代码块里面,这个时候就不能只偏向于某一个线程了,因为已经 有了竞争了。这是则开始升级到偏向锁。锁标志位为00
轻量级锁:
1、首先线程1和线程2将在各自的线程栈中开辟一块空间 曰:LockRecord(包含一个指针Owner)。然后把同步对象的MarkWord复制过来,这个动作称为 Displaced Mark Word。
2、因为线程1的代码还没执行完成(如果是两个线程在锁对象已经是轻量级锁的时候去获取锁,是 各自去竞争的。),线程1将同步对象Mark Word的指针指向线程1(自己的Owner),同时将线程 栈中LockRecord的Owner指向同步对象指针。
3、线程1继续往下执行代码。而线程2将自旋以获取锁。(线程获取轻量级锁的自旋次数可以通过参数设置(1.7之前,之后都有JVM自己调整),对于同一个对象锁,在本次自旋成功后,JVM在下次获取轻量级锁时会适当增加自旋次数来获取锁,也就是自适应自旋。因为上次的自旋成功让JVM认为这次自旋多几次也必定能成功,从而避免阻塞线程)
4、如果线程2在自旋最大次数后,任然无法获取到锁,则转向重量级锁升级
重量级锁:
1、线程2在自旋最大次数失败后,向操作系统申请互斥量Mutex lock。每一个对象中都有一个Monitor监视器,而Monitor依赖操作系统的 MutexLock(互斥锁)来实现的。Monitor对象可以在虚拟机hpp文件中查看:objectMintor.hpp中的 ObjectMonitor对象
几个重要参数已经加了注解。
2、申请成功后将原同步对象的指向线程1的轻量级锁的指针改为指向系统互斥量(可以看成是ObjectMonitor),并且将自身线程阻塞挂起,进入EntryList队列。
3、线程1在执行完成代码后,在释放轻量级锁的时候发现自己的owner指针已经被线程2取消指向同步对象的轻量级指针,这个时候线程1将去唤醒在EntryList队列中等待的被阻塞的线程。此后此同步对象将彻底进入到重量级状态,以后的线程竞争将直接被阻塞。
4、线程2在唤醒以后,如果竞争到了锁之后会进入到_Owner区域,并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count+1,而如果monitor的owner已经是当前线程,则再次尝试获取monitor时,count+1(锁重入)。如果线程调用wait方法,将释放当前持有的monitor,owner变量恢复为null,count减1,同时该线程进入WaitSet等待被notify唤醒。如果当前线程执行完毕也将释放monitor,以便其他线程获取。
5、EntryList里面时被竞争锁失败的线程集合,而WaitSet里面是获取到锁的线程调用wait方法等待的线程,需要被notify唤醒或者过了wait等待时间自动唤醒,唤醒后的状态是就绪状态。WaitSet中的对象被唤醒后会重新去EntryList中排队
引入轻量级锁的原因:在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗(用户态和核心态转换),但是如果多个线程在同一时刻进入临界区,并且无法自旋内获取到锁,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁(竞争激烈的时候可能相当长的一段时间内无法获取到锁,如果此时一直进行自旋而不是休眠将导致CPU大部分时间在空跑获取锁,而不是在执行代码),而是解决在未知竞争激烈程度下的优化。
引入偏向锁的原因:在只有单线程执行情况下,尽量减少不必要的轻量级锁执行步骤。轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只依赖一次CAS原子指令置换ThreadID,之后只要判断线程ID为当前线程即可,偏向锁使用了一种等到竞争出现才释放锁的机制,消除偏向锁的开销还是蛮大的,要等到临界安全点。
synchronized在竞争不是非常激烈的情况下,性能实测还是比较快的。其最大优势在于。。。。代码方便,不需要你自己去控制锁的释放。在没有明确的性能问题导致系统不能满足业务的时候,隐式锁是一个非常好的选择。
注意:Synchronized加锁的含义不仅仅局限于互斥行为,还包括内存可见性。
- 获得同步锁;
- 清空工作内存;
- 从主内存拷贝对象副本到工作内存;
- 执行代码(计算或者输出等);
- 刷新主内存数据;
- 释放同步锁。