Java线程之synchronized关键字

1.为什么要使用synchronized关键字

在并发场景下,如果多个线程并发修改同一个对象,那么就极有可能会出现线程安全问题。换句话说,判断一段代码是否会存在线程安全问题,主要判断标准就是,有没有线程共享的变量被并发修改。话不多说,看几个例子,来加深对上面这句话的理解。

public class SynchronizedTest {

    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int j = 0; j < 1000; j++) {
                i++;
            }
        }, "t1");

        Thread t2 = new Thread(() -> {
            for (int j = 0; j < 1000; j++) {
                i++;
            }
        }, "t2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

第一个例子很简单,有一个静态的变量i,然后两个线程t1和t2分别对i进行1000次++操作,主线程等t1和t2执行完成后输出i的值。我们期望的输出结果是2000,但是执行后发现,绝大多数情下输出的结果都是比2000小,有极小的概率是2000。按照上面的标准来判断,首先i是SynchronizedTest类的静态变量,这样无论是t1还是t2访问的都是同一个变量i,其次,t1和t2并发地对变量i进行修改,因此这种场景是有可能会出现线程安全问题的。

至于更底层的原因,我们可以使用字节码来分析,一个i++语句,在被编译器编译完成后,会生成如下图所示的四条虚拟机指令,其中,getstatic:获取i的值,iconst_1:准备一个常量1,iadd:计算i+1的值,putstatic:将结果赋值给变量i。假设当前i的值为1000,然后t1去执行i++语句,这时候它获取的值是1000,执行了前三条虚拟机指令,准备将1001赋值给i,这时候发生了线程切换,t2去执行i++语句,由于t1还没来得及将i的值改为1001,所以t2获取到的i值依然为1000,在这个基础上,假设t2做了10次循环将i的值改为了1010,再次发生线程切换,t1直接将自己上次计算的1001赋值给i,此时i的值又变为了1001。这样就出现了问题,导致大部分场景下得到的结果都小于2000。

再来看第二个例子,t1线程和t2线程几乎同时去调用method()方法,method()方法中,对i进行了1000次的++操作,最后输出i的值。从运行结果来看,每次执行完method()方法,输出的i都是1000。但这是不是就说明这段代码是线程安全的呢?答案是肯定的。

JVM在程序运行时,会为每一个线程开辟一块线程独占的内存空间--虚拟机栈。线程在运行时,每调用一个方法,就会为该方法生成一个栈帧,并把这个栈帧入栈,执行完成后再出栈。可以这样说,线程执行方法就是对应的栈帧入栈和出栈,这里的栈指的是虚拟机栈。每一个栈帧都包含了局部变量表、操作数栈、动态连接、方法返回地址和一些额外信息,这里我们只关注局部变量表。

结合第二个例子,在程序运行时,JVM会为线程t1和t2分别开辟一个虚拟机栈stack_t1和stack_t2,t1执行method()方法时会生成一个栈帧stack_frame_t1,并将其入栈至stack_t1,t2执行method()方法时会生成一个栈帧stack_frame_t2,将其入栈至stack_t2。stack_frame_t1和stack_frame_t2栈帧都包含各自的局部变量i,也就是说t1和t2操作的不是同一个变量,因此也就不存在线程安全问题了。

public class SynchronizedTest {

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            method();
        }, "t1");
        
        Thread t2 = new Thread(() -> {
            method();
        }, "t2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
    
    private static void method() {
        int i = 0;
        for (int j = 0; j < 1000; j++) {
            i++;
        }
        System.out.println(Thread.currentThread().getName() + " : " + i);
    }
}

从上面的代码和分析可知,在多线程场景下,线程安全是一个需要特别注意的问题。一段代码是否会存在线程安全问题的判断标准就是,有没有线程共享的变量被并发修改。那么一段代码存在线程安全问题该如何解决呢?一个重要的方法就是使用synchronized关键字(注:解决线程安全问题的方法有很多,本文重点介绍synchronize关键字)。这也就回答了我们的标题,为什么要使用synchronize关键字,那就是解决线程安全问题。

2.synchronized关键字的用法

synchronized关键字既可以用来修饰一个方法,也可以用来修饰一个代码块。被synchronized修饰的方法或者代码块,同一时刻只能由一个线程执行,这样就可以避免线程安全问题。具体的用法如下:

  • synchronized修饰代码块

synchronized (obj) {
    // 代码
}
  • synchronized修饰方法:
