synchronized 原理使用锁升级过程,写到我要吐血了

Posted 扛麻袋的少年

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了synchronized 原理使用锁升级过程,写到我要吐血了相关的知识,希望对你有一定的参考价值。


  多线程编程中,会出现多个线程同时访问 同一个 共享、可变资源的情况,这个资源我们称之其为 临界资源;这种资源可以是: 对象变量文件等。

  1. 共享:资源可以由多个线程同时访问
  2. 可变:资源在其生命周期内可以被修改

  由于线程执行的过程是不可控的,所以需要采用同步机制,对对象的可变状态进行访问 。实际上,所有的并发模式在解决线程安全问题时,采用的方案都是 序列化访问临界资源。即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问

解决方式:

  加锁 !!!

  不过有一点需要区别的是:当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈(工作内存)中,因此不具有共享性,不会导致线程安全问题。

  Java 中,提供了两种方式来实现同步互斥访问:synchronizedLock,本文主要介绍 synchronized 锁。首先来了解一下 Java 中锁的分类吧。

1.锁的分类

  锁,按照性质的不同,可以分为:显示锁隐式锁 两种

  1. 隐式锁:即 synchronized 加锁,它是 JVM 内置锁,不需要我们手动的加锁与解锁。JVM 会进行自动加锁 & 解锁;

  2. 显示锁:即 JUC 并发包下的 Lock 接口。比如:ReentrantLock,它实现了 Lock 接口,使用 ReentrantLock 时,需要我们在代码中手动的加锁 & 解锁

  根据不同标准,Java 锁还可以分为:悲观锁、乐观锁公平锁、非公平锁可重入锁、非可重入锁共享锁、排他(互斥)锁自旋锁偏向锁、轻量级锁、重量级锁 等。如下图所示:

2.synchronized 含义

  synchronized 是同步锁,用来实现互斥同步。

  在 Java 中,关键字 synchronized 可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作)

  synchronized 还可以保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代 volatile 功能,但是 volatile 更轻量,还是要分场景使用)。

并发编程 - 三大特性,以及 synchronized 在三大特性中的使用,参考:JMM内存模型 & 多线程三大特性

3.synchronized 三种加锁方式

  1. 修饰实例方法
  2. 修饰类方法
  3. 修饰代码块

1.修饰实例方法

  实例对象锁,就是用 synchronized 修饰实例对象中的实例方法,注意:是实例方法不包括静态方法。它锁住的是当前对象(this)。如下:

// synchronzied 修饰实例对象方法
public synchronized void increase() 
    i++;

2.修饰静态方法

  当 synchronized 作用于静态方法时,其锁住的是当前类的 Class 对象锁。由于静态成员不专属于任何一个实例对象,是类成员,因此通过 Class 对象锁可以控制静态成员的并发操作。

  需要注意的是:如果一个线程 A 调用一个实例对象的非 static synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象。因为访问静态 synchronized 方法占用的锁是当前类的 class 对象,而访问非静态 synchronized 方法占用的锁是当前实例对象锁,二者的锁并不一样,所以不冲突。

public static synchronized void increase() 
    i++;

3 修饰代码块

  在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方法对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了。

我们可以使用如下几种对象来作为锁的对象:

1.变量锁

  使用 synchronized,锁住的是变量

public Object synMethod(Object a1) 
    synchronized(a1) 
        // 操作
    

示例:

public class Demo2 

    int i = 0;

    public void synMethod(Object a1) throws InterruptedException 
        synchronized (a1)
            TimeUnit.SECONDS.sleep(2);
            i++;
        
    

    public static void main(String[] args) 
        Demo2 demo = new Demo2();
        // 变量[区别:放在new Thread外层,30个线程使用同一个参数;放在内层,每一个线程使用一个参数]
        Object lock = new Object();
        // 30个线程
        for (int i = 1; i <= 30; i++) 
            new Thread(() -> 
                try 
                    demo.synMethod(lock);
                    System.out.println(Thread.currentThread().getName() + "结束");
                 catch (InterruptedException e) 
                    e.printStackTrace();
                
            ,"线程-" + i).start();
        
    

