前言简介

Java中提起synchronized很多人第一反应就是锁,这是不准确的,翻译一下中文意思为同步,锁是概念,抽象名词,同步是动作,操作结果。

由于翻译不准确导致理解上的偏差,好比Robust翻译为中文意思是健壮的,这也是我们学习Java时讲到一个特性,但是有的地方音译为“鲁棒性”,假如有人这么问你你是不是一脸懵逼,同样的还有双亲委派机制,这里就不多说了。

既然说到同步,那肯定就会想到异步,这里就不再延伸概念,同步是为了保证有序执行,多线程是实现异步的一种手段,并非最终目的,两者并没有对等关系。

而Java中对于多线程并发操作中,考虑到内存变量安全的情况就需要同步阻塞,有序操作,变量安全不是说不变,是有序可变。

这里简单说一下JMM(Java Memory Model)Java内存模型,有利于理解实际场景。由于硬件实现的不同,每个厂家都有自己的实现比如Intel的MESI(缓存一致性协议),Java为了屏蔽各种生产差异的不同,定义了一个规范,让Java程序在所有平台的运行效果一致,这也是Java支持跨平台的的原因,JMM规定:

  • 所有的变量都存储在主内存中,包括实例变量,静态变量,不包括局部变量和方法参数。
  • 每个线程都有自己的工作内存,线程的工作内存保存了该线程用到的变量和主内存的副本拷贝,线程对变量的操作都在工作内存中进行。
  • 不同的线程之间也无法访问对方工作内存中的变量。线程之间变量值的传递均需要通过主内存来完成。
  • 线程不能直接读写主内存中的变量。

示意图:
synchronized - 图1

从这个规范出发也就不难理解包括synchronized,volatile关键字的意义,以及ThreadLocal、线程内部TLAB的使用,总的来说JMM定义了原子性、有序性、可见性,这是Java并发的基础。

原理分析

synchronized

在Java中最基本的互斥同步方式就是使用synchronized来修饰一段代码或者方法,通过锁定某个对象的reference来保证代码执行的有序性。

对象内存布局

Java中synchronized锁的是对象,清楚的理解锁实现的原理,首先要明确Java中对象如何组成,Java中针对Hotspot虚拟机对象的内存布局包含三部分

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

实例数据位对象包含的字段信息,对齐填充位非必须内容,这里不做详细分析。

对象头分析

其中对象头主要包含对象自身运行时数据,主要包含mark wordklass pointer两部分,mark word在32位和64位的虚拟机实现中长度分别为32位和64位。

  • mark word,存储了同步状态、标识、hashcode、GC状态等;
  • klass pointer,存储了对象的类型指针,以此来判断是哪个类的实例。

对象头存储内容如下:

锁状态 32bit
25bit 4bit 1bit 2bit
23bit 2bit 偏向模式 标志位
未锁定 对象哈希码 分代年龄 0 01
轻量级锁 指向调用栈中锁记录的指针 00
重量级锁
(锁膨胀)
指向重量级锁的指针 10
GC标记 11
可偏向 线程ID Epoch 分代年龄 1 01

在32位虚拟机中,对象未被同步锁定的状态,空间使用主要为25bit存储哈希码,4bit存储对象分代年龄,2bit用来存储锁标志位,1bit固定为0,对象存储格式如下

  1. // Bit-format of an object header (most significant first, big endian layout below):
  2. //
  3. // 32 bits:
  4. // --------
  5. // hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
  6. // JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
  7. // size:32 ------------------------------------------>| (CMS free block)
  8. // PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)

在64位虚拟机中,JVM会默认使用选项 +UseCompressedOops 开启指针压缩,将指针压缩至32位,可以降低内存使用量50%,对象存储内容如下:

  1. |--------------------------------------------------------------------------------------------------------------|
  2. | Object Header (128 bits) |
  3. |--------------------------------------------------------------------------------------------------------------|
  4. | Mark Word (64 bits) | Klass Word (64 bits) |
  5. |--------------------------------------------------------------------------------------------------------------|
  6. | unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | 无锁
  7. |----------------------------------------------------------------------|--------|------------------------------|
  8. | thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | 偏向锁
  9. |----------------------------------------------------------------------|--------|------------------------------|
  10. | ptr_to_lock_record:62 | lock:2 | OOP to metadata object | 轻量锁
  11. |----------------------------------------------------------------------|--------|------------------------------|
  12. | ptr_to_heavyweight_monitor:62 | lock:2 | OOP to metadata object | 重量锁
  13. |----------------------------------------------------------------------|--------|------------------------------|
  14. | | lock:2 | OOP to metadata object | GC
  15. |--------------------------------------------------------------------------------------------------------------|

