synchronized就该这么学

举报
JavaSouth南哥 发表于 2024/11/11 17:11:20 2024/11/11
【摘要】 早期sychonrized重量级锁开销大,于是JDK1.5引入了ReentrantLock,包含现在很多偏见都是认为ReentrantLock性能要优于sychonrized。但JDK1.6引入的锁升级,不断迭代,怕是性能往往还优于ReentrantLock。我是南哥,相信对你通关面试、拿下Offer有所帮助。敲黑板:本文总结了多线程相关的synchronized、volatile常见的面试题!可

先赞后看,Java进阶一大半

早期sychonrized重量级锁开销大,于是JDK1.5引入了ReentrantLock,包含现在很多偏见都是认为ReentrantLock性能要优于sychonrized。但JDK1.6引入的锁升级,不断迭代,怕是性能往往还优于ReentrantLock。

在这里插入图片描述

我是南哥,相信对你通关面试、拿下Offer有所帮助。

敲黑板:本文总结了多线程相关的synchronized、volatile常见的面试题!

⭐⭐⭐收录在《Java学习/进阶/面试指南》:https://github/JavaSouth

1. synchronized

1.1 可重入锁

面试官:知道可重入锁有哪些吗?

可重入意味着获取锁的粒度是线程而不是调用,如果大家知道这个概念,会更容易理解可重入锁的作用。

既然获取锁的粒度是线程,意味着线程自己是可以获取自己的内部锁的,而如果获取锁的粒度是调用则每次经过同步代码块都需要重新获取锁。

举个例子。线程A获取了某个对象锁,但在线程代码的流程中仍需再次获取该对象锁,此时线程A可以继续执行不需要重新再获取该对象锁。另外线程如果要使用父类的同步方法,由于可重入锁也无需再次获取锁。

在Java中,可重入锁主要有ReentrantLock、synchronized

1.2 synchronized实现原理

面试官:你先说说synchronized的实现原理?

synchronized的实现是基于monitor的。任何对象都有一个monitor与之关联,当monitor被持有后,对象就会处于锁定状态。而在同步代码块的开始位置,在编译期间会被插入monitorenter指令

当线程执行到monitorenter指令时,就会尝试获取monitor的所有权,如果获取得到则代表获得锁资源。

1.3 synchronized缺点

面试官:那synchronized有什么缺点?

在Java SE 1.6还没有对synchronized进行了各种优化前,很多人都会称synchronized为重量级锁,因为它对资源消耗是比较大的。

  1. synchronized需要频繁的获得锁、释放锁,这会带来了不少性能消耗。

  2. 另外没有获得锁的线程会被操作系统进行挂起阻塞、唤醒。而唤醒操作需要保存当前线程状态,切换到下一个线程,也就是进行上下文切换。上下文切换是很耗费资源的一种操作。

1.4 保存线程状态

面试官:为什么上下文切换要保存当前线程状态?

这就跟读英文课文时查字典一样,我们要先记住课文里的页数,查完字典好根据页数翻到英文课文原来的位置。

同理,CPU要保证可以切换到上一个线程的状态,就需要保存当前线程的状态。

1.5 锁升级

面试官:可以怎么解决synchronized资源消耗吗?

上文我有提到Java SE 1.6对synchronized进行了各种优化,具体的实现是给synchronized引入了锁升级的概念。synchronized锁一共有四种状态,级别从低到高依次是无锁、偏向锁、轻量级锁、重量级锁。

大家思考下,其实多线程环境有着各种不同的场景,同一个锁状态并不能够适应所有的业务场景。而这四种锁状态就是为了适应各种不同场景来使得线程并发的效率最高。

  1. 没有任何线程访问同步代码块,此时synchronized是无锁状态。

  2. 只有一个线程访问同步代码块的场景的话,会进入偏向锁状态。偏向锁顾名思义会偏向访问它的线程,使其加锁、解锁不需要额外的消耗。

  3. 少量线程竞争的场景的话,偏向锁会升级为轻量级锁。而轻量级采用CAS操作来获得锁,CAS操作不需要获得锁、释放锁,减少了像synchronized重量级锁带来的上下文切换资源消耗。

  4. 轻量级锁通过CAS自旋来获得锁,如果自旋10次失败,为了减少CPU的消耗则锁会膨胀为重量级锁。此时synchronized重量级锁就回归到了悲观锁的状态,其他获取不到锁的都会进入阻塞状态。

1.6 锁升级优缺点

面试官:它们都有什么优缺点呢?

