跳至主要內容

深入理解Synchronized

Yaien Blog原创大约 11 分钟JavaJVMLock

深入理解Synchronized

synchronized关键字是为了处理在Java编程中多线程环境下的数据一致性和安全性的重要问题。
synchronized关键字可以用于方法或代码块,以确保在同一时刻只有一个线程可以访问被保护的资源(临界资源)。

基本概念

临界资源

一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区,其共享资源为临界资源

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

Synchronized

在Java中,所有的对象都有一个内置的锁。当一个线程进入一个synchronized方法或代码块时,它会获取这个锁,并在执行完毕后释放这个锁。其他任何尝试进入这个方法或代码块的线程都会被阻塞,直到当前线程释放锁。

synchronized关键字可以应用于实例方法、静态方法以及代码块。当它应用于实例方法时,锁是与当前对象实例关联的。当它应用于静态方法时,锁是与当前类关联的。当它应用于代码块时,锁是与当前对象实例或类关联的。

基本用法

Synchronized方法

当你声明一个方法为synchronized时,这个方法在同一时刻只能被一个线程访问。例如:

/**
* 实例方法,锁的是该类的实例对象
*/
public synchronized void synchronizedMethod() {  
    // 执行代码...  
}

/**
* 静态方法,锁的是类对象
*/
public static synchronized void synchronizedMethod() {  
    // 执行代码...  
}

Synchronized代码块

除了synchronized方法,你还可以使用synchronized关键字来保护一个代码块。例如:


public void someMethod() {  

    /**
    * 同步代码块,锁的是该类的实例对象
    */
    synchronized (this) {  
        // 执行代码...  
    }  
    
    /**
    * 同步代码块,锁的是该类的类对象
    */
    synchronized (Demo.class) {  
        // 执行代码...  
    }
    
    // 锁对象
    String lock = "lock"
    /**
    * 同步代码块,锁的是配置的实例对象
    */
    synchronized (lock) {  
        // 执行代码...  
    }
}

深入理解

synchronized是JVM的内置锁,基于Monitor机制实现,依赖底层操作系统的互斥原语Mutex(互斥量),是一个重量级锁,性能较低。

JVM在1.5版本后做了许多的优化,例如偏向锁(Biased Locking)、轻量级锁(Biased Locking)、自适应自旋(Adaptive Spinning)、锁消除(Lock
Elimination)、锁粗化(Lock Coarsening)等技术来减少锁操作的性能开销,目前来讲synchronized的并发性能已经基本与Lock持平。

字节码层面的实现

在使用synchronized关键字进行加锁操作时

  • 如果是在同步方法上加,是通过方法中的access_flag设置ACC_SYNCHRONIZED标志来实现
  • 如果是在同步代码块上加,是通过monitorentermonitorexit来实现

Monitor(管程/监视器)机制

Monitor直译为“监视器”,而操作系统领域一般翻译为“管程”。

管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。在java1.5之前,java语言提供的唯一并发语言就是管程,1.5之后提供的SDK并发包也是以管程为基础的。

synchronized关键字以及wait()、notify()、notifyAll()这三个方法就是java中实现管程技术的组成部分。

Monitor设计思路

在管程的发展史上,先后出现过三种不同的管程模型,分别是Hasen模型、Hoare模型和MESA模型。现在正在广泛使用的是MESA模型。

MESA管程模型
MESA管程模型

管程中引入了条件变量的概念,每一个条件变量都对应又一个等待队列。条件变量和等待队列的作用就是解决线程之间的同步问题。

注意:对于使用 wait() 唤醒的时间和获取到锁继续执行的时间是存在差异的,线程被唤醒之后再次执行时条件可能又不满足了。

因此,对于MESA管程来说,使用wait()时会又一个编程范式:

while (条件不满足) {
    wait();
}

