多线程——深入剖析 Synchronized

多线程——Synchronized 详解


在上一篇文章中我们详细了解了线程安全的概念,也掌握了在 Java 语言中线程安全的三种实现机制:

  • 互斥同步:悲观并发策略,主要有 Synchronized(块结构) 和 J.U.C.Lock(非块结构)
  • 非阻塞同步:乐观并发策略,主要是 CAS 和 原子类
  • 无同步方案:消除数据的争用,主要是 ThreadLocal

在本篇文章中我们来详细探讨保证线程安全中最常见也是最主要的并发正确性保障手段——互斥同步中的 Synchronized 关键字的细节

一、Synchronzized 的三种使用方式

在详细理解 Synchronized 的底层实现原理之前,我们先需要知道 Synchronized 怎么用,Synchronized 关键字主要有以下三种应用方式,下面将分别介绍:

  • 修饰实例方法:修饰类中的普通方法,对当前实例对象加锁,进入同步代码块之前需要获得当前实例的锁

  • 修饰类方法:所谓类方法就是使用 static 关键字修饰的静态方法,不属于某一个具体实例,而是属于类;对当前的类对象(即 Class对象)加锁,进入同步代码块之前需要获得当前 Class 对象的锁

  • 修饰代码块:可以指定加锁对象,对指定对象加锁,进入同步代码块之前需要获得指定对象的锁;如果未指定加锁对象,就会判断加锁块的方法是静态还是非静态,判断锁定类对象还是实例对象

1、Synchronzized 作用于实例方法

当 Synchronzized 修饰实例方法时,就会是对调用该方法的实例对象加锁,注意是实例方法,也就是普通的方法,未被 static 修饰的方法。示例如下:

public class AccountSync implements Runnable{
    
    
    //共享资源
    public static int i = 0;

    //synchronized 修饰实例方法
    public synchronized void increase(){
    
    
        i++;
//        System.out.println(Thread.currentThread().getName() + " i的值:" + i);
    }

    @Override
    public void run() {
    
    
        for (int i = 0; i < 100000; i++){
    
    
            increase();
        }
    }

    public static void main(String[] args) throws InterruptedException {
    
    
        AccountSync instance = new AccountSync();
        //两个线程争用同一个对象
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
        //执行 join 操作,确保两个线程都能够运行
        t1.join();
        t2.join();

        //打印结果————200000
        System.out.println(i);
    }
}

在上述代码中,我们可以开启若干个线程通过同一个对象操作共享资源 i ,由于 i++ 操作在执行指令的层面并不是原子操作(前面我们讲过原子性的指令只有 8 个),所以如果一个线程在读取旧值和写回新值之间被中断,第二个线程也会读取到旧值并自增,这样两个线程都在旧值的基础上自增,就会导致旧值经过两个线程自增了 2 次,但是数值却只自增了 1 ,造成了线程安全失败;因此 increase 方法必须使用 Synchronzized 方法修饰,以保证线程安全

在此时我们注意到,两个线程是同时争用实例对象 instance 的,也就是两个线程都会尝试获得实例对象 instance 的锁 ,当其中一个线程拿到 instance 的锁之后,它可以访问这个对象的其他任意 Synchronzized 方法;与之对应的是,当一个线程拿到 instance 的锁,其他线程是不能访问这个对象的其他 Synchronzized 方法的,毕竟一个对象只有一把锁,当一个线程获得了该对象的锁之后,其他线程就无法获取到该对象的锁,当然也就无法访问该对象的其他 Synchronzized 方法,但是其他线程还是可以访问该实例对象的其他非 synchronized 方法,因为访问普通方法不需要对象的锁支持的

如果一个线程 A 访问的是实例对象 obj1 的加锁方法 increase,另一个线程 B 访问的是实例对象 obj2 的加锁方法 increase,这样是允许的,因为两个线程执行 increase 方法所需要的对象锁是不一样的,不需要竞争,此时如果两个线程操作的数据并非共享的,线程安全是有保障的;不过如果两个线程操作的是共享数据,那么线程安全就无法保障了。示例代码如下:

public class AccountSyncBad implements Runnable{
    
    
    //共享资源
    public static int i = 0;

    //synchronized 修饰实例方法
    public synchronized void increase(){
    
    
        i++;
//        System.out.println(Thread.currentThread().getName() + " i的值:" + i);
    }

    @Override
    public void run() {
    
    
        for (int i = 0; i < 100000; i++){
    
    
            increase();
        }
    }

