大厂Android高频问题:谈谈对synchronized的理解?

371 阅读13分钟

「这是我参与11月更文挑战的第11天,活动详情查看:2021最后一次更文挑战


前言

synchronized可以说是Android开发面试高频的一道问题,但总有小伙伴在回答这道问题总不能让面试满意,  本篇就搞清楚面试官问你对synchronized时,他最想听到的和其实想问的应该是哪些?下面我们从这几个方面剖析这道问题!

  • synchronized关键字的使用
  • synchronized关键字的原理
  • synchronized关键字的优化

synchronized关键字的使用

1、在Java中,synchronized关键字是一个轻量级的同步机制,也是我们在工作中用得最频繁的,我们可以使用synchronized修饰一个方法,也可以用来修饰一个代码块。 那么,你真的了解synchronized吗?是骡子是马,咱拿出来溜溜。

2、关于synchronized的使用,我相信只要正常做过Android或Java开发的工作,对此一定不会陌生。 那么请问,在static方法和非static方法前面加synchronized到底有什么不同呢?这种问题,光文字解释一点说服力都没有,直接撸个代码验证一下。

• static锁

   public class SynchronizedTest {
   
       private static int number = 0;
   
       public static void main(String[] args) {
           //创建5个线程,制造多线程场景
           for (int i = 0; i < 5; i++) {
               new Thread("Thread-" + i) {
                   @Override
                   public void run() {
                       try {
                           //sleep一个随机时间
                           Thread.sleep(new Random().nextInt(5) * 1000);
                       } catch (InterruptedException e) {
                           e.printStackTrace();
                       }
                       //调用静态方法
                       SynchronizedTest.testStaticSynchronized();
                   }
               }.start();
           }
       }
   
       /**
        * 静态方法加锁
        */
       public synchronized static void testStaticSynchronized() {
           //对number加1操作
           number++;
           //打印(线程名 + number)
           System.out.println(Thread.currentThread().getName() + " -> 当前number为" + number);
       }
   }
   
   
   // logcat日志
   // Thread-4 -> 当前number为1
   // Thread-3 -> 当前number为2
   // Thread-1 -> 当前number为3
   // Thread-0 -> 当前number为4
   // Thread-2 -> 当前number为5

代码逻辑很简单,创建5个线程去通过静态方法操作number变量,因为是静态方法,所以直接使用类名调用即可。根据logcat日志的输出,做到了同步,没有并发异常。

• 非static锁

   public class SynchronizedTest {
   
       private static int number = 0;
   
       public static void main(String[] args) {
           //创建5个线程,制造多线程场景
           for (int i = 0; i < 5; i++) {
               new Thread("Thread-" + i) {
                   @Override
                   public void run() {
                       try {
                           //sleep一个随机时间
                           Thread.sleep(new Random().nextInt(5) * 1000);
                       } catch (InterruptedException e) {
                           e.printStackTrace();
                       }
                       SynchronizedTest test = new SynchronizedTest();
                       //通过对象,调用非静态方法
                       test.testNonStaticSynchronized();
                   }
               }.start();
           }
       }
   
       /**
        * 非静态方法加锁
        */
       public synchronized void testNonStaticSynchronized() {
           number++;
           System.out.println(Thread.currentThread().getName() + " -> " + number);
       }
   }
   
   // logcat日志
   // Thread-0 -> 当前number为1
   // Thread-4 -> 当前number为1
   // Thread-2 -> 当前number为3
   // Thread-1 -> 当前number为4
   // Thread-3 -> 当前number为4

这里的代码与上面的代码逻辑一模一样,唯一改变的就是因为方法没有使用static修饰,所以使用创建对象并调用方法来操作number变量。看logcat日志,出现了数据异常,很明显不加static修饰,是没办法保证线程同步的。