各部分含义:

  • lock: 锁状态标记位,该标记的值不同,整个mark word表示的含义不同。
  • biased_lock:偏向锁标记,为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
  • age:Java GC标记位对象年龄。
  • identity_hashcode:对象标识Hash码,采用延迟加载技术。当对象使用HashCode()计算后,并会将结果写到该对象头中。当对象被锁定时,该值会移动到线程Monitor中。
  • thread:持有偏向锁的线程ID和其他信息。这个线程ID并不是JVM分配的线程ID号,和Java Thread中的ID是两个概念。
  • epoch:偏向时间戳。
  • ptr_to_lock_record:指向栈中锁记录的指针。
  • ptr_to_heavyweight_monitor:指向线程Monitor的指针。

对于锁状态位,不同标志对应锁为:

biased_lock lock 状态
0 01 无锁
1 01 偏向锁
0 00 轻量级锁
0 10 重量级锁
0 11 GC标记

JOL使用

JOL全称Java Object Layout,Java对象布局,可以查看一个对象包含的数据信息,打破你对Java对象的想象,以数据说话。JOL使用起来也很简单。
引入maven依赖

  1. <!-- JOL依赖 -->
  2. <dependency>
  3. <groupId>org.openjdk.jol</groupId>
  4. <artifactId>jol-core</artifactId>
  5. <version>0.10</version>
  6. </dependency>

代码示例:

  1. /**
  2. * TestJOL
  3. *
  4. * @author starsray
  5. * @since 2021-11-25
  6. */
  7. public class TestJOL {
  8. public static void main(String[] args) {
  9. TestJOL jol = new TestJOL();
  10. System.out.println(ClassLayout.parseInstance(jol).toPrintable());
  11. }
  12. }

输出结果:

  1. jol.TestJOL object internals:
  2. OFFSET SIZE TYPE DESCRIPTION VALUE
  3. 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
  4. 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
  5. 8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
  6. 12 4 (loss due to the next object alignment)
  7. Instance size: 16 bytes
  8. Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
  • 第一行内容和锁状态内容对应
    unused:1 | age:4 | biased_lock:1 | lock:2
    0 0000 0 01 代表A对象正处于无锁状态
  • 第三行中表示的是被指针压缩为32位的klass pointer
  • 第四行则代表了对象的对齐字段 为了凑齐64位的对象,对齐字段占用了4个字节,32bit
  • 第三行与第四行之间展示的是对象的属性值,这里没有属性,展示为空。

synchronized使用案例

synchronized在Java中为块状语法,在其实现源码中明确指定了对象参数,如果指定了对象,那线程所持有的锁即为指定对象,如果没有指定对象那要根据使用synchronized使用的是实例方法还是静态方法,来确定线程所持有的锁为当前对象实例还是Class对象实例。以下为几种场景:

  • 静态方法
    当synchronized修饰静态方法时,当前线程所持有的锁对象类型为Class类所对应的对象。 ```java public synchronized static void test() {

}

  1. - 实例方法<br />当synchronized修饰实例方法或指定对象为this时,当前线程所持有的锁对象类型为当前代码所在类的实例对象。 等同于
  2. ```java
  3. public synchronized void test() {
  4. }
  1. public void test() {
  2. synchronized (this) {
  3. }
  4. }
  • 同步代码块
    当synchronized修饰代码快指定对象时,当前线程所持有的锁对象类型为指定对象。

    • 普通对象

      1. public class TestSync {
      2. private Object o = new Object();
      3. public void test() {
      4. synchronized (o){
      5. }
      6. }
      7. }
    • class对象

      1. public class TestSync {
      2. public void test() {
      3. synchronized (TestSync.class) {
      4. }
      5. }
      6. }

使用对象作为锁的时候需要注意不可以使用String,字面量的String是一个会存在一个常量池副本,如果不存在会新创建,是可变的,作为锁的这个对象首先是要保证为强引用类型,引用不可变,才能保证为同一把锁。