/**
* synchronized修饰成员方法
*/
public void synchronized method() {

}

/**
* synchronized修饰静态方法
*/
public static void synchronized method() {

}

需要注意的是,synchronized修饰方法时虽然没有明确关联对象,但其实成员方法关联的是调用这个方法的对象,静态方法关联的是这个静态方法所在类的class对象

对于上面说到的第一个例子,可以使用synchronized修饰对i进行操作的代码块,就可以解决线程安全问题。修改后的输出结果都是2000。

public class SynchronizedTest {
    
    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized (SynchronizedTest.class) {
                for (int j = 0; j < 1000; j++) {
                    i++;
                }
            }
        }, "t1");

        Thread t2 = new Thread(() -> {
            synchronized (SynchronizedTest.class) {
                for (int j = 0; j < 1000; j++) {
                    i++;
                }
            }
        }, "t2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

3.synchronized关键字的原理

synchronized的用法并不难,但是我们就有点好奇,为什么加了这个关键字就能保证线程安全?它的底层原理是啥?接下来就是这篇文章的核心,我们来分析下synchronized关键字的底层原理。

3.1 重量级锁

3.1.1 markword

在分析synchronized的原理之前,先来看看Java中对象的结构。java中每一个对象在运行时都会拥有一个对象头,用于存储对象的一些附加信息。其中,Mark Word主要用来存储对象的运行时数据;Klass用于存储对象的类型指针,通过该指针可以获取到该对象所属的类信息。这里我们重点关注Mark Word部分,它的组成如下图。64位虚拟机的Mark Word长度为64bits。

  • 当一个对象处于正常状态时,Mark Word的前25位为0,接下来的31位为对象的hashcode,紧接着一位是0,然后四位是对象的年龄(这也是为什么对象的年龄最多为15的原因),后三位是001
  • 当一个对象加了偏向锁时,Mark Word的前54位为给这个对象加锁的线程id,接下来的2位为偏向时间戳,紧接着一位是0,然后四位是对象的年龄,后三位是101
  • 当一个对象加了轻量级锁时,Mark Word的前62位为轻量级锁记录,后两位是00
  • 当一个对象加了重量级锁时,Mark Word的前62位为重量级锁记录,后两位是10
  • 当一个对象被垃圾回收器标记为可回收时,Mark Word的前62位为0,后两位是11

这里我们可以jol第三方工具来查看以下对象头,为了方便查看,这里对ClassLayout的输出结果做了一下转换。

import org.openjdk.jol.info.ClassLayout;

public class ClassLayoutPlus {

    public static String parseInstance(Object obj) {
        String printable = ClassLayout.parseInstance(obj).toPrintable();
        String[] split = printable.split("\n");
        String[] prefix = split[3].substring(76, 111).split(" ");
        String[] suffix = split[2].substring(76, 111).split(" ");
        String result = "";
        for (int i = 3; i >= 0; i--) {
            result += prefix[i] + " ";
        }
        for (int i = 3; i >= 0; i--) {
            result += suffix[i] + " ";
        }
        return result;
    }
}
public class SynchronizedTest {

    private static final Object LOCK = new Object();

    public static void main(String[] args) {
        System.out.println(ClassLayoutPlus.parseInstance(LOCK));
    }
}

输出的结果为:

看这结果有点不对劲呀,为什么对象的hashcode为0呢?看完下面的例子可能就明白了,hashcode需要调用hashcode()方法进行计算,默认是0,所以在没有调用hashcode的时候,对象头中的hashcode是0。这里只展示了正常情况下对象头的内容,关于其他情况,下面会继续介绍。

public class SynchronizedTest {

    private static final Object LOCK = new Object();

    public static void main(String[] args) {
        int hashCode = LOCK.hashCode();
        System.out.println("hashcode:" + Integer.toBinaryString(hashCode));
        System.out.println(ClassLayoutPlus.parseInstance(LOCK));
    }
}

3.1.2 Monitor原理

等等,不是研究synchronized关键字嘛,Monitor又是个什么东西?莫急,我们首先来看一个例子,代码层面就不多说了,直接使用javap -v SynchronizedTest.class反编译这段代码。从字节码中可以看到,在执行System.out.println("do something..");这行代码之前执行了monitorenter指令,之后执行了monitorexit指令。这两个指令与synchronized关键字息息相关。

public class SynchronizedTest {

    private static final Object LOCK = new Object();

    public static void main(String[] args) {
        synchronized (LOCK) {
            System.out.println("do something..");
        }
    }
}

Monitor是一个对象,可以翻译为监视器或者管程,是操作系统提供的,可以用来进行线程调度。它的结构如下:

线程Thread0使用 synchronized 给对象加了重量级锁,就会为该Java对象关联一个Monitor对象,对应的底层实现是调用monitorenter指令。那么这个指令都干了什么事呢?

  • 在Thread0中生成一条锁记录,这条记录保存了锁对象的引用和MarkWord值
  • 为锁对象关联一个Monitor对象,将锁对象头的中Mark Word的前62位设置为Monitor对象的引用地址,后两位设置为10
  • 将Monitor对象的Owner设置为Thread0(需要注意的是,Monitor同一时间只能有一个Owner)

为了看到这个效果,可以看下面这个例子,在线程t1获取到LOCK锁之后,打印一下LOCK对象的MarkWord。从运行结果来看,和我们的预期是一致的。

public class SynchronizedTest {

    private static final Object LOCK = new Object();

    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized (LOCK) {
                for (int j = 0; j < 1000; j++) {
                    i++;
                }
            }
        }, "t1");

        Thread t2 = new Thread(() -> {
            synchronized (LOCK) {
                for (int j = 0; j < 1000; j++) {
                    i++;
                }
            }
        }, "t2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }

}

