JVM进阶——资深面试官必问的Java内存模型

本文探讨了Java内存模型的原理,涵盖了硬件效率与一致性、主内存与工作内存、内存间交互操作、原子性、可见性与有序性,以及锁优化、伪共享等关键概念。通过实例解析和实战技巧,助你应对复杂面试问题,提升线程安全设计能力。
摘要由CSDN通过智能技术生成

最近行业里面试提的问题可谓是越来越刁钻了,面个初级程序员都要问JVM,面试造火箭,工作拧螺丝成了常态。没办法,想着给大家做点面试题集锦的,纠结了下还是好好给大家讲下具体原理吧,于是又把《深入理解Java虚拟机》和一些官方的非官方文档都研究了一下,感觉头发不够用了。

在这里插入图片描述

硬件的效率与一致性

物理计算机中的并发问题和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内存模型中有一个先行发生原则,它是判断数据是否存在竞争,线程是否安全的重要手段。

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

// 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<Double> 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次,也可以自行设置。

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

锁消除

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

public String add(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}
// javac转换后
public String add(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

一起吹水聊天

锁粗化

如果一系列的操作都是对同一个对象进行反复加锁,甚至加锁是出现在循环体中的,那即使线程没有竞争,频繁地进行同步操作也会导致不必要地性能损耗。

如:上面的append()方法。虚拟机就会就会把锁扩展到第一个append()操作之前直至到最后一个append()操作之后。

轻量级锁

偏向锁

《深入理解Java虚拟机》

伪共享

什么是伪共享

为了解决计算机系统中主内存与CPU之间运行速度差的问题,会在CPU与主内存之间添加一级或多级高速缓冲存储器(Cache)。在Cache内部是按行存储的。

当CPU访问某个变量时,首先去缓存里看有没有该变量,如果有就直接获取,如果没有就去主内存中获取。

CPU读取数据通常以一块连续的块为单位,即缓存行(Cache Line)。一个缓存行里可能有多个变量。所以通常情况下访问连续存储的数据会比随机访问要快,访问数组结构通常比链结构快,因为通常数组在内存中是连续分配的。(PS. JVM标准并未规定“数组必须分配在连续空间。)

缓存行的大小通常是64字节,这意味着即使只操作1字节的数据,CPU最少也会读取这个数据所在的连续64字节数据。

缓存失效:根据主流CPU为保证缓存有效性的MESI协议的简单理解,如果一个核正在使用的数据所在的缓存行被其他核修改,那么这个缓存行会失效,需要重新读取缓存。

False Sharing:如果多个核的线程在操作同一个缓存行中的不同变量数据,那么就会出现频繁的缓存失效,即使在代码层面看这两个线程操作的数据之间完全没有关系。这种不合理的资源竞争情况学名伪共享(False Sharing),会严重影响机器的并发执行效率。

如何避免伪共享

在Java8之前,可以使用填充的方式来避免该问题,也就是创建一个变量时使用填充字段填充该变量所在的 Cache Line。如:

public static void FilledLong() {
    public long value = 0L;
    public long a1, a2, a3, a4, a5, a6;
}
// 假如Cache Line是64个字节的,而 long 是 8 个字节的,那么现在有 56 个字节,类对象FilledLong占用8个字节,刚好64个字节。

JDK8 提供了 sun.misc.Contended 注解来解决这个问题。

@sun.misc.Contended
public static void FilledLong() {
    public long value = 0L;
}
// 当然这个注解也可以用来修饰字段。
@sun.misc.Contended
long a;
// 默认情况下,这个注解只用于Java核心类,如rt包下的类。
// 如果用户路径下要使用这个注解,则需要添加JVM参数:-XX:RestrictContended,默认的填充大小为128个字节。
// 要自定义宽度:-XX:ContendedPaddingWidth

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

一起吹水聊天

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值