此外作为同步锁synchronized是可重入的,若一个程序或子程序可以安全的被并行执行,则称其为可重入(reentrant或re-entrant)的;即,当该子程序正在运行时,可以再次进入并执行它。

  1. /**
  2. * 测试同步
  3. *
  4. * @author starsray
  5. * @since 2021-11-25
  6. */
  7. public class TestSync {
  8. public static void main(String[] args) {
  9. SyncChild syncChild = new SyncChild();
  10. syncChild.synMethod();
  11. }
  12. }
  13. class SyncFather{
  14. public synchronized void synMethod(){
  15. System.out.printf("current thread : %s, Method : father %n",Thread.currentThread().getName());
  16. }
  17. }
  18. class SyncChild extends SyncFather{
  19. public synchronized void synMethod(){
  20. super.synMethod();
  21. System.out.printf("current thread : %s, Method : child %n",Thread.currentThread().getName());
  22. }
  23. }

输出结果:

  1. current thread : main, Method : father
  2. current thread : main, Method : child

Java中一个线程可以多次获得锁,而不会出现锁死的情况,这也说明Java中获得对象锁是以线程为粒度,不是每调用。

锁升级

在JDK1.5之前synchronized关键字实现的锁是一个重量级锁,需要操作系统的介入,进行用户态内核态的切换,每次执行都会消耗大量资源,在之后的JDK实现中对此做了一个优化,并不是直接转变为重量级锁,而是存在一个锁升级的过程,因此根据不同的业务场景,并不一定就比JUC包的ReentrantLock的性能要差。

锁升级的过程:

无锁 -> 偏向锁 -> 轻量级锁 -> 自旋锁 -> 重量级锁,且锁升级的顺序是不可逆的。

无锁

没有线程竞争资源,线程没有获取对象锁的状态。

  • 示例代码: ```java package jol;

import org.openjdk.jol.info.ClassLayout;

/**

  • TestNoSync *
  • @author starsray
  • @date 2021/11/25 */ public class TestNoSync {

    public static void main(String[] args) {

    1. TestNoSync noSync = new TestNoSync();
    2. System.out.printf("current Thread : %s",Thread.currentThread().getName());
    3. System.out.println(ClassLayout.parseInstance(noSync).toPrintable());

    } } ```

  • 输出内容:

    1. current Thread : main
    2. jol.TestNoSync object internals:
    3. OFFSET SIZE TYPE DESCRIPTION VALUE
    4. 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
    5. 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
    6. 8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
    7. 12 4 (loss due to the next object alignment)
    8. Instance size: 16 bytes
    9. Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
  • 分析:
    从第一行输出结果来看,当前对象头biased_lock:0 lock:01,为无锁状态。

偏向锁

偏向锁从名字来看重点在于偏,当锁对象第一次被线程获取时,虚拟机会把对象头部lock标志位设置为01,把偏向模式设置为1,表示进入偏向模式,同时通过CAS把获取到这个锁的线程ID记录到对象头的mark word中,如果CAS成功,持有偏向锁的线程进入同步块中都不会进行同步操作,提升了性能。
参考:

import org.openjdk.jol.info.ClassLayout;