当Thread0执行完synchronized修饰的方法或代码块后,会释放锁,对应的虚拟机指令就是上面提到的monitorexit。该指令会将对象的MarkWord还原,将Monitor对象的Owner置为null,并且会唤醒EntryList中的线程。

上述过程描述的是线程对对象加锁成功的过程,如果Thread0已经拿到锁了(Thread0已经是锁对象关联的Monitor对象的Owner),此时Thread3、Thread4、Thread5来给对象加锁,就会进入到Monitor的EntryList中,并且的线程状态会被设置为BLOCKED,直至Thread0释放了锁,Thread3、Thread4、Thread5才会重新去竞争。还有一种情况,一个线程Thread1,已经拿到锁了,但是这个线程调用了wait方法,这是Thread1会释放锁,并且加入到Monitor对象的WaitSet中,线程状态被设置为waiting,直至有线程唤醒它或wait时间耗尽,它才会再次尝试去获得锁。从这里就可以看出,wait方法必须在同步代码块中执行(否则都不知道要把这调用了wait方法的线程加入到哪个Monitor的WaitSet中),并且调用wait后,会释放锁。

3.2 轻量级锁

上面介绍了重量级锁的原理,主要是为锁对象关联一个Monitor对象。从名字也可以看出,重量级锁,肯定是一个比较耗费资源和时间的锁,因此Java对它进行了优化,轻量级锁便应运而生。轻量级锁对于开发人员来说是无感知的,这里的优化主要指的是在运行时由JVM底层去优化,它的使用方式和重量锁一模一样。那么什么时候会进行优化?优化都做了什么?

如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。还是上面的例子,只不过这一次t1线程和t2线程是串行执行,并且在t1和t2执行前后,打印了锁对象的MarkWord。

public class SynchronizedTest {

    private static final Object LOCK = new Object();

    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized (LOCK) {
                System.out.println("t1加锁之后的MarkWord:  " + ClassLayoutPlus.parseInstance(LOCK));
                for (int j = 0; j < 1000; j++) {
                    i++;
                }
            }
        }, "t1");

        Thread t2 = new Thread(() -> {
            synchronized (LOCK) {
                System.out.println("t2加锁之后的MarkWord:  " + ClassLayoutPlus.parseInstance(LOCK));
                for (int j = 0; j < 1000; j++) {
                    i++;
                }
            }
        }, "t2");

        System.out.println("t1加锁之前的MarkWord:  " + ClassLayoutPlus.parseInstance(LOCK));
        t1.start();
        t1.join();
        System.out.println("t1释放锁之后的MarkWord:" + ClassLayoutPlus.parseInstance(LOCK));
        t2.start();
        t2.join();
        System.out.println("t2释放锁之后的MarkWord:" + ClassLayoutPlus.parseInstance(LOCK));
    }
}