结果:
  lock 作为参数,放在 new Thread 外,30个线程都使用这1个参数,就是同一把锁。结果就是:每2s输出一次

  如果将 lock 变量的定义,放在 new Thread 代码中,就是每个线程使用一个对象作为synMethod()方法的参数,最终结果就是:等待2s后,一次性输出30条

注意:
  此处需要引用变量,如果说变量是String,则放在new Thread 内层 和 外层 都属于一个参数。这就涉及到 String 在 JVM 中如何存值的问题。一般使用 Object 类型的 lock 作为锁即可

2.实例对象锁

this 代表当前实例,即 new 出来的当前对象。

synchronized(this) 
    for (int j = 0; j < 100; j++) 
		i++;
    

3.当前类的 Class 对象锁

synchronized(AccountingSync.class) 
    for (int j = 0; j < 100; j++) 
        i++;
    

4.synchronized 底层原理

  synchronized 底层时通过内部对象 Monitor(监视器锁) 实现。

  基于进入与退出 Monitor 对象,来实现方法与代码块同步。监视器锁的实现依赖底层操作系统的 Mutex lock(互斥锁:底层有一个互斥量,由操作系统维护,如果要对线程进行阻塞/上下文切换,也会涉及【用户态→内核态】的切换)实现。

  synchronzied 锁,在JDK 6以前,是一个重量级锁,性能较低;JDK6 对 synchronzied 锁进行了比较大的优化,详细优化过程,继续往下看 5.synchronized 锁优化升级过程

  synchronized 翻译成汇编指令,就是 monitorentermonitorexit 。这两个指令保证了同步块的进、出的标志,如图所示。

  我们已经了解了 synchronized 的三种加锁方式。每一个 Object 对象在被创建以后,其都会在 JVM 中维护一个与之相对应的 Monitor 对象。该 Monitor 对象就是控制加锁/解锁的对象我们又叫它 Monitor 管程对象。所以 synchronized 是否加锁,以及锁的其他信息,都在这个对象的 Monitor 管程对象中记录。

示例:

  t1、t2、t3 三个线程,同时来到 monitorenter 临界点,开始共同竞争该对象中的 Monitor 管程对象。 假如线程 t1 拿到管程对象,此时 t2、t3 将会被放到一个 waitSet 的阻塞等待队列中去,此时线程 t1 进入逻辑代码,执行逻辑,执行到 monitorExit 时,t1 会释放 monitor 对象,并发出一个通知,唤醒 waitSet 队列中等待的线程,通知 t2、t3 去抢锁

1.Monitor 管程对象

  我们已经知道了 synchronized 加锁 & 解锁,是通过一个叫做 Monitor 的管程对象来控制的。那这个对象在哪里定义的呢?这个对象又是怎么管理这些锁信息的呢?来聊聊 Monitor 管程对象

  1.每个锁对象里面,都会维护这样一个 ObjectMonitor 对象!!!

  2.Monitor 对象的定义,是在 JVM 源码中实现的。要了解它,就需要来下载 OpenJDK Hotspot 源码进行分析了。下载源码地址:http://hg.openjdk.java.net选择 jdk8,再选择 hotspot,再选择左侧的 browse,最后选择左侧的 zip 进行下载,解压即可。hotspot 更多内容可参考:openJDK_HotSpot源码下载

ObjectMonitor 对象源码地址:\\src\\share\\vm\\runtime\\objectMonitor.hpp

1.ObjectMonitor 对象属性说明

  1. _header 属性,对象头(对象加锁信息,都保存在对象头中。该属性在 synchronized 锁升级中,会使用到);
  2. _count 属性,用来计算加锁次数,可重入锁会用到;
  3. _waiters 属性,标识当前有多少处于 wait 等待状态的线程;
  4. _owner 属性,标识当前持有锁的线程(指向当前持有 ObjectMonitor 对象的线程);
  5. _WaitSet 属性,处于 wait 等待状态的线程,会被加入到 waitSet 队列中;
  6. _EntryList 属性,处于等待加锁 block 阻塞状态的线程,会被加入到 entryList 队列中。

