JAVA并发之Synchronized(悲观锁)

时间:2022-08-31 18:02:48

一、关键字介绍

synchronized是Java中的关键字,是一种同步锁。可修饰实例方法,静态方法,代码块。
synchronized是一种悲观锁。

二、使用场景

synchronized可以修饰实例方法,静态方法,代码块。
修饰实例方法:对当前实例加锁,进入同步代码前要获得当前实例的锁
修饰静态方法:对当前类对象加锁,进入同步代码前要获得当前类对象的锁
修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
具体区别详见代码示例:

0.未加锁时状态

public class TestSyn implements Runnable {
    public static final Object lockHelper = new Object();

    static int count = 0;

    public void increase() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    @Override
    public void run() {
        increase();
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new TestSyn());
        Thread t2 = new Thread(new TestSyn());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

该方法启动两线程对同一个变量进行操作,期望结果为20000,但本地运行结果为:17168(每次运行结果不同,但都是小于20000),是由于两个线程同时对该变量进行操作所导致的。加锁即可解决同时进行操作的问题。

1.实例方法

对当前实例加锁,进入同步代码前要获得当前实例的锁

public class TestSyn implements Runnable {
    public static final Object lockHelper = new Object();

    static int count = 0;

    public synchronized void increase() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    @Override
    public void run() {
        increase();
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new TestSyn());
        Thread t2 = new Thread(new TestSyn());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

此处已加锁,但本地运行结果为:15317(每次运行结果不同,但都是小于20000),说明没有锁住。分析代码,syn修饰实例方法,锁住的是对象,但是启动线程时是新new的对象,这也就意味着存在着两个不同的实例对象锁,导致两个线程都拿到了各自的锁,同时进入了increase方法,无法保证线程安全。
对demo进行改进如下(使用同一个对象启动不同线程):

public class TestSyn implements Runnable {
    public static final Object lockHelper = new Object();

    static int count = 0;

    public synchronized void increase() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    @Override
    public void run() {
        increase();
    }

