JVM进阶——资深面试官必问的Java内存模型,顺利收获Offer

2021/12/20 7:25:39

本文主要是介绍JVM进阶——资深面试官必问的Java内存模型,顺利收获Offer,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

  • 如何避免伪共享

  • 最后,祝大家早日学有所成,拿到满意offer,快速升职加薪,走上人生巅峰。 可以的话请给我一个三连支持一下我哟,我们下期再见

硬件的效率与一致性


物理计算机中的并发问题和Java虚拟机有很多相似之处。

为了解决处理器与内存之间的速度矛盾,引入了高速缓存

高速缓存的引入带来了问题:缓存一致性。多路处理器系统中,每个处理器有各自的高速缓存,而他们又共享同一主内存。当多个处理器的运算任务额都涉及同一块主存区域的时候,将可能导致各自的缓存数据不一致。

为了解决一致性的问题,需要各个处理器在访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。我们将会多次提到“内存模型”一词,可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问时的过程抽象。不同的物理机器可以拥有不同的内存模型。而Java虚拟机也拥有自己的内存模型

除了增加高速缓存,处理器会对代码进行乱序执行优化。对应JVM中的指令重排序

Java内存模型


《Java虚拟机规范》中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

此前(如:C语言)直接使用物理硬件和操作系统的内存模型,导致一套程序在不同的平台上出现不同的错误。

主内存与工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

