我和面试官的博弈:Java 并发编程篇
点击上方 Java后端,选择 设为星标
优质文章,及时送达
面试官:你先说下你对synchronized的了解。
我:synchronized可以保证方法或者代码在运行时,同一时刻只有一个方法可以进入到临界区,同时还可以保证共享变量的内存可见性。
我:Java中每个对象都可以作为锁,这是synchronized实现同步的基础:
面试官:当线程访问同步代码块时,它首先要得到锁才能执行代码,退出或者抛异常要释放锁,这是怎么实现的呢?
我:同步代码块是使用monitorenter和monitorexit指令实现的,同步方法依靠的是方法修饰符上的ACCSYNCHRONIZED实现的。
1、同步代码块:monitorenter指令插入到同步代码快的开始位置,monitorexit指令插入到同步代码块的结束位置,jVM保证每一个monitorexist都有一个monitorenter与之相对应。任何对应都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象对应的monitor所有权,即尝试获取对象的锁。
每一个java对象都有成为Monitor的潜质,因为在Java的设计中,每一个java对象自打娘胎出来就带了一把看不见的锁,它被叫做内部锁或者Monitor锁。
我:知道一些。锁主要存在四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,
这种策略是为了提高获得锁和释放锁的效率。
面试官:那你先来说下自旋锁
何谓自旋锁呢-就是让线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。那么问题来了,等多长时间呢?时间短了等不到持有锁的线程释放锁,时间长了占用了处理器的时间,典型的“占着茅坑不拉屎”,反而带来性能上的浪费。所以,自旋等待的时间(自旋)的次数必须有一个限度,如果自旋超过了定义的时间仍没有获得锁则要被挂起。
面试官:我记得有个适应性自旋锁,更加智能。你能说下么?
面试官:给你看下面一段代码,你说下会存在加锁的操作吗?
public static void main(String [] args) {
Vector<String> vector = new Vector<>();
for (int i=0; i<10; i++) {
vector.add(i+"");
}
System.out.println(vector);
我:不会。这种情况下,JVM检测到不可能存在共享数据竞争,这时JVM会对这些同步锁进行锁消除。锁消除的基础是逃逸分析的数据支持。
面试官:再看一段代码,分析一下是在什么地方加锁的?
public static void test() {
List<String> list = new ArrayList<>();
for (int i=0; i<10; i++) {
synchronized (Demo.class) {
list.add(i + "");
}
}
System.out.println(list);
}
面试官:你能说下轻量级锁吗?
1、在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前的Mark Word的拷贝,官方称之为Displaced Mark Word,这时候线程堆栈与对象头的状态如图:
我顿了下,接着说:当锁第一次被线程获取的时候,线程使用CAS操作把这个线程的ID记录在对象Mark Word中,同时置偏向标志位1.以后该线程在进入和退出代码块时不需要进行CAS操作来加锁和解锁,只需要简单测试一下对象头的Mark Word里是否存储着指向当前线程的ID。如果测试成功,表示线程已经获得了锁。当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。
面试官:那偏向锁、轻量级锁和重量级锁有什么区别呢?
一旦有第二个线程访问这个对象,因为偏向锁不会释放,所以第二个线程看到对象是偏向状态,表明在这个对象上存在竞争了,检查原来持有该对象的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程。如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁(偏向锁就是此时升级为轻量级锁)。如果不存在使用了,则可以将对象恢复成无锁状态,然后重新偏向。
我:(接着说)轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说自旋一下,另一个线程就会释放锁。但是当自旋超过一定次数,或者一个线程持有锁,一个线程在自旋,又有第三个来访,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。简单的说就是:有竞争,偏向锁升级为轻量级锁,竞争逐渐激烈,轻量级锁升级为重量级锁。
面试官:你了解java的内存模型吗?能说下对JMM的理解吗?
我:在JSR113标准中有有一段对JMM的简单介绍:Java虚拟机支持多线程执行。在Java中Thread类代表线程,创建一个线程的唯一方法就是创建一个Thread类的实例对象,当调用了对象的start方法后,相应的线程将会执行。线程的行为有时会与我们的直觉相左,特别是在线程没有正确同步的情况下。本规范描述了JMM平台上多线程程序的语义,具体包含一个线程对共享变量的写入何时能被其他线程看到。这是官方的接单介绍。
我:Java内存模型是内存模型在JVM中的体现。这个模型的主要目标是定义程序中各个共享变量的访问规则,也就是在虚拟机中将变量存储到内存以及从内存中取出变量这类的底层细节。通过这些规则来规范对内存的读写操作,保证了并发场景下的可见性、原子性和有序性。JMM规定了多有的变量都存储在主内存中,每条线程都有自己的工作内存,线程的工作内存保存了该线程中用到的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不是直接读写主内存。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间
我:简单的说:Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如原子性、可见性和有序性的问题。JMM就是为了解决这些问题出现的,这个模型建立了一些规范,可以保证在多核CPU多线程编程的环境下,对共享变量的读写的原子性、可见性和有序性。
我:在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。happens-before原则是JMM中非常重要的原则,它是判断数据是否存在竞争、线程是否安全的主要依据,保证了多线程环境下的可见性。下面我说下happens-before的内容:
happens-before的原则定义如下:
下面是happens-before的原则规则:
1、程序次序规则:一个线程内,按照代码书写顺序,书写在前面的操作先行发生于书写在后面的操作。
2、锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作。
3、volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。
4、传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
5、线程启动规则:Thread对象的start()方法先行发生于此线程的每个动作。
6、线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
7、线程终结规则:线程中所有的操作都先行发生于线程的终止检测。
8、对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始。
面试官:你刚才提到了JVM会对我们的程序进行重排序,那是随便重排序吗?
我:不是的,它需要满足以下两个条件:
2、存在数据依赖关系的不允许重排序。
其实这两点可以归结为一点:无法通过happens-before原则推导出来的,JMM允许任意的排序。
int a=1; //A
int b=2; //B
int c=a+b; //C
A,B,C三个操作存在如下关系:A和B不存在数据依赖,A和C,B和C存在数据依赖,因此在重排序的时候:A和B可以随意排序,但是必须位于C的前面,但无论何种顺序,最终结果C都是3.
我接着说:下面举个重排序对多线程影响的栗子:
public class RecordExample2 {
int a = 0;
boolean flag = false;
/**
* A线程执行
*/
public void writer(){
a = 1; // 1
flag = true; // 2
}
/**
* B线程执行
*/
public void read(){
if(flag){ // 3
int i = a + a; // 4
}
}}
按照这种执行顺序线程B肯定读不到线程A设置的a值,在这里多线程的语义就已经被重排序破坏了。操作3和操作4之间也可以重排序,这里就不阐述了。但是他们之间存在一个控制依赖的关系,因为只有操作3成立操作4才会执行。当代码中存在控制依赖性时,会影响指令序列的执行的并行度,所以编译器和处理器会采用猜测执行来克服控制依赖对并行度的影响。假如操作3和操作4重排序了,操作4先执行,则先会把计算结果临时保存到重排序缓冲中,当操作3为真时才会将计算结果写入变量i中。
面试官:你能给我讲下对volatile的理解吗?
我:讲volatile之前,先补充说明下Java内存模型中的三个概念:原子性、可见性和有序性
2、原子性:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么都不执行。原子就像数据库里的事务一样,他们是一个团队,同生共死。看下面一个简单的栗子:
i=0; //1
j=i; //2
i++; //3
i=j+1; //4
我:volatile的原理是volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性,在JVM底层volatile是采用“内存屏障”来实现的。总结起来就是:
volatile的内存语义是:
volatile的底层实现是通过插入内存屏障,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以JMM采用了保守策略。如下:
下面通过一个例子简单分析下:
面试官:很好,看来你对volatile理解的挺深入的了。我们换个话题,你知道**CAS**吗,能跟我讲讲吗?
可以说,CAS是整个JUC的基石。如下图:
我:CAS的实现方式其实不难。在CAS中有三个参数:内存值V、旧的预期值A、要更新的值B,当且仅当内存值V的值等于旧的预期值A时,才会将内存值V的值修改为B,否则什么也不干,是一种乐观锁。其伪代码如下:
if (this.value == A) {
this.value = B
return true;
} else {
return false;
}
private static final Unsafe unsafe =Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
}catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
3、value:当前值,使用volatile修饰,保证多线程环境下看见的是同一个。
// AtomicInteger.java
public final int addAndGet(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
}
// Unsafe.java
public final int getAndAddInt(Object var1,long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
}while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
在方法compareAndSwapInt(var1, var2, var5, var5 + var4)中,有四个参数,分别代表:对象,对象的地址,预期值,修改值。
我:CAS可以保证一次的读-改-写操作是原子操作,在单处理器上该操作容易实现,但是在多处理器上实现就有点复杂。CPU提供了两种方法来实现多处理器的原子操作:总线加锁或者缓存加锁。
有点霸道。
面试官:那CAS有什么缺陷吗?
有段时间没更《今天面试了吗》系列了。在面试里,多线程,并发这块问的还是非常频繁的,不过JUC这块的内容实在太多,一篇文章很难理清楚。今天是第一章节,未完待续...
本文作者:坚持就是胜利,欢迎点击阅读原文访问作者主页,或者移步下方链接:
https://juejin.im/user/5bee7feee51d4536c03fc698
如果看到这里,说明你喜欢这篇文章,请 转发、点赞。同时 标星(置顶)本公众号可以第一时间接受到博文推送。
推荐阅读
2. 我和面试官的博弈:Spring篇
3. 我和面试官的博弈:集合篇