2.ObjectMonitor 工作流程

  1. 多个线程同时访问某段同步代码,首先所有线程会进入到 EntryList 队列

  2. 在 EntryList 与 WaitSet 中的线程争夺锁成功获得锁的线程,会将锁对象中的 ObjectMonitor 对象的 count 值 +1,owner 属性设为自己的线程名称

  3. 如果线程调用 wait() 方法,该线程会放弃争夺当前锁中 ObjectMonitor 对象的权利,进入 WaitSet 线程进行等待,等待被唤醒通过notify() / notifyAll() 方法唤醒,只有被唤醒的线程,才能重新争夺锁资源);

    3.1. 如果当前线程正好是已抢到锁的,则释放当前 monitor,owner 指针置为 NULL,count 减 1,转移到 WaitSet 中;

    3.2. 如果当前线程在 EntryList 队列中,则转移到 WaitSet 中;

  4. 如果抢锁成功的线程执行完毕,释放 monitor 并复位变量的值,owner = NULL,count 减 1,其他在 EntryList 与 WaitSet 中的所有线程开始新一轮所资源的争夺(WaitSet中被唤醒的线程,回去重新抢夺资源)

3.Java 对象内存结构

  1. 对象头:比如 hash码对象所属的年代对象锁锁状态标志偏向锁(线程)ID偏向时间数组长度(数组对象)等
  2. 对象实际数据:即创建对象时,对象中成员变量,方法等
  3. 对齐填充:对象的大小必须是8字节的整数倍

  Java 对象,包括:实例对象、类对象。它们两种对象内存结构基本一致。此处以实例对象说明,如图所示:

4._header 对象头介绍(对象如何加锁)

  synchronized 锁住的只有这 2 种情况,不是1.某个对象,就是2.某个类。在 3.Java 对象内存结构 中,我们已经知道:实例对象和类对象的内存结构基本一致那么对象是如何进行加锁的呢?


  了解 ObjectMonitor 对象属性后,知道 _header 对象头,保存的就是加锁的信息;在 synchronized 锁升级过程中,用到的也会是 _header 属性。此处来重点介绍一下 _header 对象头属性⬇⬇⬇⬇

  HotSpot 虚拟机的对象头包括两部分信息:1.Mark Word  2.MetaData

  第一部分“Mark Word”用于存储对象自身的运行时数据, 如哈希码(HashCode)GC分代年龄锁状态标志线程持有的锁偏向线程ID偏向时间戳等等,这部分数据的长度在 32 位和 64 位的虚拟机(暂不考虑开启压缩指针的场景)中分别为 32 个和 64 个Bits,官方称它为 “Mark Word” 。对象需要存储的运行时数据很多,其实已经超出了 32、64 位 Bitmap 结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。(对象头在 32位 和 64位中,记录还是有点不同的,但是整体上逻辑是一样的)

  如果对象是数组类型,则需要三个机器码,因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。(可参考:3.Java 对象内存结构 结构图)

  在 32 位的 HotSpot 虚拟机中,对象在未被锁定的状态下,MarkWord 的 32 个 Bits 空间中的 25 Bits用于存储对象哈希码(HashCode)4 Bits用于存储对象分代年龄1 Bit 用于存储是否是偏向锁2 Bits用于存储锁标志位。如下表所示

32位虚拟机
25Bit 4Bit1Bit2Bit
对象的HashCode对象的分代年龄是否是偏向锁锁标志位

  在 64 位的 HotSpot 虚拟机中,对象在未被锁定的状态下,MarkWord 的 64 个 Bits 空间中的 25Bits 未使用31 Bits用于存储对象哈希码(HashCode)1Bit 未使用4 Bits用于存储对象分代年龄1 Bit 用于存储是否是偏向锁2 Bits用于存储锁标志位。如下表所示

64位虚拟机
25Bit31Bit 1Bit4Bit1Bit2Bit
unused(未使用)对象的HashCodeunused(未使用)对象的分代年龄是否是偏向锁锁标志位

Hotspot 虚拟机中,对象头存储的内容,源码地址:\\src\\share\\vm\\oops\\markOop.hpp

源码如图所示:

  第二部分是 Meta Data,元数据指针,指向当前实例对象的类,这块和 synchronized 锁没关系,本文不对这块做介绍。主要是 Mark Word,JDK 6 中 synchronized 锁升级就会用到 Mark Word 内容。