    public static void main(String[] args) throws InterruptedException {
        TestSyn ts = new TestSyn();
        Thread t1 = new Thread(ts);
        Thread t2 = new Thread(ts);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

输出结果与期望一致,为20000,说明锁已生效。

2. 静态方法

对当前类对象加锁,进入同步代码前要获得当前类对象的锁

public class TestSyn implements Runnable {
    public static final Object lockHelper = new Object();

    static int count = 0;

    public synchronized static void increase() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    @Override
    public void run() {
        increase();
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new TestSyn());
        Thread t2 = new Thread(new TestSyn());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

运行结果为20000,证明锁生效。此处虽然与1的失败demo大体一样,但是成功,是因为syn修饰的是静态方法,锁的是类对象,虽然有两个不同实体,但是是同一个类对象,保证线程安全。

3. 修饰一个代码块

指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

public class TestSyn implements Runnable {
    public static final Object lockHelper = new Object();

    static int count = 0;

    public void increase() {
        System.out.println("其他无需保证线程安全的耗时操作");
        synchronized (lockHelper) {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        }
    }

    @Override
    public void run() {
        increase();
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new TestSyn());
        Thread t2 = new Thread(new TestSyn());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

运行结果为20000,证明锁生效。同2,此处锁住的是类的静态变量,所有此类的对象共用一个静态变量,所以能成功锁住,保证线程安全。
与2相比的优点在于,可以将锁粒度降低,只锁需要保证线程安全的代码,其他无需保证线程安全且耗时的操作可以同步进行,增加代码执行效率。

三、底层实现原理

先说结论:syn的底层实现原理是基于Java对象头与Monitor

java对象头


synchronized用的锁是存在Java对象头里的,那么什么是Java对象头呢?Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。下图是Java对象头的存储结构(32位虚拟机):
JAVA并发之Synchronized(悲观锁)
Monitor


Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

下面是锁代码块 示例代码以及对应class文件信息:

public class Test {
    private final static Object lockHelper = new Object();

    public static void main(String[] args) {
        System.out.println("Hello World");
        synchronized (lockHelper) {
            System.out.println("insert Syn...");
        }
    }
}

JAVA并发之Synchronized(悲观锁)

以上,可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令。
其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor,重入时计数器的值也会加 1。倘若其他线程已经拥有 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。(摘自https://blog.csdn.net/javazejian/article/details/72828483
这便是synchronized锁在同步代码块上实现的基本原理。

下面是锁方法 示例代码以及对应class文件信息:

public class Test {
    private final static Object lockHelper = new Object();

    public synchronized void test(){
        System.out.println("test syn");
    }
}

JAVA并发之Synchronized(悲观锁)

以上,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步方法上实现的基本原理。

四、syn的优化(JDK1.6以后)

在早期,synchronized属于重量级锁,效率低下,因为monitor是依赖于底层的操作系统来实现的,而操作系统实现线程之间的切换时需要从用户态(运行用户程序)转换到核心态(运行操作系统程序),这个状态之间的转换需要相对比较长的时间,时间成本相对较高。在JDK6上,实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

自旋锁


背景:线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒降低性能。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。故有自旋锁。
核心思想:让该线程等待一段时间【执行一段无意义的循环(自旋))】,不会被立即挂起,看持有锁的线程是否会很快释放锁。在JDK1.6中自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。

适应自旋锁


背景:自旋虽然可以避免线程切换带来的开销,但占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,浪费性能上。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。 如果手动调整自旋次数,因为无法得知一个合适的数字,会带来诸多不便。如设置为15次,结果17次时才释放锁,这种情况下本该继续等待两次,但是却被挂起,导致性能不到最优。于是JDK1.6引入自适应的自旋锁。
核心思想:若自旋成功,则下次自旋的次数会更多(上次成功,此次很可能也成功)。反之,若某个锁很少自旋成功,则以后获取锁时减少自旋次数甚至省略掉自旋过程,以免浪费处理器资源。

锁消除


背景:在有些情况下,某些代码不可能存在共享数据竞争,此时同步会导致不必要的性能降低。
核心思想:JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。 变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是我们有时在使用一些内置API时,会存在隐形加锁(如StringBuffer的append()、Vector的add())。
示例

    public void test() {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 100; i++) {
            sb.append(i);
        }
    }

在上述代码中,StringBuffer 线程安全,故它的add方法加锁public synchronized StringBuffer append(int i)
但是此处sb为局域变量,不可能引发线程安全问题,故此处可以做锁消除。

锁粗化


背景:在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。 在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。
核心思想:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

偏向锁


背景:大多数情况下,锁总是由同一线程多次获得,因此为了减少同一线程获取锁的代价而引入偏向锁。
核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时可直接获取锁,省去了大量有关锁申请的操作,从而提高程序的性能。
结果:对于锁竞争较少的场合,偏向锁有很好的优化。但是对于锁竞争比较激烈的场合,偏向锁就失效了(大多情况下每次申请锁的线程不同),这种情况下使用偏向锁会降低性能(Mark Word改变结构)。偏向锁失败后,会先升级为轻量级锁。
实现

  • 获取锁
    获取锁的步骤如下:
    a.检测Mark Word是否为可偏向状态(1|01【Mark Word最后两位,下文同】);
    b.若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤e,否则执行步骤c;
    c.如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程d;
    d.通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
    e.执行同步代码块

  • 释放锁
    偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:
    a.暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态;
    b.撤销偏向锁,恢复到无锁状态(01)或者轻量级锁的状态;

具体过程如下图所示:
JAVA并发之Synchronized(悲观锁)

轻量级锁


背景:偏向锁失败,锁升级为轻量级锁,此时Mark Word 的结构也变为轻量级锁的结构。
核心思想:根据经验得知:对绝大部分的锁,在整个同步周期内都不存在竞争。
结果:轻量级锁所适用于是线程交替执行同步块的场合。
实现
- 获取锁
获取锁的步骤如下:
a.判断当前对象是否处于无锁状态(0|01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝;否则执行c;
b.JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤c;
c.判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要进行自适应旋转等待获取锁。

  • 释放锁
    轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
    a.取出在获取轻量级锁保存在Displaced Mark Word中的数据;
    b.用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
    c.如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

具体过程如下图所示:
JAVA并发之Synchronized(悲观锁)

重量级锁


即早期的synchronized,通过monitor实现,monitor依赖于底层的操作系统来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,切换成本高。

各锁的优缺点及适用场景


JAVA并发之Synchronized(悲观锁)

参考资料


  1. 周志明:《深入理解Java虚拟机:JVM高级特性与最佳实践》
  2. blog:深入分析synchronized的实现原理