• 另外我们再做一个测试,卖个关子,先来看代码。

   public class SynchronizedTest {
   
       public static void main(String[] args) {
           //创建5个线程调用非静态方法
           for (int i = 0; i < 5; i++) {
               new Thread("Thread-" + i) {
                   @Override
                   public void run() {
                       try {
                           Thread.sleep(new Random().nextInt(5) * 1000);
                       } catch (InterruptedException e) {
                           e.printStackTrace();
                       }
   
                       //通过对象调用非静态方法
                       SynchronizedTest test = new SynchronizedTest();
                       test.testNonStaticSynchronized();
                   }
               }.start();
           }
   
           //创建5个线程调用静态方法
           for (int i = 0; i < 5; i++) {
               new Thread("Thread-" + i) {
                   @Override
                   public void run() {
                       try {
                           Thread.sleep(new Random().nextInt(5) * 1000);
                       } catch (InterruptedException e) {
                           e.printStackTrace();
                       }
   
                       //通过类名调用静态方法
                       SynchronizedTest.testStaticSynchronized();
                   }
               }.start();
           }
       }
   
       /**
        * 静态方法加锁
        */
       public synchronized static void testStaticSynchronized() {
           System.out.println("testStaticSynchronized -> running -> " + System.currentTimeMillis());
       }
   
       /**
        * 非静态方法加锁
        */
       public synchronized void testNonStaticSynchronized() {
           System.out.println("testNonStaticSynchronized -> running -> " + System.currentTimeMillis());
       }
   }
   
   // logcat日志
   // testNonStaticSynchronized -> running -> 1603433921735
   // testNonStaticSynchronized -> running -> 1603433921735
   // testStaticSynchronized -> running -> 1603433921735
   // testNonStaticSynchronized -> running -> 1603433922740  ----- 注意这里
   // testStaticSynchronized -> running -> 1603433922740     ----- 注意这里
   // testStaticSynchronized -> running -> 1603433922740
   // testNonStaticSynchronized -> running -> 1603433923735
   // testNonStaticSynchronized -> running -> 1603433924740
   // testStaticSynchronized -> running -> 1603433925736
   // testStaticSynchronized -> running -> 1603433925736

代码逻辑是这样的,各创建5个线程分别执行静态方法与非静态方法,看输出日志,特别关注一下最后的时间戳。从日志第我们可以发现,从日志第4行和第5行发现,testNonStaticSynchronized与testStaticSynchronized方法可以同时执行,那就说明static锁与非statics锁互不干预。

经过上面3个demo的分析,基本可以得出结论了,这里总结一下。

• 类锁: 当synchronized修饰一个static方法时,获取到的是类锁,作用于这个类的所有对象。

• 对象锁: 当synchronized修饰一个非static方法时,获取到的是对象锁,作用于调用该方法的当前对象。

• 类锁和对象锁不同,他们之间不会产生互斥。

synchronized关键字的原理

当然,关于如何使用的问题,上面就一笔带过了,面试官一般也不会问很多,毕竟体现不出面试官的逼格。 正所谓,知其然也要知其所以然,我们需要要探讨的重点是synchronized关键字底层是怎么帮我们实现同步的?没错,这也是面试过程中问得最多的。synchronized的原理这块,我们也分两种情况去思考。

• 第一,synchronized修饰代码块。

public class SynchronizedTest {
    public static void main(String[] args) {
        //通过synchronized修饰代码块
        synchronized (SynchronizedTest.class) {
            System.out.println("this is in synchronized");
        }
    }
}

上面是一段演示代码,没有实际功能,为了能够看得简单明了,就是通过synchronized对一条输出语句进行加锁。因为synchronized仅仅是Java提供的关键字,那么要想知道底层原理,我们需要通过javap命令反编译class文件,看看他的字节码到底长啥样。

synchronized相关面试题,你接得住吗?

