java面试题:synchronized底层原理

文章目录

  • synchronized底层原理
    • 一. synchronized的使用
      • 1.1 synchronized修饰实例方法
      • 1.2 synchronized修饰静态方法
      • 1.3 synchronized修饰代码块
    • 二. synchronized底层语义原理
      • 2.1 对象头和Monitor
      • 2.2 synchronized修饰代码块原理
      • 2.3 synchronized修饰同步方法原理
      • 2.4 同步主存
    • 三. synchronized锁升级策略
      • 3.1 偏向锁
      • 3.2 轻量级锁
      • 3.3 自旋锁
      • 3.4 重量级锁
      • 3.5 消除锁
      • 3.6 锁升级策略概述
    • 四. synchronized可重入性
    • 五.总结

synchronized底层原理

一. synchronized的使用

  • synchronized的使用一般出现在三个地方:
    • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
    • 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
    • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁

1.1 synchronized修饰实例方法

  • 修饰实例方法即一个类中有多个实例方法(A,B,C),synchronized可对类中的实例方法进行修饰,如下所示:synchronized 关键字修饰 increase()方法
public class AccountingSync implements Runnable{    //共享资源(临界资源)
    static int i=0;            
    //synchronized 修饰实例方法
    public synchronized void increase(){
        i++;
    }
    @Override
    public void run() {       
    	for(int j=0;j<1000000;j++){
            increase();
        }}
    }
  • 注意: 如果创建多个不同类实例的线程t1,t2,此时t1和t2可以同时访问被synchronized修饰的实例方法。如图下所示:创建了两个不同类实例的线程t1,t2
  • 这种情况下,t1和t2可以同时获取到increase()的锁,因为我们创建了两个不同的实例锁,t1和t2获取的是不同实例锁。
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(new AccountingSync());
        Thread t2=new Thread(new AccountingSync());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    } 

正确用法:

  • 应创建线程时使用同一实例进行创建,这时候t1和t2就会进行increase()的锁竞争。而不会同步执行。
    public static void main(String[] args) throws InterruptedException {
    	AccountingSync instance=new AccountingSync();
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    } 

1.2 synchronized修饰静态方法

  • 当synchronized作用于静态方法时,其锁就是当前类的class对象锁。因为静态成员不专属于任何一个实例对象,是类成员,因此通过class对象锁可以控制静态 成员的并发操作。定义如下:
public class AccountingSyncClass implements Runnable{
    static int i=0;    
    /**
     * 作用于静态方法,锁是当前class对象,也就是
     * AccountingSyncClass类对应的class对象
     */
    public static synchronized void increase(){
        i++;
    }          
    @Override
    public void run() {       
     for(int j=0;j<1000000;j++){
            increase();
        }
    }

用法:

  • 此时如果我们还是像上文一样创建两个不同实例的线程,去调用synchronized 修饰的静态方法increase(),此时也会进行锁竞争,因为静态方法的锁是class对象锁,而不是实例锁。如下所示:
public static void main(String[] args) throws InterruptedException {                
        Thread t1=new Thread(new AccountingSyncClass());                
        Thread t2=new Thread(new AccountingSyncClass());                /
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
  • 需要注意的是如果一个线程A调用一个实例对象的非static synchronized方法,而线程B需要调用这个实例对象所属类的静态 synchronized方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的class对象,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

1.3 synchronized修饰代码块

  • 在方法体较大,但是需要同步的代码只有其中一部分的时候,可以选择使用synchronized修饰代码块,提高程序效率。如下所示:
public class AccountingSync implements Runnable{
    static AccountingSync instance=new AccountingSync();
    static int i=0;
    @Override
    public void run() {        //省略其他耗时操作....
        //使用同步代码块对变量i进行同步操作,锁对象为instance
        synchronized(instance){                 
           for(int j=0;j<1000000;j++){
                    i++;
              }
        }
    }
  • 代码看出,将synchronized作用于一个给定的实例对象instance,即当前实例对象就是锁对象,每次当线程进入synchronized包裹的代码块时就会要求当前线程持有instance实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只有一个线程执行i++;操作。当然除了instance作为对象外,我们还可以使用this对象(代表当前实例)或者当前类的class对象作为锁。

二. synchronized底层语义原理

  • Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。
  • synchronized 修饰的代码块:使用monitorenter 和 monitorexit 指令来实现同步的
  • synchronized 修饰的同步方法:同步方法 并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的

2.1 对象头和Monitor

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下:

  • 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。
  • 对象头,它实现synchronized的锁对象的基础,这点我们重点分析它,一般而言,synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度)。

对象头会存储对象的HashCode、分代年龄、锁状态,锁标记位等信息,也就是说可以从对象头中获取当前对象锁的状态(例如当前是否锁被占用,是什么锁?偏向锁,轻量级锁还是重量级锁等)

2.2 synchronized修饰代码块原理

原理:

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

总结:

  • 总的来说,synchronized修饰代码块同步的底层原理就是在代码块的前后位置使用monitorenter和monitorexit 指令。monitorenter会判断是否可以获取当前对象锁的对象监听器,如果可以获取将就将其锁计数器加1。此时其他线程要获取该对象锁的对象监听器的时候就会被阻塞。直到当前线程执行monitorexit 指令,进行对象监听器的释放,同时将锁计数器减1。

2.3 synchronized修饰同步方法原理

原理:

  • 方法级的同步是隐式的,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。
  • 当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。
  • 在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放

2.4 同步主存

1、线程解锁前,必须把共享变量的最新值刷新到主内存中;
2、线程加锁时,讲清空工作内存中共享变量的值,从而使用共享变量是需要从主内存中重新读取最新的值(加锁与解锁需要统一把锁)

三. synchronized锁升级策略

前言: 我们刚刚所提到的synchronized是通过获取monitor(对象监听器)实现的,监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,这种实现方式导致synchronized属于重量级锁。意思就是这种锁比较消耗系统内核资源,所以Java 6之后Java官方对从JVM层面对synchronized进行了优化,也就是所说的synchronized锁升级策略。

下面首先介绍几种不同的锁类型:

3.1 偏向锁

  • 偏向锁它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。
  • 偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
  • 所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。

3.2 轻量级锁

  • 倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的)。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

3.3 自旋锁

