一、 Java共享内存模型带来的线程安全问题

当两个线程同时操作一个静态变量时,一个线程对这个变量进行自增操作,另一个线程对这个变量进行自减操作,最终结果有可能是正数,负数,零都有可能,我们可以查看 i++和 i—(i 为静态变量)的 JVM 字节码指令 ( 可以在idea中安装一个jclasslib 插件)
1.png
i++的JVM 字节码指令

  1. getstatic i // 获取静态变量i的值
  2. iconst_1 // 将int常量1压入操作数栈
  3. iadd // 自增
  4. putstatic i // 将修改后的值存入静态变量i

i—的JVM 字节码指令

  1. getstatic i // 获取静态变量i的值
  2. iconst_1 // 将int常量1压入操作数栈
  3. isub // 自减
  4. putstatic i // 将修改后的值存入静态变量i

自增和自减操作都不是原子的,当前线程在把变量读到后,在写回之前,可能会产生上下文切换,被别的线程先写回,此时当前线程再把修改后的值写回,就会覆盖别的线程写入的值。

1、临界区( Critical Section)

一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区,其共享资源为临界资源。
一个程序运行多个线程本身是没有问题的, 问题出在多个线程访问共享资源。
多个线程读共享资源其实也没有问题, 在多个线程对共享资源读写操作时发生指令交错,就会出现问题。

  1. //临界资源
  2. private static int counter = 0;
  3. public static void increment() { //临界区
  4. counter++;
  5. }
  6. public static void decrement() {//临界区
  7. counter‐‐;
  8. }

2、竞态条件( Race Condition )

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。为了避免临界区的竞态条件发生,有多种手段可以达到目的:
阻塞式的解决方案:synchronized,Lock。
非阻塞式的解决方案:原子变量(CAS自旋)。
synchronized 有两种语义,同步和互斥,互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码, 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点,例如 wait()、notify()、notifyAll()等待唤醒机制。

3、 synchronized的使用

synchronized 同步块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内置锁,也叫作监视器锁。
2.png synchronized 实际是用对象锁保证了临界区内代码的原子性,同时只能有一个线程访问临界区的代码,对临界资源进行操作。

二、 synchronized底层原理

synchronized是JVM内置锁,基于Monitor机制实现,依赖底层操作系统的互斥原语 Mutex(互斥量),因为涉及到操作系统的调用,所以需要从用户态切换到内核态,它是一个重量级锁,性能较低。当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平,支持重入锁。
在字节码中文件中,同步方法(使用synchronized修饰的方法)是通过方法中的 access_flags(访问标记) 中设置ACC_SYNCHRONIZED标志来实现。
5.png
同步代码块是通过monitorenter和monitorexit来实现。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
3.png
在临界区执行前可以看到增加了 monitorenter,在执行结束后增加了 monitorexit,这里有两个 monitorexit,一个是正常return之前,一个是出现异常的情况。

1、Monitor(管程/监视器)

Monitor,直译为“监视器”,而操作系统领域一般翻译为“管程”。管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。在Java 1.5之前,Java语言提供的唯一并发语言就是管程,Java 1.5之后提供的SDK并发包也是以管程为基础的。除了Java之外,C/C++、C#等 高级语言也都是支持管程的。synchronized关键字和wait()、notify()、notifyAll()这三个方法是 Java中实现管程技术的组成部分。

2、MESA模型

在管程的发展史上,先后出现过三种不同的管程模型,分别是Hasen模型、Hoare模型和 MESA模型。现在正在广泛使用的是MESA模型。下面我们便介绍MESA模型:
入口等待队列:当线程没有获取到资源时,需要进行阻塞,同步锁或者信号量(共享锁,当资源用尽后,没有获取到资源的线程)。
条件变量:为了解决同步问题,使用wait()方法,让当前线程把锁释放掉后进入条件队列,其他线程获取锁后,执行完成或调用notify()、notifyAll()唤醒被阻塞线的程重新抢索。
AQS也是参考这个模型。
4.png
管程中引入了条件变量的概念,而且每个条件变量都对应有一个等待队列。条件变量和等待 队列的作用是解决线程之间的同步问题。

