Java并发编程之synchronized

72 阅读8分钟

什么是synchronized

synchronized是Java提供的用于保证多线程环境下代码同步执行的的关键字,是基于jvm层面实现的锁。

synchronized用法

  1. 加在对象上
public static void main(String[] args) {
    A a = new A();
    synchronized (a){

    }
}
  1. 加在方法上
public synchronized void methodA(){
    
}
  1. 作用在类上
public class SynchronizedTest {

    public static void main(String[] args) {

        synchronized (SynchronizedTest.class){

        }
    }
}

synchronized原理

synchronized同步块字节码

synchronized是通过jvm来实现的,通过调用底层操作系统的monitor监视器对象进行加锁。 来看如下代码:

public class SynchronizedTest {

    public static void main(String[] args) {
        A a = new A();
        int x = 10;
        int y = 2;
        int result = 0;
        synchronized (a){
            result = x + y;
            int z = 1 / 0; // 此处会抛异常
        }
        System.out.printf("result:" + result);
    }
}

上述代码对应的字节码文件如下:

 0 new #2 <synchronize/A>
 3 dup
 4 invokespecial #3 <synchronize/A.<init> : ()V>
 7 astore_1
 8 bipush 10
10 istore_2
11 iconst_2
12 istore_3
13 iconst_0
14 istore 4
16 aload_1
17 dup
18 astore 5
20 monitorenter  // 进入同步代码块,获取锁
21 iload_2
22 iload_3
23 iadd
24 istore 4
26 iconst_1
27 iconst_0
28 idiv
29 istore 6
31 aload 5
33 monitorexit  // 退出同步代码块,释放锁
34 goto 45 (+11)
37 astore 7
39 aload 5
41 monitorexit // 退出同步代码块,释放锁
42 aload 7
44 athrow
45 getstatic #4 <java/lang/System.out : Ljava/io/PrintStream;>
48 new #5 <java/lang/StringBuilder>
51 dup
52 invokespecial #6 <java/lang/StringBuilder.<init> : ()V>
55 ldc #7 <result:>
57 invokevirtual #8 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
60 iload 4
62 invokevirtual #9 <java/lang/StringBuilder.append : (I)Ljava/lang/StringBuilder;>
65 invokevirtual #10 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
68 iconst_0
69 anewarray #11 <java/lang/Object>
72 invokevirtual #12 <java/io/PrintStream.printf : (Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream;>
75 pop
76 return

通过字节码文件,可以看到,当执行同步代码块时,会调用monitorenter指令获取锁,退出同步代码块时,则调用monitorexit释放锁。

为什么monitorexit出现了两次?

从字节码文件可以看到,monitorenter指令只出现了一次,而monitorexit却出现了两次。这是因为只要同步代码块执行完后退出,不管是正常运行完,还是运行时抛出了异常,都会执行monitorexit指令来释放锁,保证后面的线程能够正常拿到锁继续执行。

对象头

在jvm中,java的对象在内存中主要有三个部分组成,分别是:对象头,实例数据和对齐填充。

  • 对象头
    Mark Word:存储了hashcode,分代年龄,锁标志,线程id等信息。
    类型指针:指向对象对应的类元数据。
    数组长度:如果是数组对象,那么会有数组长度。
  • 实例数据
    主要存放类的数据信息,对象的字段属性内容。
  • 对齐填充
    jvm要求Java对象所占的内存大小必须是8bit的整数倍,当不是整数倍时会对数据进行填充以满足要求。只是为了字节的对齐。

这其中最重要的部分是对象头,对象头的结构如下图所示(64位虚拟机)
MarkWord结构.png

Monitor

Monitor也叫做“监视器”或“管程”,每个Java对象都可以关联一个Monitor对象,使用synchronized给对象上锁后,该对象的Mark Word就被设置指向Monitor的指针。

Monitor结构 (1).png

  1. obj对象关联一个Monitor锁,刚开始Monitor中的 Owner 为null,没有指向任何线程。
  2. 当 Thread-2 执行 synchronized(obj) 就会将 Mnitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个Owner。
  3. 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入EntryList 阻塞。
  4. Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,注意竞争锁时是非公平的。
  5. 上图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程。

注意:
synchronized必须是进入同一个对象的monitor才有上述的效果,不加synchronized的对象不会关联Monitor,不遵从以上规则

轻量级锁

如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。轻量级锁对使用者是透明的,即语法仍然是synchronized。
假设有两个同步代码块,用同一个对象加锁

final static Object obj = new Object();
public static void method1(){
    synchronized (obj) {
        // 同步块
        method2();
    }
}
public static void method2(){
    synchronized (obj) {
        // 同步块
    }
}
  • 创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word 轻量级锁1.png
  • 让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录 轻量级锁2.png
  • 如果 cas 替换成功,对象头中存储了锁记录地址和状态00,表示由该线程给对象加锁,这时图示如下 轻量级锁3.png
  • 如果 cas 失败,有两种情况
  1. 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
  2. 如果是自己执行了synchronized锁重入,那么再添加一条 Lock Record 作为重入的计数 轻量级锁3 (1).png
  • 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一 轻量级锁3.png
  • 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头。成功,则解锁成功;若失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程。

重量级锁

锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁 重量级锁1.png
  • 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程。即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址,然后自己进入Monitor的EntryList阻塞。 锁膨胀1.png
  • 当Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中阻塞的线程。

自旋

当一个线程尝试获取一个锁时,如果锁已经被其他线程持有,那么线程就需要等待锁的释放。传统的方式是让等待的线程进入阻塞状态,直到锁被释放,然后再唤醒等待的线程。然而,这种阻塞和唤醒的操作会造成一定的开销,包括线程的上下文切换和内核态与用户态之间的切换。相比之下,自旋是一种避免线程阻塞的技术。当一个线程遇到需要等待的情况时,它不会立即进入阻塞状态,而是会不断地检查锁是否被释放,这个循环检查的过程称为自旋。
如果检查到锁被释放了,那么就可以直接获取到锁,即自旋成功。如果在一定时间或一定次数内仍没有获取到锁,则自旋失败,进行自旋的线程要进入阻塞状态。

偏向锁

轻量级锁在没有竞争时,即只有自己这一个线程,但每次重入仍然需要执行CAS操作。Java 6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的 Mark Word 头之后发现这个线程ID是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。

偏向状态

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后3位为 101,这时它的thread、epoch、age 都为 0。
  • 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 JVM 参数XX:BiasedLockingStartupDelay=0 来禁用延迟。
  • 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01即最后3位为 001,这时它的 hashcode、age都为0,第一次用到 hashcode 时才会赋值。

偏向锁撤销

  1. 当一个可偏向的对象调用hashcode方法,会撤销偏向锁。原因是偏向锁的对象的mark word存的是线程ID,已经没有地方存放对象的hashcode了。轻量级锁会在线程栈帧的锁记录里记录hashcode,重量级锁会在Monitor中记录hashcode。
  2. 当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁。
  3. 当调用wait或notify方法时,会撤销偏向锁。因为wait或notify只有重量级锁才有。