并发编程学习笔记02-Java并发机制的底层原理之synchronized
该并发学习系列以阅读《Java并发编程的艺术》一书的笔记为蓝本,汇集一些阅读过程中找到的解惑资料而成。这是一个边看边写的系列,有兴趣的也可以先自行购买此书学习。
synchronized实现同步的基础:Java中的每一个对象都可以作为锁。具体表现为三种形式:
- 对于普通同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的Class对象。
- 对于同步方法块,锁是synchronized括号里配置的对象。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常是必须释放锁。
JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,两者实现细节不同,但都可使用 monitoreneter
和 monitorexit
这两个指令来实现。
- monitorenter指令是在编译后插入到同步块的开始位置。
- monitorexit指令是插入到方法结束处和异常处。
- JVM要保证每个monitorenter必须有对应的monitorexit与之配对。
- 任何对象都有一个monitor与之关联,且当一个monitor被持有后,它将处于锁定状态。
- 程序执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
Java对象头
synchronized用的锁是存在Java对象头里的。
- 若对象是数组类型,则虚拟机用3字宽(Word)存储对象头;
- 若对象是非数组类型,则用2字宽存储对象头。
在32位虚拟机中,1字宽等于4字节,即32bit。依次,在64位虚拟机中,1子宽为64bit。
Java对象头组成如下:
长度 | 内容 | 说明 | 备注 |
---|---|---|---|
32/64 bit | Mark Word | 存储对象的hashCode或锁信息等 | 默认存储对象的HashCode、分代年龄和锁标记位。 |
32/64 bit | Class Metadata Address | 存储对象的类型数据的指针 | 该指针指向对象的类元数据,JVM通过这个指针确定对象是哪个类的实例。 |
32/64 bit | Array Length | 数组的长度(如果当前对象是数组) | j当且仅当对象是数组时才会有这部分。 |
32位JVM的Mark Word的默认存储结构如下:
锁状态 | 25bit | 4bit | 1bit 是否是偏向锁 | 2bit 锁标记位 |
---|---|---|---|---|
无锁状态 | 对象的HashCode | 对象的分代年龄 | 0 | 01 |
运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。可能会变成存储以下4种数据:
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下所示:
由此可见无锁状态和偏向锁状态时锁标志位均是01,只是在前面的1bit区域区分了当前是无锁状态还是偏向锁状态。
UseCompressedOops
64位的JVM将会比32位的JVM多耗费50%的内存。
从JDK 1.6 update14开始,64 bit JVM正式支持了
-XX:+UseCompressedOops
这个可以压缩指针,起到节约内存占用的新参数。
oop即ordinary object pointer普通对象指针。开启该选项后,下列指针将压缩至32位:
- 1.每个Class的属性指针(即静态变量)
- 2.每个对象的属性指针(即对象变量)
- 3.普通对象数组的每个元素指针
当然不是所有指针都被压缩,一些特殊类型的指针JVM不会优化,比如:指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。
被压缩的还有数组对象头的Array Length 部分。
该指令原理是:解释器在解释字节码时,植入压缩指令。
关于JVM中的对象相关内容可查看:JVM-HotSpot虚拟机对象探秘
。
锁的升级与对比
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,以及锁升级的概念。
JDK1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。
这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级。这种策略的目的是为了提高获得锁和释放锁的效率。
几种锁的优缺点对比如下:
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 适用于只有一个线程访问同步块的场景。 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度。 | 如果始终得不到锁竞争的线程,使用自旋会消耗CPU。 | 追求响应时间;同步块执行速度非常快。 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU。 | 线程阻塞,响应时间缓慢。 | 追求吞吐量,同步块执行速度较长 |
偏向锁
大多数情况下锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,故为了让线程获得锁的代价更低而引入了偏向锁。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录中存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
- 若测试成功,表示线程已经获得了锁。
- 若测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1:
若没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。
- 首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着。
- 如果线程不处于活动状态,则将对象头设置成无锁状态;
- 如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录。
- 栈中的锁记录和对象头的Mark Word 要么重新偏向其他线程,要么恢复到无锁或标记对象不适合作为偏向锁,最后唤醒暂停的线程。
关闭偏向锁
偏向锁在Java 6 和 Java 7里是默认启用的。但它在应用程序启动几秒后才激活。可以通过JVM参数来关闭延迟:
-XX:BiasedLockingStartupDelay=0
如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:
-XX:-UseBiasedLocking=false
设置后,程序默认会进入轻量级锁状态。
轻量级锁
线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象中的Mark Word复制到锁记录中,官方称为 Displaced Mark Word。
然后线程尝试使用CAS将对象头中的Mark Word替换位指向锁记录的指针:
- 如果成功,当前线程获得锁;
- 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
轻量级锁解锁时,会使用原子的CAS操作将Displaced Mark Word 替换会到对象头:
- 若成功,表示没有竞争发生。
- 若失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞了),一旦所升级成重量级锁,就不会再回到轻量级锁状态 。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞,当持有锁的线程被释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
重量级锁
重量锁在JVM中又叫对象监视器(Monitor),它很像C中的Mutex,除了具备Mutex互斥的功能,它还负责实现了Semaphore的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后一个用于做线程同步。
参考资料
除特别注明外,本站所有文章均为 windcoder 原创,转载请注明出处来自: bingfabianchengxuexibiji01-javabingfajizhidedicengyuanlizhisynchronized

暂无数据