深入了解 synchronized 底层原理

前言

如果一个资源被多个线程共享,为了避免因为资源抢占而导致资源数据错乱,我们需要对线程进行同步,那么 synchronized 就是实现线程同步的关键字,可以说在并发控制中是必不可少的部分,今天就来了解一下 synchronized 的使用和底层原理。

一、synchronized 的特性

1.1 原子性

所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

在 Java 中,对基本数据类型的变量的读取和赋值操作是原子操作,即这些操作是不可被中断的,要么执行,要么不执行。但是像i++、i+=1 等操作字符就不是原子性的,它们是分成读取、计算、赋值几步操作,原值在这些步骤还没完成时就可能已经被赋值了,那么最后赋值写入的数据就是脏数据,无法保证原子性。

被 synchronized 修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放,这中间的过程无法被中断(除了已经废弃的stop()方法),即保证了原子性。

1.2 可见性

可见性是指多个线程访问同一资源时,该资源的状态、值信息等对于其他线程都是可见的。

synchronized 和 volatile 都具有可见性,其中 synchronized 对一个类或对象加锁时,一个线程要想访问该类或对象必须要先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将变量的修改刷新到主存中,保证资源变量的可见性,如果某个线程占用该锁,其他线程就必须在锁池中等待该锁的释放。

而 volatile 的实现类似,被 volatile 修饰的变量,每当值需要修改时都会立即更新主存,主存是共享的,所有线程可见,所以确保了其他线程读取到的变量永远都是最新值,保证可见性。

1.3 有序性

有序性指程序执行的顺序按照代码先后执行。

synchronized 和 volatile 都具有有序性,Java 运行编译器和处理器对指令进行重排,但是指令重排并不会影响单线程程序的顺序,它影响的是多线程并发执行的顺序性。synchronized 保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。

1.4 可重入性

synchronized 和 ReentrantLock 都是可重入锁。当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有锁仍然还可以重复申请锁。

二、synchronized 的用法

synchronized 可以修饰静态方法、成员函数,同时还可以直接定义代码块,但是归根结底它上锁的资源只有两类:一个是对象、一个是

如下:

public class Test1 {

    private int i = 0;
    private static int j = 0;
    private final Test1 instance = new Test1();

    /**
     * 对成员函数加锁,必须获得该类的实例对象的锁才能进入同步块
     */
    public synchronized void add1() {
        i++;
    }

    /**
     * 对静态方法加锁,必须获得类的锁才能进入同步块
     */
    public static synchronized void add2() {
        j++;
    }

    public void method() {
        synchronized (Test1.class) {
            // 同步块,执行前必须先获得 Test1 类的锁
        }

        synchronized (instance) {
            // 同步块,执行前必须先获得实例对象的锁
        }
    }

    public static void main(String[] args) {

    }
}

首先我们知道被 static 修饰的静态方法、静态属性都是归类所有,同时该类的所有实例对象都可以访问。但是普通成员属性、成员方法是归实例化的对象所有,必须实例化之后才能访问,这也是为什么静态方法不能访问非静态属性的原因。我们明确了这些属性、方法归哪些所有之后就可以理解上面几个 synchronized 的锁到底是加给谁的了。

 首先看第一个 synchronized 所加的方法是 add1(),该方法没有被 static 修饰,也就是说该方法是归实例化的对象所有,那么这个锁就是加给 Test1 类所实例化的对象。

然后是 add2() 方法,该方法是静态方法,归 Test1 所有,所以这个锁是加给 Test1 类的。

最后是 method() 方法中两个同步代码块,第一个代码块所锁定的是 Test1.class,通过字面意思便知道该锁是加给 Test1 类的,而下面那个锁定的是 instance,这个 instance 是 Test1 类的一个实例化对象,自然所上的锁是给 instance 实例化对象的。

弄清楚这些锁是上给谁的就应该很容易懂synchronized的使用啦,只要记住要进入同步方法或同步块必须先获得相应的锁才行。那么我下面再列举出一个非常容易进入误区的代码,看看你是否真的理解了上面的解释。

public class Test2 implements Runnable {

    private static int i = 0 ;

    private synchronized void add() {
        i++;
    }

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

    public static void main(String[] args) throws InterruptedException {

        Thread thread1 = new Thread(new Test2());
        Thread thread2 = new Thread(new Test2());
        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println(i);
    }
}

上面的简单意思就是用两个线程分别对 i 加100万次,理论上结果应该是200万,并且使用了 synchronized 锁住了 add() 方法,保证了其线程安全性。但是!!!无论是运行多少次结果都是小于200万的,为什么?

原因就在于 synchronized 加锁的函数,这个方法是普通成员方法,那么锁就是加给对象的,但是在创建线程时却 new 了两个 Test2 实例,也就是说这个锁是给这两个实例加的锁,并没有达到同步的效果,所以才会出现错误。至于为什么小于200万,是因为 i++ 的过程分为四步:①获取 i 的值;②然后入栈;③+1操作;④写入 i 值。那么现在就可以解释为什么实际运行结果小于理论值 200万了,在很多的线程中,某一时刻存在两个(或多个)线程同时获取到 i 的值,也就是说此时每个线程的 i 值都是一样的,都进行了加一之后在写入 i 值,那么实际的效果知识加了一次 1,而却有两个(或多个)去操作了,所以最后的结果是小于理想值的。