来分析下上面代码的执行结果。t1加锁之前,LOCK对象处于正常状态,所以Mark Word的后三位是001。t1加了锁之后,由于没有锁竞争,此时synchronized优化为轻量级锁,Mark Word的前62位是锁记录地址,后两位是00。t1释放了锁之后,对象又变成正常状态,所以Mark Word的后三位是001。t2线程加锁之后,此时没有锁竞争,synchronized优化为轻量级锁,Mark Word的前62位是锁记录地址,可以明显地看到和t1的锁记录地址不同,后两位是00。t2释放了锁之后,对象又变成正常状态,所以Mark Word的后三位是001。

那么轻量级锁底层实现和重量级锁有什么不一样呢?重量级锁之所以重,就是因为重量级锁关联了Monitor对象,Monitor对象是操作系统级别的,并且会涉及到线程切换。轻量级锁对此做了优化,由于不存在锁竞争,轻量级锁并不会去关联Monitor对象。它的执行过程如下:

  • 创建锁记录(Lock Record)对象,该对象可以存储锁定对象的Mark Word和引用

  • 让锁记录中的对象引用指向锁对象,采用cas(compare and sweep)的方式,用锁记录地址替换锁对象的Mark Word的前62位,表示由该线程给锁对象加锁把。Mark Word的最后两位设置为00,表示加的是轻量级锁。最后将原先的Mark Word的值存入锁记录中。

  • 如果第二步cas失败,此时分为三种情况:

      1. 锁对象的后两位为10,说明该对象已经被加了重量级锁,直接进入重量级锁的加锁流程;

      2. 锁对象的后两位为00,说明已经有线程给该对象加了轻量级锁,如果是别的线程加的,那么会发生锁膨胀,轻量级锁会升级为重量级锁,这个下一小节再介绍;

      3. 锁对象的后两位为00,说明已经有线程给该对象加了轻量级锁,如果是自己加的,即发生了锁重入,此时会再生成一条锁记录,不过这一条锁记录不再保存锁对象的原始Mark Word,如下图所示

  • 当退出synchronized所修饰的方法或代码块时(解锁时),使用CAS将Java锁对象的Mark Word对象还原

  • 如果解锁失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

3.3 锁膨胀

JVM对synchronized关键字做了优化,在没有线程竞争的场景下,synchronized底层加的是轻量级锁。如果一个线程去给对象加锁,发现这个对象已经被加了轻量级锁,说明此时发生了线程竞争,轻量级锁已经不能再保证线程安全了,JVM会将轻量级锁升级为重量级锁,这个过程称之为锁膨胀。

  • 当Thread1准备给锁对象加轻量级锁时,发现锁对象的Mark Word后两位是00,说明锁对象已经被别的线程加了轻量级锁,此时加锁失败

  • Thread1加锁失败,进入锁膨胀流程

    1. 为锁对象关联一个Monitor对象,让锁对象的前62位指向Monitor对象的地址,后两位变为10(重量级锁的标志)

    2. 将Monitor的owner设置为Thread0

    3. Thread1进入Monitor的EntryList中

  • Thread0执行完同步代码后,会释放锁,当时发现此时锁对象的Mark Word后两位是10,会进入重量级锁的解锁过程。将Monitor的Owner置为null,并唤醒EntryList中的线程。

关于锁膨胀,可以用一段代码验证下。在t1和t2执行之前,LOCK对象处于正常状态,所以Mark Word的后三位是001。t1开始执行,对LOCK加了轻量级锁,此时输出的Mark Word后两位00。t1线程加锁之后睡眠2s(Sleep不会释放锁),在睡眠的过程中,t2线程准备给LOCK对象加轻量级锁,但是发现LOCK对象已经被加了轻量级锁,所以发生锁膨胀,将锁升级为重量级锁。因此,t2获得锁之后,输出的Mark Word后两位为10。

​
public class SynchronizedTest {

    private static final Object LOCK = new Object();

    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized (LOCK) {
                System.out.println("t1加锁之后的MarkWord:  " + ClassLayoutPlus.parseInstance(LOCK));
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                for (int j = 0; j < 1000; j++) {
                    i++;
                }
            }
        }, "t1");

        Thread t2 = new Thread(() -> {
            synchronized (LOCK) {
                System.out.println("t2加锁之后的MarkWord:  " + ClassLayoutPlus.parseInstance(LOCK));
                for (int j = 0; j < 1000; j++) {
                    i++;
                }
            }
        }, "t2");

        System.out.println("t1加锁之前的MarkWord:  " + ClassLayoutPlus.parseInstance(LOCK));
        t1.start();
        TimeUnit.SECONDS.sleep(1);
        t2.start();
        t1.join();
        t2.join();
    }
}