    public static void main(String[] args) throws InterruptedException {
    
    
        AccountSyncBad obj1 = new AccountSyncBad();
        AccountSyncBad obj2 = new AccountSyncBad();
        //两个线程操作的不是同一个对象,不需要竞争锁
        Thread t1 = new Thread(obj1);
        Thread t2 = new Thread(obj2);
        t1.start();
        t2.start();
        //执行 join 操作,确定两个线程都能够运行
        t1.join();
        t2.join();

        //由于两个线程使用不同的对象锁操作共享资源,线程安全无法保证,打印结果必然小于 20000
        System.out.println(i);
    }
}

上述代码启动了创建了两个对象,然后启动了两个不同的线程对共享变量 i 进行操作,虽然我们使用了 synchronized 修饰了 increase 方法,但是线程 t1 和线程 t1 使用的是不同的实例对象锁,两个线程都能执行 increase 方法,因此线程安全是无法保证的

解决这种问题的方法是将 synchronized 修饰的方法 increase 修改成静态方法,这样调用此方法的时候需要的锁是当前类对象,不管创建多少个实例对象 obj1、obj2…,但对应的类的 Class 对象只会在类加载的时候创建唯一一个。下面我们来演示以下这种情况

2、Synchronized 作用于静态方法

当 synchronized 修饰静态方法时,执行此方法需要的对象锁就是当前类的唯一 Class 对象锁,由于静态成员不专属于任何一个实例对象,而是属于类成员,而类的 Class 对象注定只会存在一个,因此可以通过 Class 对象锁控制静态成员的并发操作

关于类加载的时候产生 Class 对象,参考我的介绍 JVM 的文章:类加载机制详解

示例代码如下:

public class AccountSyncClass implements Runnable{
    
    
    //共享资源
    public static int i = 0;

    //synchronized 修饰静态方法,执行此方法需要获得唯一的类对象 Class 的锁
    public static synchronized void increase(){
    
    
        i++;
//        System.out.println(Thread.currentThread().getName() + " i的值:" + i);
    }

    //synchronized 修饰普通方法,执行此方法需要获得调用此方法的实例对象的锁
    public synchronized void increaseObj(){
    
    
        i++;
    }

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

    public static void main(String[] args) throws InterruptedException {
    
    
        AccountSyncClass obj1 = new AccountSyncClass();
        AccountSyncClass obj2 = new AccountSyncClass();
        //两个线程操作的不是同一个对象,但是操作同步方法 increase 方法需要获得类对象 Class 的锁
        //所以两个线程存在争用同一个 Class 对象锁,并且由 synchronized 保证线程安全
        Thread t1 = new Thread(obj1);
        Thread t2 = new Thread(obj2);
        t1.start();
        t2.start();
        //执行 join 操作,确定两个线程都能够运行
        t1.join();
        t2.join();

        //打印结果————20000
        System.out.println(i);
    }
}

需要注意的是,如果一个线程调用的是 A 调用的是一个实例对象的非 static 的 synchronized 方法,而另一个线程 B 调用的是这个实例对象所属类的的静态的 synchronized 方法,是允许的,不会发生互斥现象,因为两个线程执行同步代码需要获得的对象锁是不同的——线程 A 需要的是一个具体的实例对象的锁、线程 B 需要的是这个类的唯一类对象 Class 的锁,两者需要的锁资源不同,当然不会发生互斥

但是对于上面代码来说,静态方法 increase 和非静态方法 increaseObj 是同时操作共享数据 i 的,如果不同的线程并发操作这两个方法,还是会发生线程安全问题,产生数据错乱

3、Synchronized 作用于代码块

上面两种同步方式,Synchronized 都是作用于整个方法,在某些情况下,我们编写的方法体可能很大,而且其中存在一些如数据库读写、请求网络资源等耗时比较大的操作,而需要保证线程安全的同步代码又只有一小部分,此时如果对整个方法进行同步操作就得不偿失,此时我们可以使用同步代码块的方式对需要同步的小部分代码进行包裹,这样就避免对整个方法进行同步了

Synchronized 作用于同步代码块的时候,需要指定要加锁的对象,示例如下:

public class AccountSyncCode implements Runnable{
    
    
    static AccountSyncCode instance = new AccountSyncCode();
    //共享资源
    public static int i = 0;

    public void increase(){
    
    
        //...省略的其他的耗时操作

        //synchronized 修饰代码块可以指定加锁的对象
        synchronized (instance){
    
    
            for (int j = 0; j < 10000; j++){
    
    
                i++;
            }
        }
    }

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