由于每个锁状态都有其不同的优缺点,也意味着有其不同的适应场景。

  1. 偏向锁的优点是加锁和解锁操作不需要额外的消耗;缺点是如果线程之间存在锁竞争,偏向锁会撤销,这也带来额外的撤销消耗;所以偏向锁适用的是只有一个线程的业务场景。
  2. 轻量级锁状态下,优点是线程不会阻塞,提高了程序执行效率;但如果始终获取不到锁的线程会进行自旋,而自旋动作是需要消耗CPU的;所以轻量级锁适用的是追求响应时间、同时同步代码块执行速度快的业务场景。
  3. 重量级锁的优点是不需要自旋消耗CPU;但缺点很明显,线程会阻塞、响应时间也慢;重量级锁更适用在同步代码块执行速度较长的业务场景。

2. volatile

2.1 指令重排序

面试官:重排序知道吧?

指令重排序字面上听起来很高级,但只要理解了并不难掌握。我们先来看看指令重排序究竟有什么作用。

指令重排序的主要作用是可以优化编译器和处理器的执行效率,提高程序性能。例如多条执行顺序不同的指令,可以重排序让轻耗时的指令先执行,从而让出CPU流水线资源供其他指令使用。

但如果指令之间存在着数据依赖关系,则编译器和处理器不会对相关操作进行指令重排序,避免程序执行结果改变。这个规则也称为as-if-serial语义。例如以下代码。

String book = "JavaGetOffer"; // A
String avator = "思考的陈"; // B
String msg = book + abator; // C

对于A、B,它们之间并没有依赖关系,谁先执行对程序的结果没有任何影响。但C却依赖于A、B,不能出现类似C -> A -> B或C -> B -> A或A -> C -> B或B -> C -> A之类的指令重排,否则程序执行结果将改变。

2.2 重排序的问题

面试官:那重排序不会有什么问题吗?

在单线程环境下,有as-if-serial语义的保护,我们无需担心程序执行结果被改变。但在多线程环境下,指令重排序会出现数据不一致的问题。举个多线程的例子方便大家理解。

       int number = 0;
       boolean flag = false;
       public void method1() {
           number = 6;                // A
           flag = true;               // B
       }
       public void method2() {
           if (flag) {               // C
               int i = number * 6;   // D
           }
       }

假如现在有两个线程,线程1执行method1、线程2执行method2。因为method1其中的A、B之间没有数据依赖关系,可能出现B -> A的指令重排序,大家注意这个指令重排序会影响到线程2执行的结果。

当B指令执行后A指令还没有执行number = 6,此时如果线程2执行method2同时给i赋值为0 * 6。很明显程序运行结果和我们预期的并不一致。

2.3 volatile特性

面试官:有什么办法可以解决?

关于上文的重排序问题,可以使用volatile关键字来解决。volatile一共有以下特性:

  1. 可见性。volatile修饰的变量每次被修改后的值,对于任何线程都是可见的,即任何线程会读取到最后写入的变量值。
  2. 原子性。volatile变量的读写具有原子性。
  3. 禁止代码重排序。对于volatile变量操作的相关代码不允许重排序。
       int number = 0;
       volatile boolean flag = false;
       public void method1() {
           number = 6;                // A
           flag = true;               // B
       }
       public void method2() {
           if (flag) {               // C
               int i = number * 6;   // D
           }
       }

由于volatile具有禁止代码重排序的特性,所以不会出现上文的B -> A的指令重排序。另外volatile具有可见性,falg的修改对线程2来说是可见的,线程会立刻感知到flag = ture从而执行对i的赋值。以上问题可以通过volatile解决,和使用synchronized加锁是一样的效果。

另外大家注意一点,volatile的原子性指的是对volatile的读、写操作的原子性,但类似于volatile++这种复合操作是没有原子性的。

2.5 可见性原理

面试官:那volatile可见性的原理是什么?

内存一共分为两种,线程的本地内存和线程外的主内存。对于一个volatile修饰的变量,任何线程对该变量的修改都会同步到主内存。而当读一个volatile修饰的变量时,JMM(Java Memory Model)会把该线程对应的本地内存置为无效,从而线程读取变量时读取的是主内存。

线程每次读操作都是读取主内存中最新的数据,所以volatile能够实现可见性的特性。

2.3 volatile局限性

面试官:volatile有什么缺点吗?

企业生产上还是比较少用到volatile的,对于加锁操作会使用的更多些。

  1. synchronized加锁操作虽然开销比volatile大,但却适合复杂的业务场景。而volatile只适用于状态独立的场景,例如上文对flag变量的读写。
  2. volatile编写的代码是比较难以理解的,不清楚整个流程和原理很难维护代码。
  3. 类似于volatile++这种复合操作,volatile不能确保原子性。

⭐⭐⭐本文收录在《Java学习/进阶/面试指南》:https://github/JavaSouth

我是南哥,南就南在Get到你的点赞点赞点赞。

在这里插入图片描述

创作不易,不妨点赞、收藏、关注支持一下,各位的支持就是我创作的最大动力❤️

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。