临界区(Critical Section)

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

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

竞态条件(Race Condition)

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
为了避免临界区的竞态条件发生,有多种手段可以达到目的:
阻塞式的解决方案:synchronized,Lock
非阻塞式的解决方案:原子变量

synchronized底层原理

synchronized是JVM内置锁,基于Monitor机制实现,依赖底层操作系统的互斥原语Mutex(互斥量),它是一个重量级锁,性能较低。当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。
Java虚拟机通过一个同步结构支持方法和方法中的指令序列的同步:monitor。 同步方法是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现;同步代码块是通过monitorentermonitorexit来实现。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态 之间来回切换,对性能有较大影响。
image.png
image.png
注:有两处monitorexit,分别是用于正常执行代码后进行解锁以及出现异常时的解锁操作。

Monitor(管程/监视器)

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

MESA

在管程的发展史上,先后出现过三种不同的管程模型,分别是Hasen模型、Hoare模型和MESA模型。现在正在广泛使用的是MESA模型。
管程中引入了条件变量的概念,而且每个条件变量都对应有一个等待队列。条件变量和等待队列的作用是解决线程之间的同步问题。
image.pngnotify()和notifyAll()分别何时使用
满足以下三个条件时,可以使用notify(),其余情况尽量使用notifyAll():
1. 所有等待线程拥有相同的等待条件;
2. 所有等待线程被唤醒后,执行相同的操作;
3. 只需要唤醒一个线程。

Java语言的内置管程synchronized

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

Monitor机制在Java中的实现

java.lang.Object类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖于 ObjectMonitor 实现,ObjectMonitor其主要数据结构如下:
_cxq:多线程竞争会先存到这个单向链表中(没有竞争到锁的线程存放在这),是栈结构(FILO),所以是非公平锁;
_WaitSet:等待线程(调用wait()方法)组成的双向循环链表,WaitSet是第一个节点(.wait()后进入_);
_EntryList:存放在进入或重新进入时被阻塞(blocked)的线程(也是存竞争锁失败的线程);
注:涉及到阻塞和唤醒,就一定也会涉及到系统调用,因此会从用户态到内核态,开销大
image.png
那么加锁/解锁的标记的过程需要首先识别当前是哪种锁(即锁状态标记)来进行,锁状态存储在对象头的mark word中

对象的内存布局

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

对象头详解

HotSpot虚拟机的对象头包括:
Mark Word
用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。
Klass Pointer
对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 32位4字节,64位开启指针压缩或最大堆内存<32g时4字节,否则8字节。jdk1.8默认开启指针压缩后为4字节,当在JVM参数中关闭指针压缩(-XX:- UseCompressedOops)后,长度为8字节。 数组长度(只有数组对象有)
如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度。 4字节
image.png

Mark Word

Hotspot通过markOop类型实现Mark Word,具体实现位于markOop.hpp文件中。由于对象需要存储的运行时数据很多,考虑到虚拟机的内存使用,markOop被设计成一个非固定的数据结构,以便在极小的空间存储尽量多的数据,根据对象的状态复用自己的存储空间。
简单点理解就是:MarkWord结构搞得这么复杂,是因为需要节省内存,让同一个内存区域在不同阶段有不同的用处。
32位JVM下的对象结构描述
image.png
64位JVM下的对象结构描述
image.png
ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争时,JVM使用原子操作而不是OS互斥,这种技术称为轻量级锁定。在轻量级锁定的情况 下,JVM通过CAS操作在对象的Mark Word中设置指向锁记录的指针。
ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针
更直观的理解方式
image.png
无锁(001):禁用偏向锁或者不满足延迟偏向条件;
偏向锁(101):不存在竞争场景(指偏向某个线程),后续进入同步块的逻辑没有加锁解锁的开销(偏向锁解锁,还是偏向锁,不会变成无锁;偏向锁必须撤销后,才能去升级为轻量级锁);
轻量级锁(00):线程间存在轻微竞争(线程交替执行,临界区逻辑简单),没有自旋,CAS获取锁失败,就直接膨胀升级成重量级锁(释放锁之后,变成无锁);
重量级锁(10):多线程竞争激烈的场景,膨胀期间创建一个monitor对象,有自旋,先CAS自旋,如还不能获取锁,则blocked(阻塞);