/**

  • TestJOL *
  • @author starsray
  • @since 2021-11-25 */ public class TestJOL { public static void main(String[] args) {
    1. try {
    2. Thread.sleep(5000);
    3. } catch (InterruptedException e) {
    4. e.printStackTrace();
    5. }
    6. TestJOL jol = new TestJOL();
    7. System.out.println(ClassLayout.parseInstance(jol).toPrintable());
    } } ```
  • 输出结果

    1. jol.TestJOL object internals:
    2. OFFSET SIZE TYPE DESCRIPTION VALUE
    3. 0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
    4. 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
    5. 8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
    6. 12 4 (loss due to the next object alignment)
    7. Instance size: 16 bytes
    8. Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
  • 分析:
    从第一行输出结果来看,当前对象头biased_lock:1 lock:01,表示当前处于偏向状态。
    为什么所在线程sleep > 5 秒就可以获取偏向锁,OpenJDK JOL示例解释到: 为什么会是5秒呢,而不是其他时间呢,JDK1.6之后偏向锁是默认开启的,但是在JVM启动类加载过程中,偏向锁是最后加载的,而在其他类加载,对象初始化的过程大概需要4s左右,因此5秒才会开始加载偏向锁,可以通过启动参数来关闭延迟加载,或者开启关闭偏向锁。

    1. This is the example of biased locking.
    2. In order to demonstrate this, we first need to sleep for >5 seconds
    3. to pass the grace period of biased locking. Then, we do the same
    4. trick as the example before. You may notice that the mark word
    5. had not changed after the lock was released. That is because
    6. the mark word now contains the reference to the thread this object
    7. was biased to.
    1. //关闭延迟开启偏向锁
    2. -XX:BiasedLockingStartupDelay=0
    3. //禁止偏向锁
    4. -XX:-UseBiasedLocking
    5. //启用偏向锁
    6. -XX:+UseBiasedLocking

轻量级锁

轻量级锁是JDK1.6引入的一种锁机制,目的在于没有多线程竞争的前提下,减少传统的操作系统重量级锁所带来的性能消耗。在有线程竞争时轻量级锁会膨胀为重量级锁。

  • 加锁过程
    同步对象没有被锁定(锁标志位为“01”状态),虚拟机先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(Displaced Mark Word),然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。
  • 解锁过程
    解锁过程也同样是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

  • 轻量级锁
    • 示例代码 ```java package jol;

import org.openjdk.jol.info.ClassLayout;

import java.util.concurrent.CountDownLatch;

/**

  • TestJOL *
  • @author starsray
  • @since 2021-11-25 */ public class TestJOL {

    static CountDownLatch cd = new CountDownLatch(1);

    public static void main(String[] args) throws Exception {

    1. long s = System.currentTimeMillis();
    2. Thread.sleep(5000);
    3. TestJOL testJOL = new TestJOL();
    4. Thread thread = new Thread(() -> {
    5. synchronized (testJOL) {
    6. System.out.println("---> new thread locking <---");
    7. System.out.println(ClassLayout.parseInstance(testJOL).toPrintable());
    8. }
    9. cd.countDown();
    10. });
    11. thread.start();
    12. thread.join();
    13. synchronized (testJOL) {
    14. System.out.println("---> main thread locking <---");
    15. System.out.println(ClassLayout.parseInstance(testJOL).toPrintable());
    16. }
    17. long e = System.currentTimeMillis();
    18. cd.await();
    19. System.out.println(e - s);

    } }

    1. - 输出结果

    —-> new thread locking <—- jol.TestJOL object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 05 48 46 8f (00000101 01001000 01000110 10001111) (-1891219451) 4 4 (object header) 72 01 00 00 (01110010 00000001 00000000 00000000) (370) 8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

—-> main thread locking <—- jol.TestJOL object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 70 f3 ff a9 (01110000 11110011 11111111 10101001) (-1442843792) 4 4 (object header) 39 00 00 00 (00111001 00000000 00000000 00000000) (57) 8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

7403

  1. - 重量级锁
  2. - 示例代码
  3. ```java
  4. package jol;
  5. import org.openjdk.jol.info.ClassLayout;
  6. import java.util.concurrent.CountDownLatch;
  7. /**
  8. * TestJOL
  9. *
  10. * @author starsray
  11. * @since 2021-11-25
  12. */
  13. public class TestJOL {
  14. static CountDownLatch cd = new CountDownLatch(1);
  15. public static void main(String[] args) throws Exception {
  16. long s = System.currentTimeMillis();
  17. Thread.sleep(5000);
  18. TestJOL testJOL = new TestJOL();
  19. Thread thread = new Thread(() -> {
  20. synchronized (testJOL) {
  21. System.out.println("---> new thread locking <---");
  22. System.out.println(ClassLayout.parseInstance(testJOL).toPrintable());
  23. }
  24. cd.countDown();
  25. });
  26. thread.start();
  27. // thread.join();
  28. synchronized (testJOL) {
  29. System.out.println("---> main thread locking <---");
  30. System.out.println(ClassLayout.parseInstance(testJOL).toPrintable());
  31. }
  32. long e = System.currentTimeMillis();
  33. cd.await();
  34. System.out.println(e - s);
  35. }
  36. }
  • 输出结果 ``` —-> main thread locking <—- jol.TestJOL object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 9a a6 cc ad (10011010 10100110 11001100 10101101) (-1379096934) 4 4 (object header) 83 01 00 00 (10000011 00000001 00000000 00000000) (387) 8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