2.JDK 6 synchronized 锁优化升级过程

  在 JDK 6 之前,使用 synchronized 就直接是重量级锁,严重影响性能,被人所诟病。在 JDK 6 中,对 synchronzied 锁进行了一次大的改进。改进后的 synchronized 就属于真香系列了。

  到这里,相信你对对象头中的 Mark Word 有了一定的认识。以32位为例,1 Bit 存储锁是否是偏向锁2 Bit 存储锁标志位。在 synchronized 锁升级的过程中,用到的主要就是这两个标识。

  锁的状态总共有四种,无锁状态偏向锁轻量级锁重量级锁随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级针对 synchronized 四种锁状态,Mark Word 中具体存储内容如下:

锁状态 25Bit4Bit1Bit2Bit
23Bit 2Bit是否是偏向锁锁标志位
GC标记11
重量级锁 指向重量级锁Monitor的指针(依赖Mutex操作系统的互斥) 10
轻量级锁 指向线程栈中锁记录的指针(pointer to Lock Record )00
偏向锁线程IDEpoch对象分代年龄101
无锁对象的 hashCode对象分代年龄001

GC标记:指的就是对象所没用了,要回收了

1.锁的四种状态介绍

1.无锁

  初始化时,对象没有被访问,处于无锁状态

2.偏向锁

  当前锁对象,只有一个线程访问,访问不是很激烈时,使用偏向锁。偏向锁在 JDK6 以后才有,开启偏向锁,大概可以提升10%性能。JDK 7 / JDK 8 默认是开启偏向锁的。

开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking

  如果JDK6之前,使用 synchronized 修饰,直接就申请一个互斥锁,另一个线程来了,直接阻塞。【会严重影响性能,所以在 synchronized 优化时,才有了锁4种状态】

  JVM 作者认为大多数线程在进入到锁的状态之后,是没有竞争的,更多的可能是一个单线程的访问,单线程没必要向底层申请一个重量级锁,做一个偏向锁就 OK 了。

3.轻量级锁

  顾名思义,轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量,仅仅将 Mark Word 中的部分字节 CAS更新指向线程栈中的 Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。

  当然,由于轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀为重量级锁。

  适用场景:竞争不激烈,执行时间都不会太长的情况。线程间交替执行,有很短一段时间两个线程存在竞争,此时轻量级锁会让后进来的线程进行自旋,等待前一个线程执行完毕。

  自旋锁的目标,就是降低线程切换的成本。如果锁竞争激烈,我们不得不依赖于重量级锁,让竞争失败的线程阻塞;如果完全没有实际的锁竞争,那么申请重量级锁都是浪费的。

  轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等

示例场景:

  1. 线程1开始执行,如果线程 1 没执行完成时,线程 2 进来了。线程 2 则会进行自旋,自旋不会丢弃 CPU 使用权,自旋锁的目标是降低线程切换的成本,JDK7 之前自旋次数可根据自己的要求手动设置。
  2. 如果自旋指定的次数后,线程 1 还没有执行完毕,则会升级为 重量级锁;
  3. 如果自旋一段时间,线程 1 执行完毕了,线程 2 获得锁后,则继续执行自己的逻辑。

  JDK 7 之后,JVM 可以智能的去调整自旋的最佳次数了。自旋就是一个循环空跑。JDK7 中设计了一个智能算法,自旋次数可以根据上一次自旋成功的次数,智能、弹性的调整本次自旋次数,叫做自适应自旋锁

4.重量级锁

  重量级锁是依赖对象内部的 monitor 锁来实现的,而 monitor 又依赖操作系统的 Mutex Lock(互斥锁)来实现的,所以重量级锁也称为互斥锁。另一个线程来了,直接阻塞。

为什么重量级线程开销很大的?

  当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗 CPU。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长

2.synchronized 锁升级流程图

3.示例演示 synchronized 锁升级全过程

场景:
   两个线程 t1 和 t2,对共享资源进行访问,来演示锁的四个状态变化过程。

锁升级场景图:

32位JVM 中 Mark Word 不同锁状态下的存储内容,此处再来一次,方便查看