偏向锁

偏向锁是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了消除数据在无竞争情况下锁重入(CAS操作)的开销而引入偏向锁。对于没有锁竞争的场合,偏向锁有很好的优化效果。
注:JVM启动期间,是不引入偏向锁,因为在没有竞争的情况下,如果引入偏向锁,然后偏向锁撤销再升级成轻量级锁,这会涉及性能问题。HotSpot启动后有4s的延迟(默认)才会对每个新建的对象开启偏向锁模式。
当JVM启用了偏向锁模式(jdk1.6默认开启),新建对象的Mark Word中的Thread ID为0,说明此时处于可偏向但未偏向任何线程,也称匿名偏向状态;

偏向锁延迟偏向

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

//关闭延迟开启偏向锁
‐XX:BiasedLockingStartupDelay=0
//禁止偏向锁
‐XX:‐UseBiasedLocking
//启用偏向锁
‐XX:+UseBiasedLocking

验证

@Slf4j
public class LockEscalationDemo{

public static void main(String[] args) throws InterruptedException {
    log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
    Thread.sleep(4000);
    log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
    }
}

4s后偏向锁为可偏向或者匿名偏向状态:
image.png

偏向锁撤销

调用对象HashCode

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

synchronized (obj) {
    // 思考:偏向锁执行过程中,调用hashcode会发生什么?
    obj.hashCode();
    //调用notify会发生什么?
    //obj.notify();
    try {
        //调用wait会发生什么?
        //obj.wait(100);
    } catch (InterruptedException e) {
        //e.printStackTrace();
    }

    log.debug(Thread.currentThread().getName() + "获取锁执行中。。。\n"
         + ClassLayout.parseInstance(obj).toPrintable());
}

image.png

调用wait/notify

偏向锁状态执行obj.notify()会升级为轻量级锁,调用obj.wait(timeout) 会升级为重量级锁
image.png
image.png
注:偏向锁和轻量级锁是存在Mark Word中,而Mark Word是存在用户态中,所以锁的撤销都可以通过CAS操作,就不会存在性能问题。

轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段,此时Mark Word的结构也变为轻量级锁的结构。轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间多个线程访问同一把锁的场合,就会导致轻量级锁膨胀为重量级锁。
轻量级锁是否可以降级为偏向锁?
不能,轻量级锁释放后变成无锁

锁对象状态转换

image.png

JVM对synchronized锁优化

偏向锁批量重偏向&批量撤销
自旋优化
锁粗化
锁消除
逃逸分析

偏向锁批量重偏向&批量撤销

从偏向锁的加锁解锁过程中可看出,当只有一个线程反复进入同步块时,偏向锁带来的性能 开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时,再将偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向批量撤销的机制。
原理:以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。
每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的Mark Word中也有该字段,其初始值为创建该对象时class中的epoch的值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其Mark Word的Thread Id改成当前线程Id。
当达到重偏向阈值(默认20)后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。
应用场景:
批量重偏向机制:为了解决一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。
批量撤销机制:为了解决在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。
注:
1.批量重偏向和批量撤销是针对类的优化,和对象无关;
2.偏向锁重偏向一次之后不可再次重偏向;
3.当某个类已经触发批量撤销机制后,jvm会默认当前类产生了严重的问题,剥夺了该类的新实例对象使用偏向锁的权利。

自旋优化(针对重量级锁)

重量级锁竞争的时候,会使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
自旋会占用 CPU 时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势。
在Java6之后自旋是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能。
Java7之后不能控制是否开启自旋功能。
注意:自旋的目的是为了减少线程挂起的次数,尽量避免直接挂起线程(挂起操作涉及系统调用,存在 用户态和内核态切换,这才是重量级锁最大的开销)