通常情况下,我们在编程时尽量使用 notifyAll() 来唤醒线程,若满足以下三个条件,可使用 notify()

  1. 只需要唤醒一个线程
  2. 所有等待线程拥有i相同的等待条件
  3. 所有线等待线程被唤醒后,执行相同的操作

Java内置管程

Java管程实现参考了MESA模型,语言内置的管程(synchronized)对MESA模型进行了简化。在MESA模型中,条件变量时允许多个的,而Java内置的管程只允许一个条件变量。

JAVA管程模型
JAVA管程模型

锁实现原理

锁实现原理
锁实现原理

当一个线程去获取锁的时候,先将当前线程插入到_cxq队列(FILO)的头部

释放锁时默认策略(QMode=0)是:

  • _EntityList为空,则将_cxq中的元素按原有顺序插入到_EntityList,并唤醒第一个线程。意味着当_EntityList为空时,后面来获取锁的线程先去获取锁
  • _EntityList不为空直接从_EntityList中唤醒线程

思考:让后面来获取锁的线程先去获取锁,这样的设计可以让已经睡眠的线程继续,而新来的直接去获取,降低线程的唤醒/睡眠操作

锁实现

JVM锁标识是记录再对象的对象头内的,关于对象内存布局以及对象头中锁标识的记录可查看JVM对象内存布局文章。

在Hotspot中将锁标记分为了:无锁偏向锁轻量级锁重量级锁,锁标记枚举如下:

enum {
    loced_value             =0,     //00  轻量级锁
    unlocked_value          =1,     //001 无锁
    monitor_value           =2,     //10  重量级锁
    marked_value            =3,     //11  GC标记
    biased_lock_pattern     =5      //101 偏向锁锁
}

偏向锁

偏向锁是一种针对加锁操作的优化手段。在多数场景下,锁是不存在多线程竞争的,总是由同一线程多次获得。为了消除对象在没有竞争的情况下锁重入(CAS操作)的开销而引入偏向锁。

延迟偏向

偏向锁机制存在偏向锁延迟机制,HotSpot虚拟机在启动后会有4s的延迟才会对每个新建的对象开启偏向锁模式。

这是因为JVM启动时会进行一系列复杂活动,比如类装载配置,系统类初始化等。在这个过程中会使用大量synchronized关键字为对象加锁,且这些锁大多数都不是偏向锁。为了减少初始化时间,JVM默认延迟加载偏向锁。

匿名偏向

当JVM启用了偏行锁模式(JDK6后默认启用),新创建对象的对行头重ThreadId为0,说明此时处于可偏向但是未偏向任何线程,
也叫做 匿名偏向 状态(anonymously biased)。

例子:

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

输出结果:

#--------------首次创建,处于无锁状态-------------- 
java.lang.Object object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4        (object header: class)    0xf80001e5
 12   4        (object alignment gap)    
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
 
#--------------延迟4s后,处于偏向锁-------------- 
java.lang.Object object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000005 (biasable; age: 0)
  8   4        (object header: class)    0xf80001e5
 12   4        (object alignment gap)    
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

偏向锁撤销-调用HashCode

调用锁对象的hashCode()或者System.identityHashCode(obj)方法会导致该对象的偏向锁被撤销。
原因是对于一个对象,其HashCode只会生成一次并保存,对象头中偏向锁是没有地方保存hashCode的。

需要注意的是,对象处于可偏向(匿名偏向)或者已偏向的状态时,调用hashCode()将会使得对象再无法偏向,并且处理也是不一样的,如下

  • 当对象处于可偏向(匿名偏向)时调用,MarkWord将变成未锁定状态,并只能升级为轻量锁
  • 当对象处于已偏向时调用,将使得偏向锁升级为重量锁

偏向锁撤销-调用wait/notify

偏向锁状态执行notify()会升级为轻量级锁,调用wait(timeout)会升级为重量级锁

轻量锁