    public static void main(String[] args) throws InterruptedException {
    
    
        //两个线程争用同一个对象
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
        //执行 join 操作,确定两个线程都能够运行
        t1.join();
        t2.join();
        //打印结果————20000
        System.out.println(i);
    }
}

不过上面这种方式,我们每次都要提前 new 一个对象才能够锁定该对象,这样每次加锁都要 new 一个对象,太麻烦而且不灵活,不符合 Java 动态语言的特性,而且大多数情况下,需要加锁的对象只有在运行的时候才能够确定,此时是无法提前指定加锁对象的,所以一般情况下 synchronized 作用于代码块都是使用 this 指针,动态的根据方法调用情况来判断锁定哪个对象,示例如下:

public class AccountSync implements Runnable{
    
    
    //共享资源
    public static int i = 0;

    public void increase(){
    
    
        //...省略其他的耗时操作
        
        //synchronized 修饰代码块可以指定为 this,这样就可以在运行时根据哪个实例调用此代码,来锁定对象
        synchronized (this){
    
    
            for (int j = 0; j < 10000; j++){
    
    
                i++;
            }
        }
    }

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

    public static void main(String[] args) throws InterruptedException {
    
    
        AccountSync instance = new AccountSync();
        //两个线程争用同一个对象
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
        //执行 join 操作,确定两个线程都能够运行
        t1.join();
        t2.join();
        //打印结果————20000
        System.out.println(instance.i);
    }
}

二、Synchronized 的底层语义原理

1、Synchronized 的语义原理

Synchronized 是一种块结构的同步语法,Synchronized 关键字经过 Javac 编译后,会在同步块的前后分别形成 monitorenter 和 monitorexit 两个字节码指令,这两个字节码指令都需要一个 reference(引用) 类型的参数来指明要锁定和解锁的对象,对应的就是上面的三种使用方式:

  • 明确指明了对象,则以此对象的引用作为 reference
  • 未明确指定对象(使用 this 指针),将根据方法类型判断锁定实例对象还是类的 Class 对象

在执行 monitorenter 指令时,首先会尝试获取对象的锁。如果这个对象未被锁定,或者当前线程已经持有了对象的锁,就会把锁的计数器的值加一,而在执行 monitorexit 指令时会将锁的计数器的值减一;一旦计数器的值为零,锁随即就被释放了;如果获取对象锁失败,那当前线程就应当被阻塞等待,知道请求锁定的对象被持有它的线程释放,才会苏醒并尝试正确对象的锁

从以上对 monitorenter 和 monitorexit 这两个字节码指令的描述,我们可以发现 Synchronized 的特点:

  • 被 Synchronized 修饰的同步块对于同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现把自己锁死的情况
  • 被 Synchronized 修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件的阻塞后面其他线程的进入。这意味着无法强制已获得锁的线程释放锁,也无法强制正在等待所得线程中断等待或者推出,这些操作一旦发生就只能由操作系统控制,我们程序员无法干预,因此才催生了 ReentrantLock 的产生

2、Synchronized 的局限

Synchronized 的局限——从执行成本的角度来看,持有锁是一个重量级的操作

在前面的文章:多线程—Java内存模型与线程 中,我们已经知道在 HotSpot 中,Java 的线程都是映射到操作系统的原生内核线程之上,如果要阻塞或唤醒一条线程,则需要操作系统来完成,这就不可避免的陷入到用户态和内核态的转换中,进行这种状态的转换需要耗费相当多的系统资源。甚至有些情况下,状态转换所消耗的时间甚至会比用户代码本身执行的时间还要长,这在当今电商活动动辄几千并发,甚至数万、数十万并发的情况下,是很要命的一件事,CPU 如果主要的精力都消耗在线程的切换和调度之上,将会造成网站的极度卡顿,因此说 Synchronized 是 Java 语言中一个重量级的操作

幸运的是,在 JDK 5 到 JDK 6 的升级过程中,官方团队花费了大量的精力在优化 Synchronized 的执行效率上,时至今日 Synchronized 本身的执行效率已经不再成为使用 Synchronized 的瓶颈,而是如何正确的使用 Synchronized 进行代码的编写成为重点。当然这是代码编写层面的东西,需要根据实际业务情况来分析。下面我们来关注一下 JVM 是如何对 Synchronized 进行优化的

三、JVM 对 Synchronized 的优化