锁粗化

如果有一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。 如果JVM检测到有一连串的对同一个对象加锁和解锁的操作,就会将其合并成一次范围更大的加锁和解锁操作(即锁粗化)。

Object obj = new Object();
synchronized (obj) {
    // 代码块1...
}
synchronized (obj) {
    // 代码块2...
}

可以优化为:

Object obj = new Object();
synchronized (obj) {
    // 代码块1...
    // 代码块2...
}

以StringBuffer为例:

StringBuffer buffer = new StringBuffer();
/**
锁粗化
*/
public void append(){
    buffer.append("aaa").append(" bbb").append(" ccc");
}

上述代码每次调用buffer.append方法都需要加锁和解锁,如果JVM检测到有一连串的对同一个对象加锁和解锁的操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

锁消除

锁消除即删除不必要的加锁操作。锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。

@Slf4j
public class LockEliminationTest {
    /**
    * 锁消除 -XX:+EliminateLocks 开启锁消除(jdk8默认开启) -XX:-EliminateLocks 关闭锁
消除
    *
    * @param str1
    * @param str2
    */
    public void append(String str1, String str2) {
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(str1).append(str2);
    }

    public static void main(String[] args) throws InterruptedException {
        LockEliminationTest demo = new LockEliminationTest();
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            demo.append("aaa", "bbb");
        }
        long end = System.currentTimeMillis();
        log.error("执行时间:" + (end - start) + " ms");
    }
}

开启锁消除,JDK8默认开启
image.png
关闭锁消除 -XX:-EliminateLocks
image.png

逃逸分析

逃逸分析,是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而 决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域。
方法逃逸(对象逃出当前方法)
当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。
线程逃逸((对象逃出当前线程)
这个对象甚至可能被其它线程访问到,例如赋值给类变量或可以在其它线程中访问的实例变量。
使用逃逸分析,编译器可以对代码做如下优化:
1.同步省略或锁消除(Synchronization Elimination)。如果一个对象被发现只能从一个线程被访问 到,那么对于这个对象的操作可以不考虑同步。
2.将堆分配转化为栈分配(Stack Allocation)。如果一个对象在子程序中被分配,要使指向该对象 的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
3.分离对象或标量替换(Scalar Replacement)。有的对象可能不需要作为一个连续的内存结构存 在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
关于偏向锁、轻量级锁、重量级锁存在的误区!
1.无锁->偏向锁->轻量级锁->重量级锁? (不存在无锁->偏向锁)
2.轻量级锁自旋获取锁失败,会膨胀升级为重量级锁?(轻量级锁不存在自旋)
3.重量级锁不存在自旋?(恰恰是为了避免直接park,会多次自旋尝试获取锁)

问题一:synchronized加锁在对象上,锁对象是如何记录锁状态的?

需要了解对象的内存布局。锁标记是记录在Mark Word中的:
001 无锁
101 偏向锁
00 轻量级锁
10 重量级锁

问题二:new Object()占几个字节?

64位OS:
对象头:mark word 8字节,类型指针(指针压缩后) 4字节,数组长度:无
实例数据:无
对齐填充:4字节
总共:8+4+4=16

问题三:synchronized自旋发生在哪个阶段?为什么要设计自旋操作?

自旋发生在重量级锁阶段。轻量级锁阶段没有自旋操作,cas获取锁失败,直接开始膨胀逻辑,获取monitor对象后进入重量级锁阶段。在重量级锁获取锁期间,如果cas失败,会进入自适应自旋尝试获取锁,如果一直失败,会park当前线程。重量级锁park涉及系统调用,挂起开销太大。

问题四:synchronized做了哪些优化

偏向锁批量重偏向&批量撤销
自旋优化
锁粗化
锁消除