【offer作手】java并发-synchronized和volatile

面试难度:★★★★★

考察概率:★★★★★

#莫等闲,白了少年头#

本人从毕业开始一直在一线互联网大厂工作,现任技术TL,出版过《深入理解Java并发》一书,折腾过技术开源项目,并长期作为面试官参与面试,深谙双方的诉求与技术沟通。如今归零心态,再出发。#莫等闲,白了少年头#

技术交流+v:xxxyxsyy1234(和笔者一起努力,每日打卡)

面试官视角

如果谈到并发,一定会绕不过去synchronized和volatile两个很重要的并发关键字,可以毫不夸张的说,这两大关键字就是整个j.u.c体系的基石。但是从过往面试经历来说,很少有候选人能够完整的讲到这两个关键字,都是从八股文中找到三大性质:可见性、有序性和原子性相关的文章进行泛泛而谈,候选人本身的体系化总结很少有,就这个话题,对不同的候选人进行横向比较直接高下立见了。

面试题

  1. synchronized的实现原理?
  2. java对象头布局?
  3. synchronized锁升级策略?
  4. volatile实现原理?
  5. 三大性质:原子性、有序性和可见性的比较?

回答要点

1.synchronized的实现原理?

进一步分析synchronized的具体底层实现,有如下一个简单的示例代码:

arduino 复制代码
public class SynchronizedDemo {
    public static void main(String[] args) {
        synchronized (SynchronizedDemo.class) {
            System.out.println("hello synchronized!");
        }
    }
}

上述代码通过synchronized"锁住"当前类对象来进行同步,将java代码进行编译之后通过javap -v SynchronizedDemo .class来查看对应的main方法字节码如下:

yaml 复制代码
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: ldc           #2                  // class com/codercc/chapter3/SynchronizedDemo
2: dup
3: astore_1
4: monitorenter
5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc           #4                  // String hello synchronized!
10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: monitorexit
15: goto          23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return

重要的字节码已经在原字节码文件中进行了标注,再进入到synchronized同步块中,需要通过monitorenter指令获取到对象的monitor(也通常称之为对象锁)后才能往下进行执行,在处理完对应的方法内部逻辑之后通过monitorexit指令来释放所持有的monitor,以供其他并发实体进行获取。代码后续执行到第15行goto语句进而继续到第23行return指令,方法成功执行退出。另外当方法异常的情况下,如果monitor不进行释放,对其他阻塞对待的并发实体来说就一直没有机会获取到了,系统会形成死锁状态很显然这样是不合理。

因此针对异常的情况,会执行到第20行指令通过monitorexit释放monitor锁,进一步通过第22行字节码athrow抛出对应的异常。从字节码指令分析也可以看出在使用synchronized是具备隐式加锁和释放锁的操作便利性的,并且针对异常情况也做了释放锁的处理。

可加分亮点

面试官心理 :回答到这部分,通过字节码的分析已经要比一般的候选人优秀了,但是对于更加优秀的候选人而言,能更进一步思考的话,一定会脱颖而出的。这里还有一个问题是,从字节码上分析后,如果延伸到操作系统上是如何支持的呢?这里可以看出不同候选人对技术的垂直纵深的理解程度以及好奇心

monitor机制的底层实现?

每个对象都存在一个与之关联的monitor,线程对monitor持有的方式以及持有时机决定了synchronized的锁状态以及synchronized的状态升级方式。monitor是通过C++中ObjectMonitor实现,代码可以通过openjdk hotspot链接(hg.openjdk.java.net/jdk8u/jdk8u... )进行下载openjdk中hotspot版本的源码,具体文件路径在src\share\vm\runtime\objectMonitor.hpp,具体源码为:

ini 复制代码
// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
_header       = NULL;
_count        = 0;
_waiters      = 0,
_recursions   = 0;
_object       = NULL;
_owner        = NULL;
_WaitSet      = NULL;
_WaitSetLock  = 0 ;
_Responsible  = NULL ;
_succ         = NULL ;
_cxq          = NULL ;
FreeNext      = NULL ;
 _EntryList    = NULL ;
_SpinFreq     = 0 ;
_SpinClock    = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}

从ObjectMonitor的结构中可以看出主要维护_WaitSet以及_EntryList两个队列来保存ObjectWaiter 对象,当每个阻塞等待获取锁的线程都会被封装成ObjectWaiter对象来进行入队,与此同时如果获取到锁资源的话就会出队操作。另外_owner则指向当前持有ObjectMonitor对象的线程。等待获取锁以及获取锁出队的示意图如下图3.1所示:

