Java笔记 ·

并发编程学习笔记01-Java并发机制的底层原理之synchronized

并发学习系列以阅读《Java并发编程的艺术》一书的笔记为蓝本,汇集一些阅读过程中找到的解惑资料而成。这是一个边看边写的系列,有兴趣的也可以先自行购买此书学习。

synchronized实现同步的基础:Java中的每一个对象都可以作为锁。具体表现为三种形式:

  • 对于普通同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前类的Class对象。
  • 对于同步方法块,锁是synchronized括号里配置的对象。

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常是必须释放锁。

JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,两者实现细节不同,但都可使用 monitorenetermonitorexit 这两个指令来实现。

  • 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队列),前者负责做互斥,后一个用于做线程同步。

参考资料

  1. JVM优化之压缩普通对象指针(CompressedOops)

  2. Java对象头详解

  3. 让我们来一起聊聊Java中的锁

参与评论