我们知道 Synchronized 是基于互斥同步来保证线程安全,互斥同步属于一种悲观的并发策略,其总是认为只要不做正确的同步措施(如加锁),那就肯定会出现问题,无论共享的数据是否真的会出现竞争,它都会进行加锁,使用操作系统级别的互斥量,这将会导致用户态到内核态、维护锁计数器、检查是否有阻塞的线程需要被唤醒等开销

因此针对 Synchronized 的加锁优化可以从下面两个方向来优化:

  • 尽量避免加锁:使用硬件指令集 CAS 等来消除数据无竞争下的同步原语,对应的机制是偏向锁
  • 避免操作系统的干预:即减少操作系统级别的互斥量的使用,对应的机制是轻量级锁、自旋锁

下面来对上面这些优化进行详细讲解

1、轻量级锁

我们知道通俗意义上来讲的锁是通过互斥量来交给操作系统来进行线程的阻塞、唤醒、调度,涉及到用户态和用户态的转换,比较耗时耗资源,因此它被称之为重量级锁

轻量级锁是 JDK 6 时引入的新型锁机制,所谓的 “轻量级” 是相对于使用操作系统互斥量的传统锁的重量级锁,轻量级锁并不是用来代替轻量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗

我们知道加锁的代码只是保证一旦存在并发情况,数据一定不会发生错误。然而事实情况是,除了双十一、双十二、教务系统选课等特定的时间段之外,一段代码大多数情况下并不会存在并发。一天中有那么多的时间,操作系统处理请求的速度又非常快,所以在同一时刻并不会总是有多个并发需要处理

要理解轻量级锁,以及后面讲到的偏向锁的原理和执行流程,我们需要对对象的内存布局(尤其是对象头部分)足够了解,对象在内存中的详细信息参考我关于 JVM 的介绍文章:JMM——对象的创建、内存布局及访问,这里我们主要知道对象头部分

对象头分为两个部分,第一部分用于存储对象自身的运行时数据,如哈希码、 GC 分代年龄等,在 32 位和 64 位机器上分别会占用 32 比特 和 64 比特,官方称之为 “Mark Word”,这部分是实现轻量级锁和偏向锁的关键。存储内容如下:

在这里插入图片描述

轻量级锁的工作流程:在代码即将进入同步块的时候,如果此时对象没有被锁定(锁标志位为 “01”),虚拟机将在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 拷贝(displaced hdr),此时线程堆栈与对象头的状态如下图:

在这里插入图片描述

然后虚拟机将使用 CAS 操作尝试把对象的 Mark Word 更新为指向 Lock Record 的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象 Mark Word 的锁标志位将转变为 “00”,表示此对象处于轻量级锁状态。此时线程堆栈与对象头的状态如下图:

在这里插入图片描述

如果这个更新动作失败了,则意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机会首先检查对象的 Mark Word 是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,则直接进入同步代码块执行即可,否则就说明这个锁对象已经被其他线程抢占了

如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁(在此之前其实还有一个自旋优化),锁标志位将会转变为 “10”,此时 Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待的锁也必须进入阻塞状态

轻量级锁的解锁过程也是通过 CAS 操作来完成的:如果对象的 Mark Word 仍然指向线程的锁记录,那就用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来。如果能成功替换,则整个同步过程就完成了;如果替换失败,则说明有其他线程尝试获取过该对象锁,则线程在释放锁的时候,需要唤醒被挂起的线程

轻量级锁能提升程序同步性能的依据是 “对于绝大部分的锁,在整个同步周期内都是不存在竞争的” 这一经验之谈,如果确实没有竞争,轻量级锁便通过 CAS 操作成功的避免了使用互斥量的开销,避免了用户态到内核态的资源浪费

2、自旋锁

在上面我们说过轻量级锁如果出现线程争用,就会失效并膨胀为重量级锁。重量级锁会把未获得锁的线程阻塞挂起,我们知道挂起线程和唤醒线程的操作需要完成用户态到内核态的转换,消耗大量的资源。事实情况是——在大多数情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段极短的时间去挂起和恢复线程并不值得

现在大多数物理机都是多核处理器系统,如果多核物理机器能够让两个或以上的线程同时并行执行,我们就可以让后面请求锁的线程 “稍等一会儿”,并不会立即挂起,看看持有锁的线程是否会很快释放锁。如果线程很快就释放了锁,这个线程就可以立即尝试获得锁,省却了挂起和唤醒的重量级操作。为了让线程等待,我们需要让线程执行一个忙循环(自旋),这就是所谓的自旋锁