这里的变量不包括局部变量和参数,因为其实线程私有的,不会被共享,自然不会存在竞争问题。

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存(可与高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量(包括volatile变量也是这样)。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

在这里插入图片描述

一起吹水聊天

内存间交互操作

关于主内存和工作内存之间具体的交互协议:即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节。Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的。

  • lock(主内存)

  • unlock(主内存)

  • read(主内存)

  • load(工作内存)

  • use(工作内存)

  • assign(工作内存)

  • store(工作内存)

  • write(主内存)

注意:Java内存模型只要求这两个操作(read、load 操作,store、write 操作)必须按顺序执行,而没有保证是连续执行。

也就是说readwrite之间是可以插入其他指令的,如

read a、read b、load b、load a

原子性、可见性与有序性


Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的

原子性

一个操作要么都发生,要么都不发生

  • 基本数据的读写都是具备原子性的(32位机器要注意long和double)

  • synchronized块之间的操作也是具备原子性的

可见性

一个线程修改了值,其他线程能够立刻知道

  • 和普通变量一样,volatile 变量也是通过主内存作为传递媒介的,但volatile和主内存之间的读和写是立刻发生的

  • 除了volatile,synchronized(unlock之前就会同步到主内存中),final(只要没有 this 逃逸)

有序性

在本线程中观察,所有操作都是有序的(指本线程内表现为串行的语义)

在另一个线程中观察,所有操作都是无序的(指“指令重排序”现象和“工作内存和主内存同步延迟”现象)

  • volatile本身就包含指令重排序的语义

  • synchronized 则一个变量在同一时刻只允许一条线程对其进行 lock 操作

先行发生原则


Java内存模型中有一个先行发生原则,它是判断数据是否存在竞争,线程是否安全的重要手段。

我们

《一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义》

【docs.qq.com/doc/DSmxTbFJ1cmN1R2dB】 完整内容开源分享

举个例子来说明说明什么是先行发生原则

// A 线程

i = 1;

// B 线程

j = i;

// C 线程

j = 2;

如果 A 先行发生于 B,且 C 没有登场,那么,j 的值一定是 1。如果 C 登场了,仍旧只是 A 先行发生于 B,那么 j 可能是 2,也可能是 1

Java 内存模型中有一些天然的先行发生原则,其中介绍下面两条;

  • 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。 这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。

  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。

volatile变量规则

一起吹水聊天

对于volatile变量的读和写而言,如果在实际执行时间上有写在读前的话

(如线程A的assign在先于线程B的use执行,前面说了assign、use是原子操作)

,那么就有写在读前的先行发生关系,这样就保证了一切对于volatile变量写操作可见的变量(即happens before volatile写操作的其他变量操作所造成的一切影响),对于后面的volatile变量读操作也是可见的。如果换成可普通变量,即使是有时间上的写在读前,但如不是同一线程就没有happens关系,这样就不能保证可见性。

private int value = 0;

public void setValue(int value) {

this.value = value;

}

public int getValue() {

return value;

}

  • 假设有线程 A 和 B ,在时间顺序上,A 先调用 setValue(2),B 再调用 getValue(),那么,B 得到的值还是不确定的。

  • 可以把 value 修饰为 volatile 类型,由于 setter 方法对 value 的修改不依赖于原值,所以将会是线程安全的。

线程安全


Java中的线程安全排序:

  • 不可变

  • 绝对线程安全

  • 相对线程安全

  • 线程兼容

  • 线程对立

this逃逸

在构造器构造还未彻底完成前(即实例初始化阶段还未完成),将自身this引用向外抛出并被其他线程复制(访问)了该引用。

class ThisEscape {

int i;

static ThisEscape obj;

public ThisEscape() { // 由于指令重排序,所以不能确定这两部谁先进行

i = 1;

obj = this;

}

}

// 如果线程A还没来得及为i赋值,线程B就使用了这个obj.i;会导致空指针。或者其他情况下会导致对象不完整。

什么情况下会This逃逸?

(1)如上述的明显将this抛出

(2)在构造器中内部类使用外部类情况:内部类访问外部类是没有任何条件的,也不要任何代价,也就造成了当外部类还未初始化完成的时候,内部类就尝试获取为初始化完成的变量

  • 在构造器中启动线程:启动的线程任务是内部类,在内部类中xxx.this访问了外部类实例,就会发生访问到还未初始化完成的变量

  • 在构造器中注册事件,这是因为在构造器中监听事件是有回调函数(可能访问了操作了实例变量),而事件监听一般都是异步的。在还未初始化完成之前就可能发生回调访问了未初始化的变量。

不可变

  • final修饰:只要一个对象被正确地构建出来(即没有发生this引用逃逸)

  • 如果多线程共享的是一个基本数据类型,那只要再定义时使用final关键字修饰就可以保证它是不可变的。如果是一个对象,那就需要保证它自己不可变(如:String无论用什么方法都不会影响它原来的值,它是由final修饰的)

一起吹水聊天

绝对线程安全

再Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。

如:Vector类,它的add()、get()等方法都是由synchronized修饰的,但不意味着调用它的时候不需要原子操作(指要么都做要么都不做)了。

public void method(Vector vec) {

synchronized(vec) {

vec.set(…);

vec.get(…);

}

}

相对线程安全

指我们通常意义上的线程安全,它需要保证这个对象单次的操作是线程安全的。如:Vector类。

线程兼容

指我们通常所说的线程不安全。指对象本身并不安全,但可以通过调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。

线程对立

指不管怎样操作都无法在多线程环境中并发使用。由于Java本身就具备支持多线程地特性,线程对立地情况很少出现。

线程安全的实现方法


互斥同步

同步是指在多线程并发访问共享数据时,保证共享数据同一时刻只被一条线程使用(或者是一些,当使用信号量时)。

互斥是同步的一种手段:临界区、互斥量和信号量都是常见的互斥实现方法。

互斥同步这四个字里:互斥是因,同步是果。互斥是方法,同步是目的。

我们拿Reentrantsynchronized多了一些高级功能:

  • 我们可以认为synchronized是Reentrant的一个子集,但是经过JDK6优化后,他们性能差不多,而synchronized使用方便

  • 公平锁:他们都是使用非公平锁(Reentrant可以改为公平锁,但性能会下降)

  • 锁绑定多个条件:Reentrant可以绑定多个Condition对象,而synchronized不行。

  • 等待可中断:当持有锁的线程长期不释放锁时,正在等待的线程可以放弃等待,去处理其他的事情。(synchronized则会一直阻塞,如果阻塞或者唤醒一条线程,则需要操作系统在用户态到核心态之间的转换,这样很耗资源)

一起吹水聊天

非阻塞同步

互斥同步面临的问题是线程阻塞和唤醒带来的性能开销,这称为阻塞同步,是一种悲观的发展策略,因为不管是否出现竞争都进行加锁。

非阻塞同步:是一种乐观发展策略,就是不管风险先进行操作(如CAS算法),但是它需要硬件指令集的发展(使多个步骤的操作具备原子性),使某些看起来的多步操作只要一步就可以完成。不过它无法解决ABA问题,如果要解决ABA问题,改用传统的互斥同步可能会更快。

无同步方案

同步和线程安全没有必然的联系。如果一个方法本来就不涉及共享数据,那自然就不需要任何同步措施就能保证其正确性。

  • 可重入代码

  • 线程本地存储(如:使用ThreadLocal类)

锁优化


从 JDK5 到 JDK6 虚拟机开发团队实现的各种锁优化技术

自旋锁与自适应自旋

互斥同步对性能最大的影响是阻塞的实现,需要用户态与内核态切换(需要消耗的不止CPU资源)。

如果机器上有多个处理器或者处理器核心,能让两个线程并行执行,我们就会请求其中一个线程等一会儿,但不放弃处理器的执行时间,这里的等一会就是忙循环(自旋)。默认忙循环10次,也可以自行设置。

如果,循环的时间不再是固定的,而是它自己决定的,那就是自适应自旋。

锁消除

对被检测到不可能存在共享数据竞争的锁进行消除。你可能觉得自己没有加锁呀,可是编译器会加锁。如:



这篇关于JVM进阶——资深面试官必问的Java内存模型,顺利收获Offer的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程