—-> new thread locking <—- jol.TestJOL object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 9a a6 cc ad (10011010 10100110 11001100 10101101) (-1379096934) 4 4 (object header) 83 01 00 00 (10000011 00000001 00000000 00000000) (387) 8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

7367

  1. - 分析<br />从上面示例中main Thread延迟加载,new Thread获取偏向锁,退出代码块后和主线程仍存在竞争关系,此时主线程获取的为轻量级锁,示例代码二中,让两个线程进行竞争获取重量级锁,整个过程的执行时间与示例一代码对比,会出现获取重量级锁但执行时间也会略低于轻量级锁加CAS的过程(这里有待验证)。
  2. <a name="fffbc1fa"></a>
  3. #### 自旋锁
  4. 轻量级锁升级为重量级锁之前,线程执行monitorenter指令进入Monitor对象的EntryList队列,此时会通过自旋尝试获得锁,如果自旋次数超过了一定阈值(默认10),才会升级为重量级锁,等待线程被唤起。
  5. 线程等待唤起的过程涉及到Linux系统用户态和内核态的切换,这个过程是很消耗资源的,自旋锁的引入正是为了解决这个问题,先不让线程立马进入阻塞状态,而是先给个机会自旋等待一下。
  6. 自旋的这个过程是短暂的,如果自旋失败就会通过轻量级锁膨胀为重量级锁,但是如果过多的线程进入自旋状态,会提高CPU负荷,因此线程切换比较的频繁的场景更适合用synchronizedJVM会对自旋的场景进行优化:
  7. - 如果平均负载小于CPUs则一直自旋
  8. - 如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞
  9. - 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
  10. - 如果CPU处于节电模式则停止自旋
  11. - 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
  12. - 自旋时会适当放弃线程优先级之间的差异
  13. <a name="7c2d43bc"></a>
  14. #### 重量级锁
  15. 重量级锁就是需要操作系统来介入,通过互斥量来实现,会消耗过多的性能,前面所提到的过程如果无法避免,最终就会升级到重量级锁。<br />Java中的锁升级过程不可逆,一旦获取重量级锁,当前线程执行完同步代码块才会释放掉所获得的锁。
  16. <a name="844f4c41"></a>
  17. ### 锁优化
  18. Java在发展的过程,从JDK1.5的直接调用系统重量级锁到后面的优化,引入锁升级的过程,这些都是JVM来实现的,在日常开发中如果需要用到同步,也可以从编码细节来对锁的使用进行一个优化。
  19. <a name="6802d66d"></a>
  20. #### 锁粗化
  21. 开发过程中,原则上一般要求使用synchronized的范围能够尽可能的具体,减小作用范围,但是在某些连续操作对一个对象频繁加锁解锁,比如在循环体中,这种原则反而会禁锢。
  22. ```java
  23. public static void main(String[] args) {
  24. for (int i = 0; i < 10; i++) {
  25. synchronized (TestSync.class) {
  26. System.out.println("test");
  27. }
  28. }
  29. synchronized (TestSync.class) {
  30. for (int i = 0; i < 10; i++) {
  31. System.out.println("testa");
  32. }
  33. }
  34. }

锁消除

锁消除指的是Java虚拟机运行时,针对一些同步代码,即时编译器认为对不可能存在数据共享的锁进行消除,锁消除的主要判断依据是源于逃逸分析的数据支持,如果判断代码块中,堆上的所有数据不会逃逸,被其他线程进行访问,就可以把这部分数据当作栈上分配,认为是线程私有的,同步加锁就没有必要无需进行。
例如下面一段代码:

  1. public class TestSync {
  2. public static String test(String s1, String s2, String s3) {
  3. return s1 + s2 + s3;
  4. }
  5. public static void main(String[] args) {
  6. System.out.println(test("1", "2", "3"));
  7. }
  8. }

String是一个不可变类,在JDK1.5之前,对于字符串的连续相加,编译后的字节码使用StringBuffer进行append()操作,而StringBuffer的append()操作是一个线程安全的方法,每次append()操作都是一个以当前对象为锁的同步块,虚拟机通过对变量进行分析,判断这个同步块被局限于方法test()内,里面的变量不会被其他线程访问共享,因此通过JIT进行编译后会忽略掉每次的同步执行,来消除锁带来的性能开销。

锁实现

Java中synchronized更多的说法称之为锁,其实是一种同步结构,这种同步结构借助管程Monitor来实现,方法级的同步为隐式的,不需要通过字节码来控制,虚拟机从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志来判断一个方法是否为同步方法,如果设置了,执行线程只有先持有管程才能执行方法,在执行期间其他线程均无法获得管程,也就不能执行方法。

Java虚拟机指令集中通过monitorenter和monitorexit关键字来确定代码块的开始结束。Java虚拟机规范定义了,在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。

测试代码:

  1. package jol;
  2. /**
  3. * 同步字节码
  4. *
  5. * @author starsray
  6. * @since 2021-11-28
  7. */
  8. public class SyncByteCode {
  9. public void test() {
  10. synchronized (this) {
  11. System.out.println("test sync bytecode");
  12. }
  13. }
  14. public static void main(String[] args) {
  15. SyncByteCode byteCode = new SyncByteCode();
  16. byteCode.test();
  17. }
  18. }

字节码:

  1. // access flags 0x1
  2. public test()V
  3. TRYCATCHBLOCK L0 L1 L2 null
  4. TRYCATCHBLOCK L2 L3 L2 null
  5. L4
  6. LINENUMBER 11 L4
  7. ALOAD 0
  8. DUP
  9. ASTORE 1
  10. MONITORENTER
  11. L0
  12. LINENUMBER 12 L0
  13. GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
  14. LDC "test sync bytecode"
  15. INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
  16. L5
  17. LINENUMBER 13 L5
  18. ALOAD 1
  19. MONITOREXIT
  20. L1
  21. GOTO L6
  22. L2
  23. FRAME FULL [jol/SyncByteCode java/lang/Object] [java/lang/Throwable]
  24. ASTORE 2
  25. ALOAD 1
  26. MONITOREXIT
  27. L3
  28. ALOAD 2
  29. ATHROW
  30. L6
  31. LINENUMBER 14 L6
  32. FRAME CHOP 1
  33. RETURN
  34. L7
  35. LOCALVARIABLE this Ljol/SyncByteCode; L4 L7 0
  36. MAXSTACK = 2
  37. MAXLOCALS = 3

使用场景

单例模式

单例模式是设计模式中最简单,也是最常见的模式,这里使用了Double-Check,synchronized保证了线程安全,volatile关键字,保证了线程之间的可见性,其次在CPU乱序执行中可能会调整类加载机制,优化初始化和赋值的过程,禁止指令重排序,保证了并发安全。

  1. package jol;
  2. /**
  3. * 单例模式
  4. *
  5. * @author starsray
  6. * @since 2021-11-28
  7. */
  8. public class Singleton {
  9. private static volatile Singleton INSTANCE;
  10. private Singleton() {
  11. }
  12. /**
  13. * 获取实例
  14. *
  15. * @return {@link Singleton }
  16. */
  17. public static Singleton getInstance() {
  18. if (INSTANCE == null) {
  19. synchronized (Singleton.class) {
  20. if (INSTANCE == null) {
  21. INSTANCE = new Singleton();
  22. }
  23. }
  24. }
  25. return INSTANCE;
  26. }
  27. }

总结

synchronized在设计之初直接通过操作系统参与,使用互斥量来作为重量解锁,以及后面优化的锁升级过程,都说明了想要熟悉掌握如何使用好锁,必须要深刻了解其内部实现的细节。技术在不断的进步,使用成本在逐步的降低,但是对知识的探索却不能停止。最后总结一下。

  • synchronized是Java实现同步功能的一个重要方式,这种锁是可重入的。
  • JDK对synchronized进行了锁升级优化,无锁-偏向锁-轻量级锁-重量级锁,锁升级的过程是不可逆的,但是JIT会对同步块进行优化进行锁粗化或者锁消除,日常编码中也可以根据场景进行编码优化。
  • 对象内存布局,对象头记录了锁的基本信息,持有线程信息,32为虚拟机和64位虚拟机的头部信息有所差异。
  • JMM定义了线程安全,内存安全等无关操作系统的顶层规范,屏蔽了操作系统等硬件实现带来的差异。

参考文章: