0%

Java - synchronized

synchronized,Java中用来实现线程同步的一个关键字,有以下几个特性:

  • 互斥性:同时只能有一个线程持有某个锁,在持有期间,其他竞争线程需要等待锁的释放;
  • 可重入性:一个线程获取了一个锁之后,在释放锁之前,如果嵌套的代码中需要再次获取这个锁(必须是同一个锁对象),则此时不会因为锁没有释放而阻塞;
  • 数据可见性:被synchronized关键字保护的同步代码,会在进入时,从主存读取数据,执行完毕时将线程缓存的数据刷新到主存,因此可以解决多线程的数据可见性问题;
  • 重排序:由于as-if-serial,happens-before的概念约束,因此synchronized能够解决指令重排序的问题。

那么synchronized关键字的原理是啥呢?

反编译synchronized

synchronized方法可以修饰代码块,也可以用来修饰方法,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
public class Main {
public synchronized void func1() {
System.out.println("func1");
}

public void func2() {
synchronized (Main.class) {
System.out.println("func2");
}
}
}

经过javap反编译,过滤掉其他无用信息,得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public synchronized void func1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String func1
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 10: 0
line 11: 8

public void func2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: ldc #5 // class net/cllc/Main
2: dup
3: astore_1
4: monitorenter
5: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #6 // String func2
10: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return

两个函数的区别在于,func1是由ACC_SYNCHRONIZED标志来标识方法进入临界区,而func2是由monitorenter/monitorexit来进入/退出临界区。

  • ACC_SYNCHRONIZED

    由synchronized关键字修饰方法的时候,编译期间会设置常量池中的ACC_SYNCHRONIZED标志位,当方法被调用的时候,调用方法的时候会先检查ACC_SYNCHRONIZED标志位是否被设置,如果设置了,则执行线程会先获取对象的锁,获取成功之后进入方法主体,方法执行完之后释放锁。

    当然执行过程中如果发生了异常,并且方法内部没有处理该异常,则在异常被抛出去之前,锁也会自动被释放。

  • monitorenter/monitorexit

    由synchronized关键字修饰代码块的时候,编译器会在代码块前后插入monitorenter和monitorexit两个指令,monitorenter指令类似一个加锁操作,monitorexit指令类似一个释放锁的操作,当执行到这两个指令的时候,便会去获取,释放对象的锁。

类锁和对象锁

前面提到,指令在获得锁的时候,获取的是synchronized修饰的对象的锁,这里修饰的对象是啥呢?

synchronized修饰的对象的锁,一般分为对象锁和类锁:

  • 对象锁

    比如如下代码,修饰的都是对象锁:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public void func1() {
    synchronized(this) {
    // ...
    }
    }

    public synchronized void func2() {
    // ...
    }

    func1修饰的是this指针指向的对象,当然这里也可以将this换成其他对象指针;func2是一个实例方法,则默认就是修饰的当前实例。

  • 类锁

    比如如下代码,修饰的都是类锁:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public void func3() {
    synchronized(SomeClass.class) {
    // ...
    }
    }

    public static synchronized void func4() {
    // ...
    }

    func3修饰的是某个类;func2是一个静态方法,则默认就是修饰的当前类。

类的对象实例可以有很多个,但是每个类只有一个class对象,所以不同对象实例的对象锁是互不干扰的,但是每个类只有一个类锁。类锁其实只是一个概念上的东西,并不是真实存在的,它只是用来帮助我们理解锁定实例方法和静态方法的区别的。

Java对象头

更详细的Java对象模型的技术细节,推荐阅读OOP-Klass Model相关的知识。

一个Java对象,包含三部分:对象头、实例数据和对齐填充,而对象头中,又包含了一个Mark Word(标记字段)的数据结构。

根据当前对象的锁状态不同,Mark Word的数据存储的结构也不同,在32位虚拟机上的数据结构如下:

1

Mark Word的数据结构被设计成能最大效率的利用存储空间,当最后2个bit的锁标志位不同,前面30个bit存储的数据也不同。

锁状态一共有5种,因此理论上需要3个bit来判断,当锁标志位为01的时候,高位的1bit表示是否是偏向锁,其他情况下,只需要用两个bit来判断锁状态。

JDK1.6之前,synchronized只有一种重量级锁的实现,效率很低,因为实现Synchronized的Monitor机制依赖于底层操作系统的Mutex Lock,而线程状态之间的转换,需要从用户态切换到内核态,这个状态切换所需的时间成本相对较高。自JDK1.6之后,对synchronized进行了优化,引入了偏向锁和轻量级锁,减少了锁的竞争和释放的性能消耗。

锁级别

初始状态

从上图Mark Word数据结构可知,最后三位为001的时候,即使无锁状态。一个Java对象初始化后,最后两位锁标志位为01,而是否是无锁,则要根据-XX:-UseBiasedLocking参数来确定,JDK1.6以后,默认为true。

  • -XX:-UseBiasedLocking=true

    为true表示启用了偏向锁模式,则是否是偏向锁标记位为1,并且thread id字段为0,表示此时处于可偏向状态,但是未偏向任何线程,也称之为匿名偏向状态。

  • -XX:-UseBiasedLocking=false

    如果未启用偏向锁,则是否是偏向锁标记位为0,当前为无锁状态。

偏向锁

代码运行过程中,大多数情况下锁总是由同一个线程多次获得,不存在与其他线程竞争的情况,因此引入偏向锁机制,来降低频繁加解锁的性能消耗。

当线程执行到一段同步代码时候,偏向锁的获取过程如下:

1

  1. 判断Mark Word中的后3位,如果是101,则表示当前是可偏向状态,继续执行,否则表示不是可偏向锁的逻辑,走其他锁的逻辑;

  2. 测试thread id字段的值:

    • thread id的值与当前线程id一致,则表示当前线程已经获得了对象锁,则继续执行同步代码;

      经过偏向锁的优化之后,同一个线程多次重复进入同步代码,只需要很小的,可以忽略不计的性能开销。

    • thread id的值与当前线程不一致,则利用CAS指令,将thread id设置成当前线程的id,如果设置成功,则表示获取了偏向锁,继续执行执行同步代码;否则表示遇到了锁冲突,则进入步骤3;

  3. 如果CAS设置线程ID失败,则表示有其他线程也在竞争锁,此时需要撤销偏向锁。

    偏向锁的撤销,需要等到全局安全点(safepoint),这个时间点上没有任何字节码正在执行,首先暂停拥有偏向锁的线程,判断持有偏向锁的线程是否还活着,如果线程不处于活动状态,则进入步骤4;否则判断线程是否正在执行同步块中的代码,如果否,则进入步骤4;如果是,则进入步骤5。

    • 为什么要判断持有偏向锁的线程是否还活着

      偏向锁是不会主动释放的,只会遇到线程竞争的时候才会去撤销,有可能持有偏向锁的线程已经执行完毕了,但是偏向锁还存在,所以需要判断持有偏向锁的线程是否还活着。

      JVM维护了一个集合,存放所有存活的线程。

  4. 判断是否开启了重新偏向:

    • 没有开启,则撤销偏向锁,将Mark Word后3位设置为001,即是无锁状态,然后进入步骤5;
    • 有开启,则将Mark Word后3位设置为101,然后将线程id指针指向新的thread id,表示获取偏向锁成功,然后唤醒暂停的线程,继续执行同步代码。
  5. 到这一步,锁需要升级成轻量级锁,参加下一节的内容。

详细的源码解读,参考:死磕Synchronized底层实现—偏向锁

上文中,提到了当撤销偏向锁的时候,需要等待一个全局安全点,在这个状态下,所有线程都是暂停状态,因此偏向锁的撤销是有一定代价的,这里采用了一种批量重偏向与批量撤销的机制来优化,具体可以参考Synchronized - 偏向锁

轻量级锁

前面的偏向锁中,提到当出现多个线程竞争锁的时候,便会升级为轻量级锁,锁的升级过程如下:

  1. 线程在执行同步代码块之前,会在当前线程的栈帧中创关键一个名为锁记录(Lock Record)的空间,用于存储当前锁对象目前的Mark Word的拷贝(称之为Displaced Mark Word),以及一个指向对象的指针,如下图:

    1

  2. 拷贝对象头重的Mark Word到Lock Record中,然后使用CAS常识将对象的Mark Word更新为Lock Record的指针,并将Lock Record中的owner指针指向锁对象,如果更新成功了,则说明线程拥有了对象的锁,继续执行同步块中的代码;否则进入步骤3。

  3. 上一步如果更新失败了,则先检查对象的Mark Word是否指向当前线程的栈帧,如果是,则说明当前线程已经拥有了对象的锁,则可以直接进入同步块继续执行;否则说明出现多个线程的竞争,若当前只有一个等待线程,则通过自旋进行等待,若自旋超过一定次数,或者又有第三个线程参与锁的竞争,则锁将升级为重量级锁。

详细的源码解读,参考:死磕Synchronized底层实现—轻量级锁

重量级锁

重量锁是依赖Monitor机制来实现的,Monitor可以理解为一种同步机制,在Java虚拟机中,每个Object对象或者Class类,都通过某种逻辑关联一个Monitor对象,因此也可以理解成每个对象都有一把Monitor锁。当对象的锁类型升级为重量锁时,对象锁的竞争,即是通过操作Monitor对象来实现的。

在Java虚拟机中,Monitor对象由ObjectMonitor实现,其数据结构为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class ObjectMonitor {
public:
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
}

其中几个重要的成员变量:

  • _owner:指向持有当前ObjectMonitor对象的线程

  • _cxq:临时存放无法直接获取锁的线程的队列(存放的是ObjectWaiter对象)

    当一个线程尝试获取锁时,如果此时锁已经被占用,则会将当前线程封装成ObjetWaiter对象,然后存放到cxq队列的尾部,然后调用park函数挂起当前线程。在持有锁的线程释放锁之前,会将cxq中的所有元素移到EntryList中。

  • _EntryList:存放由于等待锁,而处于block状态的线程队列(存放的是ObjectWaiter对象)

  • _WaitSet:存放处于wait状态的线程队列(存放的是ObjectWaiter对象)

    如果一个线程调用了Object#wait方法,则会将该线程放入WaitSet队列中,等待被notify之后,重新移动到EntryList。

几个成员变量的交互关系,可以简单描述为下图:

1

  • 线程尝试获取一个被占用的锁时,会进入EntrySet队列等待;
  • 当锁被释放时,会从EntrySet中挑选一个线程唤醒,但是由于synchronized是非公平锁,因此这个线程不一定能获得锁(有可能会被其他正好进来的线程获取);
  • 如果线程在获得锁之后,调用Object#wait方法,则线程会进入到WaitSet队列中;当被Object#notify唤醒之后,会被移动到EntryList中;

详细的源码解读,参考:死磕Synchronized底层实现—重量级锁

区别

偏向锁只有一个锁在用,每次进入的时候CAS一次就行了,不存在性能开销;轻量级锁表示两个线程交替执行,靠CAS等待锁,开销主要是再CAS的时候,CPU的使用上;重量级锁靠挂起/唤醒机制。

第一个性能最优,第二个不涉及到线程状态切换,第三个最重。

  • 当对象处于偏向锁状态时,它认为现在只有一个线程在访问他,因此会偏向访问它的线程。此线程每次获取锁的时候,只需要对比对象头重的thread id值来判断是否持有锁,如果没有持有,则也只需要一次CAS操作即可,代价非常小;
  • 当对象处于轻量级锁状态时,它认为现在只有两个线程在交替的访问他,因此在获得锁的时候,采用自旋的方式,稍微等待一下即可,无需挂起/唤醒线程;
  • 当对象处于重量级锁状态时,它认为现在锁的竞争较为复杂,如果采用CAS,则会导致CPU空转,因此需要调用底层的Mutex Lock机制,阻塞其他线程,防止CPU空转。

锁优化

synchronized的偏向锁,轻量级锁,都是对synchronized的优化,除了这种优化之外,还有一些其他的锁优化策略。

锁消除

锁消除即删除不必要的加锁操作,JVM虚拟机通过一种逃逸分析(Escape Analysis)的技术,判断同步块所使用的锁对象是否只能够被一个线程访问,如果是的话,则JIT编译器在编译这个同步块的时候,会取消对这部分代码的同步。

例如如下代码:

1
2
3
4
public void test(String str1, String str2) {
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}

其中,StringBuffer#append的源码为:

1
2
3
4
5
6
@Override
public synchronized StringBuffer append(int i) {
toStringCache = null;
super.append(i);
return this;
}

可以看到StringBuffer#append是一个同步方法,但是在实际执行test函数过程中,由于sb是一个局部变量,并不会从方法中逃逸出去,即使test被多线程访问,则每个线程都会有线程私有的一个sb变量,不会冲突,所以整个过程是线程安全的,在JIT编译的过程中,会将锁消除掉。

这种优化通过反编译无法验证,因为大部分的优化,都是在javac编译阶段发生的,所以可以通过反编译class文件观察优化的结果,而锁优化是通过JIT即时编译器优化的,因此无法提现在class文件中。

锁粗化

我们被教导在写代码的时候,要注意锁的范围,粒度越细越好,避免将无用代码也纳入到同步块中。这种方式没有错,但是如果如果在一段代码中连续的对同一个对象反复加锁解锁,其实是相对耗费资源的,这种情况可以适当放宽加锁的范围,减少性能消耗,这就是锁粗化。

当JIT发现一系列连续的操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中的时候,会将加锁同步的范围扩散(粗化)到整个操作序列的外部。考虑如下代码:

1
2
3
4
5
6
7
8
9
public class A {
StringBuffer stringBuffer = new StringBuffer();

public void append(){
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
}
}

每次调用StrinbBuffer#append方法都需要加锁解锁,因此虚拟检测到有一系列连串的对同一个对象的加锁和解锁操作,就会将其合并,在第一次append方法时加锁,在最后一次append方法结束之后解锁。

参考



-=全文完=-