看反编译的结果,着重看红色标注的地方。我们可以清楚地发现,代码块同步是使用monitorenter和monitorexit两个指令完成的,monitorenter指令插入到同步代码块的开始位置,monitorexit插入到方法结束处和异常处,被同步的代码块由monitorenter指令进入,然后在monitorexit指令处结束。

这里的重要角色monitor到底是什么呢?简单来说,可以直接理解为锁对象,只不过是虚拟机实现的,底层是依赖于操作系统的Mutex Lock实现。任何Java对象都有一个monitor与之关联,或者说所有的Java对象天生就可以成为monitor,这也就可以解释我们平时在使用synchronized关键字时可以将任意对象作为锁的原因了。

monitorenter

在执行monitorenter时,当前线程会尝试获取锁,如果这个monitor没有被锁定,或者当前线程已经拥有了这个对象的锁,那么就把锁的计数器加1,获取锁成功,继续执行下面的代码。如果获取锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。

monitorexit

与此对应的,当执行monitorexit指令时,锁的计数器也会减1,当计数器等于0时,当前线程就释放锁,不再是这个monitor的所有者。这个时候,其他被这个monitor阻塞的线程便可以尝试去获取这个monitor的所有权了。

到这里,synchronized修饰代码块实现同步的原理,我相信你已经搞懂了吧,那趁热打铁,继续看看修饰方法又是怎么处理的。

• 第二,synchronized修饰方法。

public class SynchronizedTest {


    public static void main(String[] args) {
        doSynchronizedTest();
    }
    //通过synchronized修饰方法
    public static synchronized void doSynchronizedTest(){
        System.out.println("this is in synchronized");
    }
}

按照上面的老规矩,直接javap进行反编译,看字节码的变化。

synchronized相关面试题,你接得住吗?

从反编译的结果来看,这次方法的同步并没有直接通过指令monitorenter和monitorexit来实现,但是相对于其他普通的方法,它的方法描述多了一个ACC_SYNCHRONIZED标识符。想必你都能猜出来,虚拟机无非就是根据这个标识符来实现方法同步,其实现原理大致是这样的:虚拟机调用某个方法时,调用指令首先会检查该方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象,这样也保证了同步。当然这里monitor其实会有类锁和对象锁两种情况,上面就有说到。

关于synchronized的原理,这边再简单总结一下。synchronized关键字的实现同步分两种场景,代码块同步是使用monitorenter和monitorexit指令的形式,而方法同步使用的ACC_SYNCHRONIZED标识符的形式。但万变不离其宗,这两种形式的根本都是基于JVM进入和退出monitor对象锁来实现操作同步。

synchronized关键字的优化

扛到了这里,是该小小的开心一下啦,不过并没有完全结束呢! 从上面的原理分析知道,synchronized关键字是基于JVM进入和退出monitor对象锁来实现操作同步,这种抢占式获取monitor锁,性能上铁定堪忧呀。这时候烦人的面试官又上线了,请问JDK1.6以后对synchronized锁做了哪些优化?这个问题难度较大,我们细细地说。

其实在JDK1.6之前,synchronized内部实现的锁都是重量级锁,也就是说没有抢到CPU使用权的线程都得堵塞,然而在程序真正运行过程中,其实很多情况下并不需要这么重,每次都直接堵塞反而会导致更多的线程上下文切换,消耗更多的资源。所以在JDK1.6以后,对synchronized锁进行了优化,引入偏向锁,轻量级锁,重量级锁的概念。

• 锁信息

熟悉synchronized原理的同学应该都知道,当一个线程访问synchronized包裹的同步代码块时,必须获取monitor锁才能进入代码块,退出或抛出异常时再去释放monitor锁。这里就有问题了,线程需要获取的synchronized锁信息是存在哪里的呢?所以在介绍各种锁的概念之前,我们必须先尝试解答这个疑惑。

在学习JVM时,我们了解过一个对象是由三部分组成的,分别是对象头、实例数据以及对齐填充。其中对象头里又存储了对象本身运行时数据,包括哈希码、GC分代年龄,当然还有我们这里要讲的与锁相关的标识,比如锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。