锁状态 25Bit4Bit1Bit2Bit
23Bit 2Bit是否是偏向锁锁标志位
GC标记11
重量级锁 指向重量级锁 Monitor 的指针(依赖 Mutex 操作系统的互斥) 10
轻量级锁 指向线程栈中锁记录的指针(pointer to Lock Record )00
偏向锁线程IDEpoch对象分代年龄101
无锁对象的 hashCode对象分代年龄001

1.无锁 → 偏向锁

  线程 t1 访问,此时锁对象 Mark Word 处于无锁状态,检查锁标志位是否位 01,是的话,检查倒数第3位,是无锁状态还是偏向锁状态。

  初次进来,还是无锁状态,线程 t1 会将锁升级为偏向锁(利用CAS算法将倒数第三位修改为1)。偏向锁不会自动释放,只有其他线程和他竞争时才会去释放,否则会一直偏向当前线程 t1。

  此时线程 t1 进入 monitorenter,开始执行同步块内容。此是线程 t2 也来了,和线程 t1 处于并发状态。线程 t2 会去检查当前锁的偏向线程 ID是否是自己的 ID,如果不是自己,则锁已经被别人占用,此时 CAS 会尝试去修改一次(为什么还要修改?考虑到修改的同时,如果线程 t1 刚好释放掉锁,正好就OK),如果 CAS 修改失败,线程 t2 就会向 JVM 虚拟机申请撤销偏向锁。

  线程 t1 此时如果还没执行完,多线程再 CPU 底层调用的是时间片,等到达一个安全点时,此时会再次检查线程 t1 的运行状态。如果线程 t1 运行完毕退出了同步块,此时处于解锁状态,那么通过 CAS 锁,对象会将末三位的状态由 1 变为 0 。然后线程 t2 获取锁,将 Mark Word 对象头中的线程 ID 替换为自己的 ID,开始执行自己的逻辑即可,此时就不需要由偏向锁升级至轻量级锁。

2.偏向锁 → 轻量级锁

  到达安全点,并不意味着线程 t1 已经执行完毕。如果线程 t1 还未执行完毕,那么将开始 偏向锁 → 轻量级锁 的升级。

  暂停线程 t1 ,通过 CAS 设置锁标志位为 00 (变为轻量级锁)。线程 t1 和 t2 在自己线程栈上开辟一块空间 Lock Record,同时将 Mark Word 对象头数据复制一份到自己的 Lock Record 空间中。并将 Mark Word 中的前 30 位指向线程 t1 栈中所记录的指针。

  偏向锁 → 轻量级锁,升级成功。

3.无锁 → 轻量级锁

  初次进入,属于无锁状态。线程 t1 和 线程 t2 存在竞争。

  此时两个线程处于并发状态,每个线程都会在当前线程栈上开辟一块空间 Lock Record,同时将 Mark Word 对象头数据复制一份到自己的 Lock Record 空间中。

  同时还会在 Lock Record 中定义一些变量,比如 owner 等,然后 Mark Word 的前 30 位清空,记录为线程栈中锁记录的指针,同时 Lock Record 的 owner 属性,也有个指针指向 Mark Word,这就是一个双向指针,线程的栈空间和 Mark Word 双向互指对方

  此时线程 t1 和线程 t2 就处于一个竞争状态,目前还不知道哪个线程获得锁,owner 属性等都为空。两个线程都开始通过 CAS算法修改 Mark Word 中的指针指向地址,准备升级为轻量级锁。

  线程 t1 如果修改成功,拿到锁后,Mark Word 中的前 30 位指向线程 t1 栈中所记录的指针。就开始执行自己的同步逻辑块,线程 t2 发现自己修改失败,便进入for( ; ; )自旋阶段。