1)wait() 的使用

对于MESA管程来说,有一个编程范式,JAVA中的Monitor是基于对象实现的,所以wait()、notify()、notifyAll()方法是在Object类中定义的,可以看到wait()方法的这段注释:
6.png
注释中提供了一个wait的例子,使用while循环包裹wait方法,为了避免虚假唤醒,当调用notify()、notify()或者中断标志等把线程唤醒,这时需要判断被唤醒的线程是否满足继续执行的条件,即是否应该被唤醒,如果唤醒了本不应该被唤醒的线程,那么应该判断这个线程继续阻塞。

2)notify()和notifyAll()分别何时使用

满足以下三个条件时,可以使用notify(),其余情况尽量使用notifyAll():
(1)、所有等待线程拥有相同的等待条件。
(2)、有等待线程被唤醒后,执行相同的操作。
(3)、只需要唤醒一个线程,或者说确定能够唤醒想要唤醒的线程,比例只有两个线程在执行,一个线程负责唤醒另一个线程。

3)Java语言的内置管程synchronized

Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。模型如下图所示。

7.png4)Monitor机制在Java中的实现

java.lang.Object 类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖于 ObjectMonitor 实现,这是 JVM 内部基于 C++ 实现的一套机制,一个JAVA对象就对应一个monitor。
ObjectMonitor其主要数据结构如下(hotspot源码ObjectMonitor.hpp):

  1. ObjectMonitor() {
  2. _header = NULL; //对象头 markOop
  3. _count = 0;
  4. _waiters = 0,
  5. _recursions = 0; // 锁的重入次数
  6. _object = NULL; //存储锁对象
  7. _owner = NULL; // 标识拥有该monitor的线程(当前获取锁的线程)
  8. _WaitSet = NULL; // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点
  9. _WaitSetLock = 0 ;
  10. _Responsible = NULL ;
  11. _succ = NULL ;
  12. _cxq = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构)
  13. FreeNext = NULL ;
  14. _EntryList = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失败的线程)
  15. _SpinFreq = 0 ;
  16. _SpinClock = 0 ;
  17. OwnerIsThread = 0 ;
  18. _previous_owner_tid = 0;
  19. }

使用到monitor就代表已经生成为一个重量级锁了。
_owner会保存当前获取锁的线程,利用CAS来修改这个值。
其中有三个队列,WaitSet,cxq,EntryList。
当调用wait()方法后,被阻塞的线程会进入WaitSet中,此时调用notify()方法(并没有唤醒线程),根据条件判断把WaitSet中阻塞的线程转移到cxq或者进入EntryList中。
没有获取到资源的线程被阻塞的线程进入cxq链表,每新进入一个线程会被新增到头部,新增的节点的next指向原来的头部节点,所以cxq是一个先进后出的栈结构,所以从cxq唤醒的线程是非公平的。
8.png
在获取锁时,是将当前线程插入到cxq的头部,而释放锁时,默认策略(QMode=0)是:如果EntryList为空,则将 cxq中的元素按原有顺序插入到EntryList,并唤醒第一个线程,也就是当EntryList为空时,是后来的线程先获取锁。_EntryList不为空,直接从_EntryList中唤醒线程。

  1. public class SyncQModeDemo {
  2. public static void main(String[] args) throws InterruptedException {
  3. SyncQModeDemo demo = new SyncQModeDemo();
  4. demo.startThreadA();
  5. //控制线程执行时间
  6. Thread.sleep(100);
  7. demo.startThreadB();
  8. Thread.sleep(100);
  9. demo.startThreadC();
  10. }
  11. final Object lock = new Object();
  12. public void startThreadA() {
  13. new Thread(() -> {
  14. synchronized (lock) {
  15. log.debug("A get lock");
  16. try {
  17. // Thread.sleep(300);
  18. lock.wait(300);
  19. } catch (InterruptedException e) {
  20. e.printStackTrace();
  21. }
  22. log.debug("A release lock");
  23. }
  24. }, "thread-A").start();
  25. }
  26. public void startThreadB() {
  27. new Thread(() -> {
  28. synchronized (lock) {
  29. try {
  30. log.debug("B get lock");
  31. Thread.sleep(500);
  32. } catch (InterruptedException e) {
  33. e.printStackTrace();
  34. }
  35. log.debug("B release lock");
  36. }
  37. }, "thread-B").start();
  38. }
  39. public void startThreadC() {
  40. new Thread(() -> {
  41. synchronized (lock) {
  42. log.debug("C get lock");
  43. }
  44. }, "thread-C").start();
  45. }
  46. }