图3.1 等待获取锁以及获取锁出队的示意图

当多个线程进行获取锁的时候,首先都会进行_EntryList队列,其中一个线程获取到对象的monitor后,对monitor而言就会将_owner变量设置为当前线程,并且monitor维护的计数器就会加1。如果当前线程执行完逻辑并退出后,monitor中_owner变量就会清空并且计数器减1,这样就能让其他线程能够竞争到monitor。另外,如果调用了wait()方法后,当前线程就会进入到_WaitSet中等待被唤醒,如果被唤醒并且执行退出后,也会对状态量进行重置,也便于其他线程能够获取到monitor。

从线程状态变化的角度来看,如果要想进入到同步块或者执行同步方法,都需要先获取到对象的monitor,如果获取不到则会变更为BLOCKED状态,具体过程如下图所示:

从上图可以看出任意线程对Object的访问,首先要获得Object的monitor,如果获取失败,该线程就会进入到同步队列中,线程状态变为BLOCKED。当monitor持有者释放后,在同步队列中的线程才会有机会重新获取monitor,才能继续执行。

2.java对象的内存布局?

对象的内存布局主要分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

如上图所示,这三部分区域具体如下:

Mark Word(标记字段) :该区域主要用于存储一系列的标志位,比如对象的哈希值、轻量级锁的标记位、偏向锁标记位、分代年龄等等;

Klass Pointer:Class对象的类型指针,在Jdk1.8默认开启指针压缩后占用4字节,关闭指针压缩(-XX:-UseCompressedOops)后,其长度为8字节。指向的位置是对象对应的Class对象(其对应的元数据对象)的内存地址;

数组长度:如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度;

对象实例数据:包括对象的所有成员变量,大小由各个成员变量决定,比如byte占1个字节8比特位、int占4个字节32比特位;

对齐填充:为了满足虚拟机规范以及系统底层操作的高效性,HotSpot虚拟机的内存管理系统要求对象起始地址必须是8字节的整数倍,如果对象内存为达到这个要求,就会进行内存填充以达到该大小要求。

3.synchronized锁升级策略?

偏向锁

锁在大多数情况下不存在竞争,而且总是由同一线程多次获得,为了让锁被同一个线程能够以更低的成本获得就引入了偏向锁机制

加锁

当一个线程访问同步块并获取锁时,会在对象头Mark Word中和栈帧中的锁记录里存储锁偏向的线程Id,后续线程如果需要获取到锁则会与保存的线程Id进行比较,获取锁的流程主要步骤如下:

  1. 判断锁状态,先检测Mark Word是否为可偏向状态即是否为偏向锁1同时锁标识位为01;

  2. 若为可偏向状态,则测试线程Id是否为当前线程Id。如果是则执行步骤(5),否则执行步骤(3);

  3. 如果线程Id不为当前线程Id,则通过CAS操作竞争锁来替换Thread Id。如果竞争成功,则将Mark Word的线程Id替换为当前线程Id,否则执行线程(4);

  4. 通过CAS竞争锁失败说明当前存在多线程资源竞争情况,则会执行锁撤销流程;

  5. 执行同步逻辑

解锁

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。持有偏向锁的线程需要等待全局安全点(在这个时间点上没有正在执行的字节码),然后会暂停拥有偏向锁的线程,检查该线程该线程的线程状态。如果线程不处于活动状态,则将对象头设置成无锁状态表示锁资源已经释放。如果线程仍然活着,要么栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么升级到轻量级锁。

轻量级锁

引入轻量级锁的主要原因是对绝大部分的锁,在整个同步周期内都不存在竞争,可能是线程能够交替获取到锁执行。同偏向锁的区别在于,偏向锁的设置是建立在大多数情况锁是由同一个线程获取的假设前提,而轻量级锁则是建立多个线程能够交替互相获取且很低的概率能够彼此互相出现竞争的假设前提。引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

加锁

  1. 线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间Lock Record,并将对象目前的Mark Word的拷贝至Lock Record(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);

  2. 利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果成功表示竞争到锁,则将锁标志位变成00表示为轻量级锁状态,并可以执行同步操作;如果失败则执行步骤3;

  3. CAS操作失败则通过自旋尝试获取,自旋到一定的次数仍然未成功,只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;