当获取偏向锁失败,虚拟机并不会立即升级为重量级锁,他还会尝试使用一种称为轻量级锁的优化手段,而MarkWord的结构也会变为轻量级锁的结构。

轻量级锁适合的场景是线程交替执行同步块的场合,如果存在同一时间多个线程同时访问一把锁的场景时,轻量级锁就会膨胀升级为重量级锁。

轻量锁是否存在自旋

轻量锁加锁失败会自旋,失败一定次数后会膨胀为重量锁这种理解是错误的!

轻量锁不存在自旋,只有重量锁加锁事变才会自旋。重量锁加锁失败,会多次尝试cas和自适应自旋,如果一直加锁失败就会阻塞当前线程,等待唤醒。

之所以这么设计,是因为轻量级锁本身就不是为了处理过于激烈的竞争场景,而是为了应对线程之间交替获取锁的场景。

重量锁

锁升级流程

锁实现原理
锁实现原理

锁优化进阶

批量重偏向&批量撤销

通过偏向锁加解锁过程,当只有一个线程反复进入同步块,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获取锁时,就需要等到safePoint时,再将偏向锁撤销为无锁或升级为轻量锁,这会消耗一定的性能。

所以,当处于多线竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。

批量重偏向

当一个线程创建了大量对象并对这些对象执行初始的同步操作时,后续线程也使用这些对象作为锁对象进行操作。

当另外一个线程来,且在一定时间内(默认为25秒),如果偏向锁撤销的次数达到20次,JVM会执行批量重偏向。这会尝试将这一类锁重新偏向于其他线程。

批量撤销

当偏向锁被频繁撤销时,JVM会采取批量撤销机制。JVM会关闭类的偏向标记,之后现存对象加锁时会升级为轻量级锁,同时锁定中的偏向锁对象会被撤销,新创建的对象默认为无锁状态。

自旋优化

重量级在竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(持有锁的线程已经释放锁了),当前线程就可以直接去获取锁避免阻塞。需要注意的是自旋会占用cpu时间,多核cpu自旋才能发挥优势。

在JDK6之后自旋是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会很高,就会多自旋几次,否则就少自旋甚至不自旋,较为智能。

自旋的目的是为了减少线程挂机的次数,尽量避免直接挂机线程,因为挂记操作设计系统调用,存在用户态和内核态切换,这部分的开销是很大的

锁粗化

锁粗化是为了处理当有一系列连续的操作都会对同一个对象反复加/解锁,甚至连续加锁的操作都是在同一个方法体的情况。
这样的操作即使没有出现线程竞争,频繁地进行同步操作也会导致不必要地性能损耗。

如果JVM检测到有一连串地操作都是对同一对象加锁,将会扩大锁地范围(锁粗化)到整个一连串操作地外部

    StringBuffer buffer = new StringBuffer();
    
    public void append() {
        buffer.append("a").append("b").append("c");
    }

上述为一个锁粗化的简单案例,当JVM检测到之后,会将其合并称一次范围更大的加/解锁操作,既在第一次append时加锁,在最后一次append结束后解锁。

锁消除

锁消除就是删掉一些不必要的加锁操作。

锁消除的操作是在编译期间,对运行上下文扫描,去除不可能存在临界资源竞争的锁(锁消除),以节省毫无意义的请求锁的时间。

public class LockClearExample {
    private int count = 0;

    public static void main(String[] args) {
        LockClearExample example = new LockClearExample();
        for (int i = 0; i < 100000; i++) {
            example.append("aaa", "bbb");
        }
    }

    public void append(String str1, String str2) {
        // StringBuffer是一个线程安全的类,但在这里并不需要同步
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(str1).append(str2);
        // JVM会消除这里的锁,因为没有其他线程访问这个方法
    }
}

在上述示例中,append方法内部使用了StringBuffer,它是一个线程安全的类。然而,由于在这个方法中没有其他线程访问,JVM会自动消除掉这里的同步锁。

上次编辑于:
贡献者: yanggl