代码中有三个线程,当ThreadA先执行,获取到锁资源,然后wait释放掉锁,进入WaitSet中,ThreadB执行时,占用锁,ThreadC执行,此时锁资源被ThreadB持有,所以没有抢到锁进入cxq,ThreadA此时被唤醒(wait时间结束),EntryList为空,所以进入EntryList,ThreadB释放锁资源,此时EntryList不为空,会先唤醒EntryList中的ThreadA,等ThreadA释放锁后,把ThreadC移动到EntryList,再唤醒。
如果都是竞争关系,那么三个线程都会进入cxq,ThreadC是最后开始竞争的,所以ThreadC是cxq的头节点,会先被唤醒。

三、对象的内存布局

Hotspot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据 (Instance Data)和对齐填充(Padding)。
对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID, 偏向时间,数组长度(数组对象才有)等。
实例数据:存放类的属性数据信息,包括父类的属性信息。
对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

9.png1、对象头详解

1)Mark Word

用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为 32bit和64bit,官方称它为“Mark Word”。

2)Klass Pointer

对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指 针来确定这个对象是哪个类的实例。 32位4字节,64位开启指针压缩或最大堆内存<32g时4字节,否则8字节。jdk1.8默认开启指针压缩后为4字节,当在JVM参数中关闭指针压缩(-XX:- UseCompressedOops)后,长度为8字节。

3)数组长度(只有数组对象有)

如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度。 4字节。

10.png4)new Object() 占内存中多少子节

对象头中,Mark Word固定占用8个字节,元数据指针,在开启指针压缩且堆内存小于32G时,占4个字节,未开启指针压缩或者堆内存大于32G,占8字节,因为不是数组,所以没有数组长度。如果没有成员变量,那么就是8+4 = 12字节,对齐填充到8的整数倍,那么一个new Object()就占内存16字节。如果有成员变量,int占4字节,short占2字节,long占8字节,byte占1字节,float占4字节,double占8字节,char占2字节,boolean占1字节。其中char,short,byte,boolean存在内部填充,也是为了对象整体对齐填充位。

2、Mark Word是如何记录锁状态的

Hotspot通过markOop类型实现Mark Word,具体实现位于markOop.hpp文件中。由于对象需要存储的运行时数据很多,考虑到虚拟机的内存使用,markOop被设计成一个非固定的数据结构,以便在极小的空间存储尽量多的数据,根据对象的状态复用自己的存储空间。 简单点理解就是:MarkWord 结构搞得这么复杂,是因为需要节省内存,让同一个内存区域在不同阶段有不同的用处。
MarkWord 是保存在用户态的,所以偏向锁和轻量级锁可以使用cas来修改mark word的状态,而重量级锁需要切换到内核态。
hash:保存对象的哈希码。运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。
age:保存对象的分代年龄。表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代,分代年龄占4位,所以最大年龄为15。
biased_lock:偏向锁标识位。由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
lock:锁状态标识位。区分锁状态,比如11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
JavaThread*:保存持有偏向锁的线程ID。偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动 作。这个线程ID并不是JVM分配的线程ID号,和Java Thread中的ID是两个概念。
epoch:保存偏向时间戳。偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争时,JVM使用原子操作而不是OS互斥,这种技术称为轻量级锁定。在轻量级锁定的情况 下,JVM通过CAS操作在对象的Mark Word中设置指向锁记录的指针,会指向栈空间保存mark word位置的指针,占内存会把mark word保存到栈空间。
ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。 如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指 向Monitor的指针。
11.png
偏向锁状态时,保存的线程ID并不是java的线程ID,而是线程的地址。