​

3.4 自旋优化

以Thread0和Thread1为例,假设Thread0已经为锁对象加了重量级锁,此时Thread1再给锁对象加锁,就会进入到锁对象关联的Monitor对象的EntryList中,并且会处于BLOCKED状态。一直等到Thread0释放锁,Thread1才会被唤醒,再次去竞争锁,拿到锁后才能执行。由于中间涉及到线程切换,会比较消耗性能,所以JVM底层做了优化:当Thread1发现锁对象已经被Thread0加了重量级锁后,并不会立即进入到EntryList中,而是循环一定次数,在每次循环中都去检查Thread0是否已经释放了锁,如果在这期间Thread0释放了锁,那么Thread1就可以获取到锁去运行,而不需要变为BLOCKED状态;如果循环到达一定次数后,Thread0依然没有释放锁,那么Thread1只好进入到EntryList中,这就是自旋优化。

3.5 偏向锁

如果一个锁自始至终都只有一个线程使用,那么这种场景下轻量级锁还可以做进一步优化。轻量级锁每次重入仍然需要执行 CAS 操作,用锁记录去替换Mark Word。Java 6 中引入了偏向锁对此做了进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后重入时不使用 CAS,只判断Mark Word中的线程id是不是自己的,如果是自己的说明没有竞争,可以继续使用。

偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟。

public class SynchronizedTest02 {

    private static final Object LOCK = new Object();

    public static void main(String[] args) {
        System.out.println(ClassLayoutPlus.parseInstance(LOCK));
    }
}

没加 -XX:BiasedLockingStartupDelay=0参数,没加禁止延迟参数,LOCK对象是正常状态,Mark Word最后三位是001

加了 -XX:BiasedLockingStartupDelay=0参数,没加禁止延迟参数,LOCK对象是可偏向状态,Mark Word最后三位是101

下面的这段代码,运行时加了 -XX:BiasedLockingStartupDelay=0参数,LOCK对象刚开始就处于可偏向状态,所以main线程在给LOCK对象加锁时,优先加了偏向锁。加完偏向锁之后,LOCK对象的Mark Word前54位存储main线程id,表示这个锁对象只属于main线程,这也是偏向锁的由来,后三位为101表示偏向锁。

public class SynchronizedTest02 {

    private static final Object LOCK = new Object();

    public static void main(String[] args) {
        synchronized (LOCK) {
            System.out.println(ClassLayoutPlus.parseInstance(LOCK));
        }
    }
}

当有其他线程也要使用偏向锁对象时(未发生线程竞争,两个线程依次执行),会将偏向锁膨胀为轻量级锁。当然,如果两个线程发生竞争,那么会直接膨胀为重量级锁。这里不再演示。

public class SynchronizedTest02 {

    private static final Object LOCK = new Object();

    public static void main(String[] args) throws InterruptedException {
        synchronized (LOCK) {
            System.out.println(ClassLayoutPlus.parseInstance(LOCK));
        }
        TimeUnit.SECONDS.sleep(1);
        new Thread(() -> {
            synchronized (LOCK) {
                System.out.println(ClassLayoutPlus.parseInstance(LOCK));
            }
        }).start();
    }
}

3.6 批量重偏向

如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 t1的对象仍有机会重新偏向 t2。当撤销偏向锁阈值超过20(默认为20次,可以通过-XX:BiasedLockingBulkRebiasThreshold=20 设置阈值)次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程,而不是升级为轻量级锁。

3.6 批量撤销偏向

当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。

4.总结

  • 线程安全是多线程编程中要特别关注的问题,判断一段代码是否会存在线程安全问题,主要判断标准就是,有没有线程共享的变量被并发修改
  • Java中可以使用synchronized关键字保证线程同步执行,进而解决线程安全问题
  • Java中对象头有一个Mark Word字段,该字段在对象不同的加锁状态下取值不同
  • Java中的重量级锁,实现原理是为被加锁对象关联一个Monitor对象,通过该Monitor对象间接实现线程同步
  • JVM对重量级锁做了优化,在没有线程竞争的场景下,JVM默认先使用轻量级锁
  • 如果一个锁对象自始至终只有一个线程对他加锁,JVM做了进一步优化,即偏向锁
  • 轻量级锁在使用过程中如果出现了线程竞争,会发生锁膨胀,升级为重量级锁
  • 加锁过程可以用如下流程图表示

  • 5
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 8
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值