synchronized相关面试题,你接得住吗?

对象头默认存储的是无锁状态,随着程序的运行,对象头里存储的数据会随着锁标志位的变化而变化,大致结构如下图所示。

synchronized相关面试题,你接得住吗?

现在便可以解开上面的疑惑了,原来线程是通过获取对象头里的相关锁标识来获取锁信息的。有了这个基础,我们现在可以上正菜了,看synchronized锁是怎么一步一步升级优化的。

• 偏向锁

锁是用于并发场景的,然而,在大多数情况下,锁其实并不存在多线程竞争,甚至都是由同一个线程多次获取,所以没有必要花太多代价去放在锁的获取上,这时偏向锁就应运而生了。

偏向锁,顾名思义,它会偏向于第一个访问锁的线程。当一个线程第一次访问同步代码块并尝试获取锁时,会直接给线程加一个偏向锁,并在对象头的锁记录里存储锁偏向的线程ID,这样的话,以后该线程再次进入和退出同步代码块时就不需要进行CAS等操作来加锁和解锁,只需查看对象头里是否存储着指向当前线程的偏向锁即可。很明显,偏向锁的做法无疑是消除了同一线程竞争锁的开销,大大提高了程序的运行性能。

synchronized相关面试题,你接得住吗?

当然,如果在运行过程中,突然有其他线程抢占该锁,如果通过CAS操作获取锁成功,直接替换对象头中的线程ID为新的线程ID,继续会保持偏向锁状态;反之如果没有抢成功时,那么持有偏向锁的线程会被挂起,造成STW现象,JVM会自动消除它身上的偏向锁,偏向锁升级为轻量级锁。

synchronized相关面试题,你接得住吗?

• 轻量级锁

轻量级锁位于偏向锁与重量级锁之间,其主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。自旋锁就是轻量级锁的一种典型实现。

自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做进入阻塞挂起状态,它们只需要稍微等一等,其实就是进行自旋操作,等持有锁的线程释放锁后即可立即获取锁,这样就进一步避免切换线程引起的消耗。

synchronized相关面试题,你接得住吗?

当然,自旋锁也不是最终解决方案,比如遇到锁的竞争非常激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu进行自旋检查,这对于业务来讲就是无用功。如果线程自旋带来的消耗大于线程阻塞挂起操作的消耗,那么自旋锁就弊大于利了,所以这个自旋的次数是个很重要的阈值,JDK1.5默认为10次,在1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。

• 重量级锁

自旋多次还是失败后,一般就直接升级成重量级锁了,也就是锁的最高级别了,在上一篇synchronized的原理里有讲到其底层基于monitor对象实现,而monitor的本质又是依赖于操作系统的Mutex Lock实现。这里其实又涉及到我们之前有篇文章讲过的一个知识,频繁切换线程的危害?因为操作系统实现线程之间的切换需要进行用户态到内核态的切换,不用想就知道,切换成本当然就很高了。

当JVM检查到重量级锁之后,会把想要获得锁的线程进行阻塞,插入到一个阻塞队列,被阻塞的线程不会消耗CPU,但是阻塞或者唤醒一个线程,都需要上面所说的从用户态转换到内核态,这个成本比较高,有可能比真正需要执行的同步代码的消耗还要大。

我们依次理了一遍,由无锁到偏向锁,再到轻量级锁,最后升级为重量级锁,具体升级流程参考下面这张整体图。这一切的出发点都是为了优化性能,其实也给我们一线开发者一个启示,并不是功能实现了,编码也就结束了,后面还有很长的优化之路等待着你我,加油!

synchronized相关面试题,你接得住吗?

看完三件事❤️

如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:

  1. 点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。
  2. 关注公众号 『 小新聊Android 』,不定期分享原创知识
  3. 同时可以期待后续文章ing🚀