3、 Mark Word中锁标记枚举

  1. enum {
  2. locked_value = 0, //00 轻量级锁
  3. unlocked_value = 1, //001 无锁
  4. monitor_value = 2, //10 监视器锁,也叫膨胀锁,也叫重量级锁
  5. marked_value = 3, //11 GC标记
  6. biased_lock_pattern = 5 //101 偏向锁
  7. };

12.png四、偏向锁

判断当前环境字节是大端还是小端字节序存储,Little-Endian 高位字节在前,低位字节在后,Big-Endian 低位字节在前,高位字节在后,在x86的计算机中,一般采用的是小端字节序。
偏向锁是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了消除数据在无竞争情况下锁重入(CAS操 作)的开销而引入偏向锁。对于没有锁竞争的场合,偏向锁有很好的优化效果。
当JVM启用了偏向锁模式(jdk6默认开启),新创建对象的Mark Word中的Thread Id为0, 说明此时处于可偏向但未偏向任何线程,也叫做匿名偏向状态(anonymously biased)。
这里我们通过打印对象头查看锁状态。

  1. public class ObjectTest {
  2. public static void main(String[] args) throws InterruptedException {
  3. //jvm延迟偏向
  4. // Thread.sleep(5000);
  5. Object obj = new Test();
  6. //Object obj = new Integer[4];
  7. //obj.hashCode();
  8. //查看对象内部信息
  9. System.out.println(ClassLayout.parseInstance(obj).toPrintable());
  10. new Thread(()->{
  11. synchronized (obj){
  12. System.out.println(Thread.currentThread().getName()+"\n"+ClassLayout.parseInstance(obj).toPrintable());
  13. }
  14. System.out.println(Thread.currentThread().getName()+"释放锁\n"+ClassLayout.parseInstance(obj).toPrintable());
  15. // jvm 优化
  16. try {
  17. Thread.sleep(100000);
  18. } catch (InterruptedException e) {
  19. e.printStackTrace();
  20. }
  21. },"Thread1").start();
  22. // Thread.sleep(2000);
  23. //
  24. // new Thread(()->{
  25. // synchronized (obj){
  26. // System.out.println(Thread.currentThread().getName()+"\n"+ClassLayout.parseInstance(obj).toPrintable());
  27. // }
  28. // },"Thread2").start();
  29. }
  30. }
  31. class Test{
  32. private boolean flag;
  33. private long p;
  34. }

打印出来的结果,并没有偏向锁状态,而是直接从无锁状态升级为轻量级锁。
13.png
因为在JVM加载中,会出现线程竞争的问题,所以多导致锁状态频繁的从偏向锁升级为轻量级锁,从偏向锁升级为轻量级锁存在一个撤销动作,需要先把偏向锁撤销再变成轻量级锁,这个开销很大,所以JVM开启了偏向锁延迟,在一定时间内全部都是轻量级锁,等时间过了,再从偏向锁开始。

1、偏向锁延迟偏向

偏向锁模式存在偏向锁延迟机制:HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式。JVM启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁。 为了减少初始化时间,JVM默认延时加载偏向锁。

  1. //关闭延迟开启偏向锁
  2. XX:BiasedLockingStartupDelay=0
  3. //禁止偏向锁
  4. XX:‐UseBiasedLocking
  5. //启用偏向锁
  6. XX:+UseBiasedLocking