4.轻量级锁–> 重量级锁

  当线程 t2 自旋一定次数后,发现线程 t1 还没执行完毕,线程 t2 自旋失败后,线程 t2 请求 JVM 进行锁升级,将自己的锁膨胀为重量级锁。同时将 Mark Word 中的前 30 位指针指向重量级锁

  线程 t2 会调用 pThread ,去底层申请一个互斥量。此时就会涉及【用户态 → 内存态】的切换,需要调用系统内核申请互斥量,状态转换是一个非常耗时、耗费资源的过程。(pThread参考:提起线程,你不了解的那些事

  此时,锁的对象头 Mark Word 前 30 位,不再指向当前拥有锁的线程 t1,而是指向重量级锁。然后线程 t2 会调用底层 pThread.Mutex 方法,操作将自己成为阻塞挂起状态。所有的阻塞线程,都会放在 ObjectMonitor 的 waitSet 队列中去。

  此时线程 t1 执行完同步代码后,开始释放锁,通过 CAS 修改 Mark Word,发现前 30位 指针并不是指向自己线程 t1 。这时线程 t1 在释放轻量级锁时,则会去唤醒被阻塞的线程,进行新一轮的锁竞争。

5.锁粗化 & 锁消除 & 逃逸分析

1.锁粗化

  原则上,锁的粒度要尽量小,因为这样可以提高并发度,但是假如一系列的连环操作都是对同一个对象反复加锁,解锁,比如把锁加载在循环体里,单次同步操作的时间也许很短,但是高频反复的锁请求、同步和释放,也会对系统资源造成一定消耗,可能还不如加一把大锁。而锁粗化就是增大锁的作用域,把很多次锁的请求合并成一个请求,以此来降低短时间内大量锁请求、同步、释放带来的性能损耗。

  场景: JVM 会检测到这样一连串的操作都对同一个对象加锁(for 循环内1000次执行append,没有锁粗化得话就要执行1000次加锁/解锁),此时JVM就会将加锁的范围粗化到这一连串的操作的外部(比如for 循环外),使得这一连串操作只需要加一次锁即可。

public class Test 

    StringBuffer sb = new StringBuffer();
    /**
     * 锁的粗化
     */
    public void test() 
		// StringBuffer 线程安全,每个 append 操作都会加锁,针对多次 append 操作的情况,JVM 会对锁进行粗化
		for(int i = 0;i < 1000; i++) 
			sb.append(i);
		
    

2.锁消除

1.什么是锁消除

  虚拟机的即时编译器在运行时,会对一些代码上要求是同步的,但被检测到其实不可能存在共享数据竞争的锁进行消除,主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

  比如 StringBuffer 的 append 方法用了 synchronized 关键词,它是线程安全的。但我们可能仅在线程内部把 StringBuffer 当作局部变量使用:

public class Test 

    /**
     * 锁的消除
     */
    public static String createStringBuffer(String str1, String str2) 
        StringBuffer sBuf = new StringBuffer();
        sBuf.append(str1);// append方法是同步操作
        sBuf.append(str2);
        return sBuf.toString();
   	

  代码中 createStringBuffer 方法中的局部对象 sBuf就只在该方法内的作用域有效,不同线程同时调用 createStringBuffer() 方法时,都会创建不同的 sBuf 对象,因此此时的 append 操作若是使用同步操作,就是白白浪费的系统资源

  这时我们可以通过编译器将其优化,将锁消除,前提是 Java 必须运行在server模式(server模式会比client模式作更多的优化),同时必须开启逃逸分析:

-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks

其中:

  +DoEscapeAnalysis   表示开启逃逸分析,
  +EliminateLocks   表示锁消除。

  逃逸分析比如上面的代码,它要看sBuf是否可能逃出它的作用域?,如果将 sBuf 作为方法的返回值进行返回,那么它在方法外部可能被当作一个全局对象使用,就有可能发生线程安全问题,这时就可以说 sBuf 这个对象发生逃逸了,因而不应将 append 操作的锁消除,但我们上面的代码没有发生锁逃逸,锁消除就可以带来一定的性能提升。

2.锁消除实例

public class Test 

    /**
     * 锁的消除
     */
    public static String createStringBuffer(String str1, String str2) 
        StringBuffer sBuf = new StringBuffer();
        sBuf.append(str1);// append方法是同步操作
        sBuf.append(str2);
        return sBuf.toString();
   	
   	
	public static void main(String[] args) 
        long start = System.currentTimeMillis();
        for (int i = 0; i < 5000000; i++) 
            createStringBuffer("a", "b");
        
        long end = System.currentTimeMillis();
        System.out.println("用时:" + ( end - start) + "ms");
    

关闭逃逸分析、关闭锁消除。JVM配置如下参数:-XX:-DoEscapeAnalysis -XX:-EliminateLocks
用时:337ms

开启逃逸分析、开启锁消除。JVM配置如下参数:-XX:+DoEscapeAnalysis -XX:+EliminateLocks(JDK 8 默认这两者都开启)
用时:155ms

  这就说明了逃逸分析把锁消除了,并在性能上得到了很大的提升。这里说明一下Java的逃逸分析是方法级别的,因为JIT的即时编译是方法级别。【除了方法逃逸,还有线程逃逸,继续看下面的逃逸分析】

3.逃逸分析

面试题: 实例对象在内存中,存储在哪?

  实例对象存储在堆区时:实例对象内存存在堆区,实例的引用存在栈上,实例的元数据class存在方法区或者元空间。但是,实例对象并不一定是存在堆区。只有在对象没有线程逃逸行为时,才全部存在堆区。如果发生线程逃逸行为,部分对象是会存在线程栈中的。


1.什么是逃逸分析

  逃逸分析,是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。 逃逸分析(Escape Analysis)算是目前Java虚拟机中比较前沿的优化技术了。Java 从 JDK6 才开始引入该技术。

2.逃逸分析的原理

  Java 本身的限制(对象只能分配到堆中),我们可以这么理解,为了减少临时对象在堆内分配的数量,我会在一个方法体内定义一个局部变量,并且该变量在方法执行过程中未发生逃逸,按照 JVM 调优机制,首先会在堆内存创建类的实例,然后将此对象的引用压入调用栈,继续执行,这是 JVM 优化前的方式。

  然后,我采用逃逸分析对 JVM 进行优化。即针对栈的重新分配方式,首先找出未逃逸的变量,将该变量直接存到栈里,无需进入堆,分配完成后,继续调用栈内执行,最后线程执行结束,栈空间被回收,局部变量也被回收了。如此操作,是优化前在堆中,优化后在栈中,从而减少了堆中对象的分配和销毁,从而优化性能。

3.逃逸的方式

  方法逃逸:在一个方法体内,定义一个局部变量,而它可能被外部方法引用,比如作为调用参数传递给方法,或作为对象直接返回。或者,可以理解成对象跳出了方法。

  线程逃逸:这个对象被其他线程访问到,比如赋值给了实例变量,并被其他线程访问到了。对象逃出了当前线程。

4.逃逸分析,编译器对代码做了如下优化

  1. 同步消除(锁消除)。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。(例子参考:2.锁消除实例
  2. 将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。(例子参考:逃逸分析实例
  3. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
      这个简单来说就是把对象分解成一个个基本类型,并且内存分配不再是分配在堆上,而是分配在栈上。这样的好处有,一、减少内存使用,因为不用生成对象头。 二、程序内存回收效率高,并且GC频率也会减少,总的来说和上面优点一的效果差不多

5.逃逸分析命令

  • 开启逃逸分析(JDK8中,逃逸分析默认开启)
    -XX:+DoEscapeAnalysis

  • 关闭逃逸分析
    -XX:-DoEscapeAnalysis

  • 查看逃逸分析结果
    jps -l
    jmap -histo id号

6.代码展示

1.创建的对象并没有被方法外使用(发生逃逸)

  for 循环创建 5w 个 People 对象,只创建,方法外并没有使用。采用 JDK8 默认开启逃逸分析

public class Test 

    public static void main(String[] args) 
        for (int i = 0; i < 50000; i++) 
            createObject();
        
        // 休眠100s,只是为了jmap -histo命令查看逃逸情况(不休眠,执行完就结束了,无法查看)
        try 
            Thread.sleep(100000);
         catch (InterruptedException e1) 
            e1.printStack

以上是关于synchronized 原理使用锁升级过程,写到我要吐血了的主要内容,如果未能解决你的问题,请参考以下文章

synchronized关键字实现原理

Synchronized和锁升级

synchronized原理及1.6之后的锁升级优化

Java中锁升级的探究

synchronized 的实现原理以及锁升级详解

Java synchronized关键字的底层实现以及锁升级优化的原理一万字