解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生并成释放锁。替换失败,说明有其他线程尝试获取该锁,锁已经膨胀为重量级锁,则需要唤醒阻塞等待的线程。

4.volatile实现原理?

在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令,为了能够满足数据可见性。Lock指令肯定有神奇的地方,那么Lock前缀的指令在多核处理器下会发现什么事情呢?主要有这两个方面的影响:

  1. 将当前处理器缓存行的数据写回系统内存;

  2. 这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效;

为了提高执行处理的速度以及匹配不同硬件读取数据的速率,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1、L2或者L3)后再进行操作,但操作完不知道何时会重写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就需要缓存一致性 的协议来保证。每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。因此,经过分析可以得出如下结论:

  1. Lock前缀的指令会引起处理器缓存写回内存;

  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;

  3. 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。

5.三大性质:原子性、有序性和可见性的比较?

原子性

原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着"同生共死"的感觉。即时在多个线程并发执行的时候,一个操作一旦开始,就不会被其他线程所干扰。

java内存模型中定义了8种操作都是原子的,不可再分的。

1. ****lock( 锁定 ) :作用于主内存中的变量,它把一个变量标识为一个线程独占的状态;

2. ****unlock( 解锁 ) 作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;

3. ****read (读取) :作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面的load动作使用;

4. ****load (载入) :作用于工作内存中的变量,它把read操作从主内存中得到的变量值放入工作内存中的变量副本;

5. ****use (使用) :作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;

6. ****assign (赋值) :作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;

7. ****store (存储) :作用于工作内存的变量,它把工作内存中一个变量的值传送给主内存中以便随后的write操作使用;

8. write(操作) :作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

synchronized原子性

尽管jvm没有把lock和unlock开放给我们使用,但jvm以更高层次的指令monitorenter和monitorexit指令开放给我们使用,对应到java代码中就是synchronized关键字,也就是说synchronized满足原子性。

volatile原子性

volatile并不具备,如果让volatile保证原子性,必须符合以下两条规则:

  1. 运算结果并不依赖于变量的当前值,或者能够确保只有一个线程修改变量的值;

  2. 变量不需要与其他的状态变量共同参与不变约束

有序性

synchronized

synchronized语义表示锁在同一时刻只能由一个线程进行获取,当锁被占用后,其他线程只能等待。因此,synchronized语义就要求线程在访问读写共享变量时只能"串行"执行,因此synchronized具有有序性。

volatile

根据JMM的规定为了性能优化,编译器和处理器会进行指令重排序。也就是说java程序有序性可以总结为:如果在本线程内观察,所有的操作都是有序的;如果在一个线程观察另一个线程,所有的操作都是无序的。 volatile包含禁止指令重排序的语义,其具有有序性


可见性

可见性是指当一个线程修改了共享变量后,其他线程能够立即可见这个修改。通过之前对synchronzed内存语义的分析,当线程获取锁时会从主内存中获取共享变量的最新值。从而synchronized具有可见性。同样的在volatile分析中,会通过在指令中添加lock指令,以实现内存可见性,因此volatile也同样具有可见。

结论: synchronized: 具有原子性,有序性和可见性; volatile:具有有序性和可见性

代码考核

这部分不会出现手撕代码的情况,但是针对校招的同学的话,在涉及到机考的时候,会给出一段代码,考察涉及到三大性质的判断

知识点详情

这部分可以参考本人的书籍《深入理解Java并发》,或者本人博客

1、juejin.cn/post/684490...

2、juejin.cn/post/684490...

3、juejin.cn/post/684490...

相关推荐
Yaml41 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
测试19982 小时前
2024软件测试面试热点问题
自动化测试·软件测试·python·测试工具·面试·职场和发展·压力测试
小码编匠2 小时前
一款 C# 编写的神经网络计算图框架
后端·神经网络·c#
AskHarries2 小时前
Java字节码增强库ByteBuddy
java·后端
佳佳_2 小时前
Spring Boot 应用启动时打印配置类信息
spring boot·后端
马剑威(威哥爱编程)2 小时前
MongoDB面试专题33道解析
数据库·mongodb·面试
许野平4 小时前
Rust: 利用 chrono 库实现日期和字符串互相转换
开发语言·后端·rust·字符串·转换·日期·chrono
BiteCode_咬一口代码5 小时前
信息泄露!默认密码的危害,记一次网络安全研究
后端