在代码中,让线程先睡5秒时间,再开始执行,可以看到开启了偏向锁。
14.png
虽然开启了偏向锁,但是我们看到第一次打印对象头信息时,也是偏向锁的状态,此时并没有对object进行加锁操作,这就是匿名偏向,虽然是偏向锁状态,但是对象头中并没有偏向任何一个线程,仅仅表示是可偏向的状态。
上面的代码中,锁的对象是obj,如果锁的对象是类对象

  1. System.out.println(ClassLayout.parseInstance(ObjectTest.class).toPrintable());
  2. new Thread(()->{
  3. synchronized (ObjectTest.class){
  4. System.out.println(Thread.currentThread().getName()+"\n"+ClassLayout.parseInstance(ObjectTest.class).toPrintable());
  5. }
  6. System.out.println(Thread.currentThread().getName()+"释放锁\n"+ClassLayout.parseInstance(ObjectTest.class).toPrintable());
  7. // jvm 优化
  8. try {
  9. Thread.sleep(100000);
  10. } catch (InterruptedException e) {
  11. e.printStackTrace();
  12. }
  13. },"Thread1").start();

那么锁的状态也不不同,因为类在JVM运行期间已经被创建好了,所以类的锁状态是轻量级锁,因为在类创建的时候,还是延迟偏向锁期间。锁的升级膨胀是不可逆的,升级到轻量级锁就不会再变成偏向锁了。
15.png

2、偏向锁撤销

当偏向锁升级时会先进行偏向锁撤销,当对象偏向一个线程的时候,即便这个线程释放了锁,偏向锁也不会变为无锁状态,而是匿名偏向状态。偏向锁撤销必须要到达安全点。

1)调用hashcode

调用锁对象的obj.hashCode()或System.identityHashCode(obj)方法会导致该对象的偏向锁被撤销。因为对于一个对象,其HashCode只会生成一次并保存,偏向锁是没有地方保存 hashcode的。
轻量级锁会在锁记录中记录 hashCode。
重量级锁会在 Monitor 中记录 hashCode。
当对象处于可偏向(也就是线程ID为0)和已偏向的状态下,调用HashCode计算将会使对象再也无法偏向:
当对象可偏向时,MarkWord将变成未锁定状态,并只能升级成轻量锁。
当对象正处于偏向锁时,调用HashCode将使偏向锁强制升级成重量锁。

16.png2)wait/notify

偏向锁状态执行obj.notify() 会升级为轻量级锁,调用obj.wait(timeout) 会升级为重量级锁。因为wait是基于monitor的,所以需要获取到monitor对象,会直接膨胀为重量级锁,而notify仅仅是唤醒,所以会升级为轻量级锁。

  1. synchronized (obj) {
  2. //obj.hashCode();
  3. //obj.notify();
  4. try {
  5. obj.wait(100);
  6. } catch (InterruptedException e) {
  7. e.printStackTrace();
  8. }
  9. log.debug(Thread.currentThread().getName() + "获取锁执行中。。。\n"+ ClassLayout.parseInstance(obj).toPrintable());
  10. }

五、轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段,此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁所适应的场景是线程交替执行同步块的场合(轻微竞争),如果存在同一时间多个线程访问同一把锁的场合,就会导致轻量级锁膨胀为重量级锁。
当线程释放轻量级锁后,对象会变成无锁状态,如果再次加锁,还是轻量级锁,不会成为偏向锁。
当线程1获取轻量级锁时,会把mark word保存到线程栈中(为了恢复mark word的无锁状态),并生成一个锁记录压入栈中,再用cas把无锁状态的mark word修改为指向线程栈中mark word的指针,此时如果线程2来竞争,会尝试用cas修改mark word,但是此时的mark word不是无锁状态,所以会修改失败,直到线程1释放锁,把mark word还原成无锁状态,线程2才可以cas修改mark word,所以轻量级锁释放后,会变为无锁状态。
轻量级锁是线程存在轻微竞争的场景,线程交替执行,使用CAS获取锁,如果获取锁失败,则开始膨胀,膨胀期间会创建一个monitor对象,膨胀期间也是在尝试CAS自旋获取锁,最终才会阻塞。。
锁对象状态转换:
17.png
重量级锁在释放锁时也会变回无锁状态。
锁的重入,当线程1获取了锁,会把mark word保存到线程栈中,如果此时再次加锁,会再次生成一个锁记录,进行压栈,等释放锁的时候,会把锁记录逐一出栈。