  • 轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

3.4 重量级锁

  • 当自旋锁失败之后,就会升级成重量级锁了,也就是我们刚刚一直在讲述的synchronized的底层实现原理。

3.5 消除锁

  • 消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

3.6 锁升级策略概述

  • 偏向锁:
    • 当锁对象第一次被线程获取的时候,线程使用CAS操作把这个锁的线程ID记录在对象头之中,同时置偏向标志位1。以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。
    • 如果线程使用CAS操作时失败则表示该锁对象上存在竞争并且这个时候另外一个线程获得偏向锁的所有权。当到达全局安全点(safepoint,这个时间点上没有正在执行的字节码)时获得偏向锁的线程被挂起,膨胀为轻量级锁。也就是说:当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束
  • 轻量级锁:
    • 当前线程使用CAS将对象头的mark Word锁标记位替换为锁记录指针,如果成功,当前线程获得锁。
    • 如果失败,表示其他线程竞争锁,当前线程尝试通过自旋获取锁
    • 如果自旋成功则依然处于轻量级状态
    • 如果自旋失败,升级为重量级锁
  • 重量级锁: 此时就升级到重量级锁了

四. synchronized可重入性

  • 从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功。
  • 在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。如下:
  • 此时我们创建一个实例的线程t1,而在t1进入同步代码块运行过程中,又要进入该实例锁下的其他同步方法,这种情况是被允许的。
public class AccountingSync implements Runnable {
//    static AccountingSync instance = new AccountingSync();
    static int i=0;
    static int j=0;
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){  //this,当前实例对象锁
        synchronized(this){
            i++;
            increase();//synchronized的可重入性
        }
    }
    }

    public synchronized void increase(){
        j++;
    }

    public static void main(String[] args) throws InterruptedException {
        AccountingSync instance = new AccountingSync();
        Thread t1=new Thread(instance);
        t1.start();
        t1.join();
        System.out.println(i);
        System.out.println(j);
    }
}

  • 正如代码所演示的,在获取当前实例对象锁后进入synchronized代码块执行同步代码,并在代码块中调用了当前实例对象的另外一个synchronized方法,再次请求当前实例锁时,将被允许,进而执行方法体代码,这就是重入锁最直接的体现,需要特别注意另外一种情况,当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。注意由于synchronized是基于monitor实现的,因此每次重入,monitor中的计数器仍会加1。

五.总结

  • synchronized三种用法:修饰方法,静态方法,代码块
  • synchronized实现同步代码块是通过两个指令实现,获取当前的对象监听器,同时锁计数器加1
  • synchronized同步方法是隐式实现的,通过判断标志位实现,但是底层实际还是通过对象监听器实现
  • synchronized锁升级策略:偏向锁,轻量级锁(自旋锁),重量级锁
  • synchronized可重入性

你可能感兴趣的:(java面试题,多线程,java,synchronized,面试)