自旋锁的局限

自旋等待本身虽然减少了操作系统级别的用户态—内核态的转换,但是由于自旋等待的线程并未被挂起, CPU 仍然需要不断地轮询和切换自旋线程,所以自旋锁会占用 CPU 的时间。如果锁被占用的时间很短,自旋等待就能够规避大量的用户态—内核态转换;反之如果锁被占用的时间很长,那么自旋等待的线程就会白白的浪费掉 CPU 资源

自旋锁不访问操作系统内核态,在用户态解决锁的争用问题,但是需要 CPU 轮询和切换,占用 CPU 的时间

因此自旋等待必须有一定的限度,默认情况下是自旋 10 次(可以使用参数手动指定),如果自旋 10 次仍然未获得锁,就应该用传统的方式去挂起线程,即使用互斥量的重量级锁

自旋锁的优化——自适应自旋

在 JDK 6 中对自旋锁的优化,引入了自适应自旋。自适应意味着自旋的时间不再是固定的了,而是由 JVM 根据之前在同一个锁上的自选时间以及锁的拥有者状态决定的:

  • 如果在同一个锁对象上,自选等待刚刚获得锁,并且持有锁的线程正在运行,那么 JVM 认为这一次的自选操作大概率也会成功,允许自旋操作相对执行更长时间,如自旋 100 次
  • 如果在同一个锁对象上,自旋操作很少成功获得锁,那在以后要获得这个锁时,甚至会直接省略掉自选操作,避免浪费 CPU 资源

有了自适应自旋,随着程序运行时间的增长以及性能监控信息的完成, JVM 对于程序锁的状况预测将会越来越精准,JVM 将越来越智能


轻量级锁自旋锁的执行流程图如下:

在这里插入图片描述

3、偏向锁

偏向锁也是 JDK 6 引入的一项锁优化措施,如果说轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 都不去做了

偏向锁中的 “偏”,就是偏心、偏袒的意思,它的意思是这个锁会偏向于第一个获得它的线程,如果在后面的执行过程中,该锁一直没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步

假设当前虚拟机启用了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机会把对象头中的标志位设置为 “01”(无锁态),把偏向模式设置为 “1”,表示进入偏向模式;同时使用 CAS 操作把获取到这个锁的线程 ID 记录在锁对象的 Mark Word 之中。如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,就像不存在锁一样

一旦出现另外一个线程去尝试获得锁的情况,偏向模式马上宣告结束。根据锁对象是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为 0 ),撤销后标志位恢复到未锁定(标志位为 “01”),或者恢复到轻量级锁定(标志位为 “00”)的状态,后序的同步操作将按照轻量级锁的流程继续执行


偏向锁的执行流程如下:

在这里插入图片描述

4、总结 Synchronized 的锁升级流程

无锁 ----》偏向锁 ----》轻量级锁 ----》重量级锁

首先一个正常的对象是不需要加锁的,如果认为可能出现并发线程安全问题,就需要使用 Synchronized 加锁:

  1. 此时当第一个线程到来并获得对象锁的时候,JVM 会把对象头中的锁标志位设置为 “01”(无锁状态)、偏向模式设置为 “1”,表示进入偏向状态,此后持有偏向锁的线程每次进入这个锁相关的同步块时,无需进行任何同步操作,近似于无锁,效率极高
  2. 一旦出现锁争用,偏向锁立即失效,将会使用 CAS 操作尝试把对象的 Mark Word 更新为指向 Lock Record 的指针,如果成功则升级为轻量级锁,锁标记位为 “00”(轻量级锁);如果失败,说明锁对象已经被其他线程抢占了
  3. 如果出现两条以上的线程争用同一个线程,则轻量级锁就不再有效,此时除获得锁并执行的线程之外,其余线程会执行自旋操作,可以简单的认为如果自旋操作超过 10 次,锁资源仍然未曾被释放,则对象锁膨胀为重量级锁
  4. 一旦升级为重量级锁,意味着需要使用操作系统级别的互斥量,需要操作系统介入挂起线程和唤醒线程,需要在用户态和内核态之间转换,需要消耗大量的系统资源

参考:《深入理解JAVA虚拟机》

关联文章:

多线程—Java内存模型与线程

多线程——Volatile 关键字详解

多线程——线程安全及实现机制

JMM——对象的创建、内存布局及访问

猜你喜欢

转载自blog.csdn.net/qq_42583242/article/details/108218664