三、synchronized 锁的实现

synchronized 有两种形式上锁,一个是对方法上锁,一个是构造同步代码块。它们的底层实现其实都一样,在进入同步代码之前先获取锁,获取到锁之后锁的计数器 +1,同步代码执行完锁的计数器 -1,如果获取失败就阻塞式等待锁的释放。只是它们在同步块识别方式上有所不同,从 class 字节码文件可以表现出来,一个是通过 flags 标志,一个是 monitorenter 和 monitorexit 指令操作。

3.1 同步方法

首先我们新定义一个同步方法然后进行反编译,查看其字节码:

public class Test3 {
    private static int i = 0;

    public void method() {
        synchronized (Test3.class) {
            i++;
        }
    }
}
 public void method();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
       0: ldc           #2                  // class com/lwstudy/leetcode/Test3
       2: dup
       3: astore_1
       4: monitorenter                      // 监视器进入,获取锁
       5: getstatic     #3                  // Field i:I
       8: iconst_1
       9: iadd
      10: putstatic     #3                  // Field i:I
      13: aload_1
      14: monitorexit                       // 监视器退出,释放锁
      15: goto          23
      18: astore_2
      19: aload_1
      20: monitorexit
      21: aload_2
      22: athrow
      23: return

可以看到在 add() 方法的 flags 里面多了一个 ACC_SYNCHRONIZED 标志,这标志用来告诉 JVM 这是一个同步方法,在进入该方法之前要先获得相应的锁,锁的计数器加1,方法结束后计数器减1,如果获取失败就阻塞住,直到该锁被释放。

3.2 同步代码块

我们新定义一个同步代码块,编译出class字节码,然后找到method方法所在的指令块,可以清楚的看到其实现上锁和释放锁的过程,如下:

public class Test3 {
    private static int i = 0;

    public synchronized void add() {
        i++;
    }
}
public synchronized void add();
    descriptor: ()V
    flags: ACC_PUBLIC,ACC_SYNCHRONIZED
    Code:
     stack=2, loacls=1, args_size=1
       0: getstatic     #2                  // Field i:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field i:I
       8: return
     LineNumberTable:
       line 7: 0
       line 8: 8

从反编译的同步代码块可以看到同步块是由 monitorenter 指令进入,然后 monitorexit 释放锁,在执行 monitorenter 之前需要尝试获取锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,那么就把锁的计数器加1。当执行 monitorexit 指令时,锁的计数器也会减1。当获取锁失败时会被阻塞,一直等待锁被释放。

但是为什么会有两个 monitorexit 呢?其实第二个 monitorexit 是来处理异常的,仔细看反编译的字节码,正常情况下第一个monitorexit 之后会执行 goto 指令,而该指令转向的就是23行的 return,也就是说正常情况下只会执行第一个 monitorexit 释放锁,然后返回。而如果在执行中发生了异常,第二个 monitorexit 就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁。

四、synchronized 锁的底层实现

在理解锁实现原理之前了解一下 Java 的对象头和 Monitor,在 JVM 中,对象是分成三部分存在的:对象头、实例数据、对齐填充。

深入了解 synchronized 底层原理_第1张图片

实例数据和对齐填充与 synchronized 无关,这里简单说一下(我也是阅读《深入理解Java虚拟机》学到的,读者可仔细阅读该书相关章节学习)。实例数据存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐;对其填充不是必须部分,由于虚拟机要求对象起始地址必须是8字节的整数倍,对齐填充仅仅是为了使字节对齐。

对象头是我们需要关注的重点,它是 synchronized 实现锁的基础,因为 synchronized 申请锁、上锁、释放锁都与对象头有关。对象头主要结构是由 Mark Word 和 Class Metadata Address 组成,其中 Mark Word 存储对象的 hashCode、锁信息或分代年龄或GC标志等信息Class Metadata Address 是类型指针指向对象的类元数据,JVM 通过该指针确定该对象是哪个类的实例

锁也分不同状态,JDK6 之前只有两个状态:无锁、有锁(重量级锁),而在 JDK6 之后对 synchronized 进行了优化,新增了两种状态,总共就是四个状态:无锁状态、偏向锁、轻量级锁、重量级锁,其中无锁就是一种状态了。锁的类型和状态在对象头 Mark Word 中都有记录,在申请锁、锁升级等过程中 JVM 都需要读取对象的 Mark Word 数据。

每一个锁都对应一个 monitor 对象,在 HotSpot 虚拟机中它是由 ObjectMonitor 实现的(C++实现)。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;  //锁计数器
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor 中有两个队列 _WaitSet 和 _EntryList,用来保存 ObjectWaiter 对象列表(每个等待锁的线程都会被封装 ObjectWaiter 对象),_owner 指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList集合,当线程获取到对象的 monitor 后进入 _Owner 区域并把 monitor 中的 owner 变量设置为当前线程同时 monitor 中的计数器 count 加1,若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减1,同时该线程进入  WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor (锁)并复位变量的值,以便其他线程进入获取 monitor (锁)。
monitor 对象存在于每个 Java 对象的对象头中(存储的指针的指向),synchronized 锁便是通过这种方式获取锁的,也是为什么Java 中任意对象可以作为锁的原因,同时也是 notify/notifyAll/wait 等方法存在于顶级对象 Object 中的原因。

 

你可能感兴趣的:(Java)