java面试必备--JVM篇(一)

      相信很多同行小伙伴会因为许多原因想跳槽,不论是干得不开心还是想跳槽涨薪,在如此内卷的行业,我们都面临着“面试造火箭,上班拧螺丝”的局面,鉴于当前形势博主呕心沥血整理的干货满满的造火箭的技巧来了,本博主花费2个月时间,整理归纳java全生态知识体系常见面试题!总字数高达百万! 干货满满,每天更新,关注我,不迷路,用强大的归纳总结,全新全细致的讲解来留住各位猿友的关注,希望能够帮助各位猿友在应付面试笔试上!当然如有归纳总结错误之处请各位指出修正!如有侵权请联系博主QQ1062141499!
 


目录

1 什么是JVM?

2 什么是JMM

3  Java 内存模型(JMM)

4 JVM内存区域如何划分?

5 如何理解Java内存模型?

6 如何保证变量对多线程的可见性

1 什么是JVM?

    1、Java Virtual Machine(Java虚拟机)的缩写

    2、实现跨平台的最核心的部分

    3、.class 文件会在 JVM 上执行,JVM 会解释给操作系统执行

    4、有自己的指令集,解释自己的指令集到 CPU 指令集和系统资源的调用

    5、JVM 只关注被编译的 .class 文件,不关心 .java 源文件

     Java 相比 C/C++ 最显著的特点便是引入了自动垃圾回收 (下文统一用 GC 指代自动垃圾回收),它解决了 C/C++ 最令人头疼的内存管理问题,让程序员专注于程序本身,不用关心内存回收这些恼人的问题,这也是 Java 能大行其道的重要原因之一,GC 真正让程序员的生产力得到了释放,但是程序员很难感知到它的存在,这就好比,我们吃完饭后在桌上放下餐盘即走,服务员会替你收拾好这些餐盘,你不会关心服务员什么时候来收,怎么收。

      有人说既然 GC 已经自动我们完成了清理,不了解 GC 貌似也没啥问题。在大多数情况下确实没问题,不过如果涉及到一些性能调优,问题排查等,深入地了解 GC 还是必不可少的,曾经美团通过调整 JVM 相关 GC 参数让服务响应时间 TP90,TP99都下降了10ms+,服务可用性得到了很大的提升!所以深入了解 GC 是成为一名优秀 Java 程序员的必修课!

2 什么是JMM

  JMM即为JAVA 内存模型(java memory model)。因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。Java内存模型,就是为了屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。JMM从java 5开始的JSR-133发布后,已经成熟和完善起来。

内存划分

  JMM规定了内存主要划分为主内存和工作内存两种。此处的主内存和工作内存跟JVM内存划分(堆、栈、方法区)是在不同的层次上进行的,如果非要对应起来,主内存对应的是Java堆中的对象实例部分,工作内存对应的是栈中的部分区域,从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存。

   JVM在设计时候考虑到,如果JAVA线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所以每条线程拥有各自的工作内存,工作内存中的变量是主内存中的一份拷贝,线程对变量的读取和写入,直接在工作内存中操作,而不能直接去操作主内存中的变量。但是这样就会出现一个问题,当一个线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。因为JMM制定了一套标准来保证开发者在编写多线程程序的时候,能够控制什么时候内存会被同步给其他线程。

内存交互操作

   内存交互操作有8种,虚拟机实现必须保证每一个操作都是原子的,不可在分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许例外)

    • lock     (锁定):作用于主内存的变量,把一个变量标识为线程独占状态
    • unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
    • read    (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
    • load     (载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
    • use      (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
    • assign  (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
    • store    (存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
    • write  (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

  JMM对这八种指令的使用,制定了如下规则:

    • 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write
    • 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
    • 不允许一个线程将没有assign的数据从工作内存同步回主内存
    • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作
    • 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
    • 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
    • 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
    • 对一个变量进行unlock操作之前,必须把此变量同步回主内存

  JMM对这八种操作规则和对volatile的一些特殊规则就能确定哪里操作是线程安全,哪些操作是线程不安全的了。但是这些规则实在复杂,很难在实践中直接分析。所以一般我们也不会通过上述规则进行分析。更多的时候,使用java的happen-before规则来进行分析。

模型特征

  原子性:例如上面八项操作,在操作系统里面是不可分割的单元。被synchronized关键字或其他锁包裹起来的操作也可以认为是原子的。从一个线程观察另外一个线程的时候,看到的都是一个个原子性的操作。

 synchronized (this) {
2             a=1;
3             b=2;
4         }

 

      例如一个线程观察另外一个线程执行上面的代码,只能看到a、b都被赋值成功结果,或者a、b都尚未被赋值的结果。

  可见性:每个工作线程都有自己的工作内存,所以当某个线程修改完某个变量之后,在其他的线程中,未必能观察到该变量已经被修改。volatile关键字要求被修改之后的变量要求立即更新到主内存,每次使用前从主内存处进行读取。因此volatile可以保证可见性。除了volatile以外,synchronized和final也能实现可见性。synchronized保证unlock之前必须先把变量刷新回主内存。final修饰的字段在构造器中一旦完成初始化,并且构造器没有this逸出,那么其他线程就能看到final字段的值。

  有序性:java的有序性跟线程相关。如果在线程内部观察,会发现当前线程的一切操作都是有序的。如果在线程的外部来观察的话,会发现线程的所有操作都是无序的。因为JMM的工作内存和主内存之间存在延迟,而且java会对一些指令进行重新排序。volatile和synchronized可以保证程序的有序性,很多程序员只理解这两个关键字的执行互斥,而没有很好的理解到volatile和synchronized也能保证指令不进行重排序。

Volatile内存语义

   volatile的一些特殊规则

Final域的内存语义

  被final修饰的变量,相比普通变量,内存语义有一些不同。具体如下:

    • JMM禁止把Final域的写重排序到构造器的外部。
    • 在一个线程中,初次读该对象和读该对象下的Final域,JMM禁止处理器重新排序这两个操作。
public class FinalConstructor {
 2 
 3     final int a;
 4 
 5     int b;
 6 
 7     static FinalConstructor finalConstructor;
 8 
 9     public FinalConstructor() {
10         a = 1;
11         b = 2;
12     }
13 
14     public static void write() {
15         finalConstructor = new FinalConstructor();
16     }
17 
18     public static void read() {
19         FinalConstructor constructor = finalConstructor;
20         int A = constructor.a;
21         int B = constructor.b;
22     }
23 }

假设现在有线程A执行FinalConstructor.write()方法,线程B执行FinalConstructor.read()方法。

  对应上述的Final的第一条规则,因为JMM禁止把Final域的写重排序到构造器的外部,而对普通变量没有这种限制,所以变量A=1,而变量B可能会等于2(构造完成),也有可能等于0(第11行代码被重排序到构造器的外部)。

   对应上述的Final的第二条规则,如果constructor的引用不为null,A必然为1,要么constructor为null,抛出空指针异常。保证读final域之前,一定会先读该对象的引用。但是普通对象就没有这种规则。

  (上述的Final规则反复测试,遗憾的是我并没有能模拟出来普通变量不能正常构造的结果

Happen-Before(先行发生规则)

  在常规的开发中,如果我们通过上述规则来分析一个并发程序是否安全,估计脑壳会很疼。因为更多时候,我们是分析一个并发程序是否安全,其实都依赖Happen-Before原则进行分析。Happen-Before被翻译成先行发生原则,意思就是当A操作先行发生于B操作,则在发生B操作的时候,操作A产生的影响能被B观察到,“影响”包括修改了内存中的共享变量的值、发送了消息、调用了方法等。

  Happen-Before的规则有以下几条

    • 程序次序规则(Program Order Rule):在一个线程内,程序的执行规则跟程序的书写规则是一致的,从上往下执行。
    • 管程锁定规则(Monitor Lock Rule):一个Unlock的操作肯定先于下一次Lock的操作。这里必须是同一个锁。同理我们可以认为在synchronized同步同一个锁的时候,锁内先行执行的代码,对后续同步该锁的线程来说是完全可见的。
    • volatile变量规则(volatile Variable Rule):对同一个volatile的变量,先行发生的写操作,肯定早于后续发生的读操作
    • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的没一个动作
    • 线程中止规则(Thread Termination Rule):Thread对象的中止检测(如:Thread.join(),Thread.isAlive()等)操作,必行晚于线程中所有操作
    • 线程中断规则(Thread Interruption Rule):对线程的interruption()调用,先于被调用的线程检测中断事件(Thread.interrupted())的发生
    • 对象中止规则(Finalizer Rule):一个对象的初始化方法先于一个方法执行Finalizer()方法
    • 传递性(Transitivity):如果操作A先于操作B、操作B先于操作C,则操作A先于操作C

  以上就是Happen-Before中的规则。通过这些条件的判定,仍然很难判断一个线程是否能安全执行,毕竟在我们的时候线程安全多数依赖于工具类的安全性来保证。想提高自己对线程是否安全的判断能力,必然需要理解所使用的框架或者工具的实现,并积累线程安全的经验。

3  Java 内存模型(JMM)

JMM 规定了线程的工作内存和主内存的交互关系,以及线程之间的可见性和程序的执行顺序。

  • 一方面,要为程序员提供足够强的内存可见性保证。
  • 另一方面,对编译器和处理器的限制要尽可能地放松。JMM 对程序员屏蔽了 CPU 以及 OS 内存的使用问题,能够使程序在不同的 CPU 和 OS 内存上都能够达到预期的效果。

Java 采用内存共享的模式来实现线程之间的通信。编译器和处理器可以对程序进行重排序优化处理,但是需要遵守一些规则,不能随意重排序。

在并发编程模式中,势必会遇到上面三个概念:

  • 原子性:一个操作或者多个操作要么全部执行要么全部不执行。
  • 可见性:当多个线程同时访问一个共享变量时,如果其中某个线程更改了该共享变量,其他线程应该可以立刻看到这个改变。
  • 有序性:程序的执行要按照代码的先后顺序执行。

通过 volatile、synchronized、final、concurrent 包等 实现。

4 JVM内存区域如何划分?

      在实际工作中,开发人员经常会遇到OOM以及StackOverflow等与内存有关的异常,同样需要掌握这方面的基本知识来帮助排查和定位问题。

JVM内存区域划分

JVM以进程的形式运行,JVM内存可大致分为线程共享区跟线程独占区。

  • 线程共享区 是JVM初始运行的时候就会分配好内存的区域,所有线程共享这个区域的数据,该区域的生命周期与JVM相同;
  • 线程独占区 又称为非线程共享区,JVM每遇到一个线程就会分配这么个区域,这个区域的生命周期与所属线程相同,线程终止,这个区域的内存也会被释放掉。

线程独占区

      线程独占区分为:程序计数器(Program Counter Register),虚拟机栈(VM Stack)和本地方法栈 (Native Method Stack)。JVM每遇到一个线程,就会为这三者分配内存,当线程终止时,它们所占用的内存空间也会被释放掉。

程序计数器(PCR) 

      程序计数器是一块较小的内存区域,作用是当前线程执行的字节码的位置指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令的。

虚拟机栈(VM Stack)

      每个方法被执行的时候都会创建一个栈帧( Stack Frame )用于存储局部变量表(Local Vrariables)、操作数栈(Operand Stack)、动态链接(Dynamic Linking)、方法返回地址(Return Address)等信息。每一个方法被调用的过程就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。

      局部变量表存放了编译期可知的各种基本数据类型、对象的引用和方法返回地址。操作数栈记录了用指令把数值入栈和出栈的操作。

      当一个方法 f1() 被调用时就产生了一个栈帧 F1,并被压入到栈中,f1() 方法又调用了 f2() 方法,于是产生了一个栈帧 F2 也被压入栈,执行完毕后,先弹出栈帧F2,再弹出栈帧 F1,遵循“先进后出”的原则。

       当线程请求的栈深度大于虚拟机所允许的深度时,就会出现StackOverflowError;如果虚拟机栈可以动态扩展,当需要扩展空间而无法申请到足够的内存时,就会出现OutOfMemoryError。

本地方法栈(Native Method Stack)

       本地方法栈的作用与虚拟机栈十分相似,区别在于虚拟机栈执行的是Java方法,而本地方法栈则为虚拟机所用到的native方法服务,底层调用的可能是C或者C++的函数。

线程共享区

       线程共享区分为:方法区(Method Area)和(Heap),方法区也被称为非堆区(Non-Heap)。JVM初始运行的时候都会分配好这两个区域的内存空间。

方法区(Method Area)

     类定义数据(Object Class Data) ,也就是类信息是存储在方法区的(不是类的Class对象,Class对象在堆里)。除此之外,常量、静态变量、即时编译器(JIT)编译后的代码也都在这个区域。

      垃圾回收在这个区域会比较少出现,这个区域内的回收主要是针对常量池的回收和类的卸载。方法区是Java虚拟机规范的定义,对于Sun的Java虚拟机实现 HotSpots,在1.7版本之前,HotSpot 使用永久代实现方法区;在Java8中,HotSpots用元空间取代了永久代。

      方法区内部有一个非常重要的区域,叫做运行时常量池(Runtime Constant Pool,简称 RCP)。在字节码文件(.class文件)中,除了有类的版本、字段、方法、接口等信息描述外,还有常量池(Constant Pool Table)信息,用于存储编译器产生的字面量和符号引用。这部分内容在类加载后,都会在RCP中存放。

堆(Heap)

      堆是JVM的内存数据区,是被所有线程共享的内存区域,在JVM启动时候创建,专门用来保存对象的实例。

      在堆中分配一定的内存来保存对象实例,实际上也只是保存对象实例的属性值、属性的类型以及对象本身的类型标记,并不保存对象的方法,对象方法以帧栈的形式保存在VM Stack中的。

       所有的对象实例以及数组都要在堆上分配,堆也是垃圾回收的主要场所。堆的管理很复杂,非一两句能说得清楚,篇幅有限,关于堆的话题我们在后续的文章中探讨。

5 如何理解Java内存模型?

         在《Java面试必考问题:JVM内存区域如何划分? 》中介绍了Java虚拟机的内存区域是如何划分的。Java还有另外一个重要概念:Java内存模型(JMM:Java Memory Model),要注意的是JMM和JVM内存区域可不是一回事。

Java内存模型是什么?

       Java是跨平台的语言,因为JVM屏蔽了底层操作系统和物理计算机的差异,JMM也是JVM规范定义的一部分,JMM抽象地定义了JVM在计算机内存中的工作方式。

        JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory)

从JMM定义来看,线程工作内存对应Stack,主内存对应Heap

        JMM的主内存和工作内存都是抽象概念,与JVM内存的堆栈没有直接关系;不过从JMM的定义来看,主内存应该主要对应的是堆中的对象实例,工作内存主要对应的是虚拟机栈中的变量。

 

硬件内存架构

        从更底层的硬件内存架构来看,主内存对应的基本是物理内存(RAM),线程中的工作内存主要对应的是CPU寄存器和高速缓存,因为线程在这里进行变量IO操作性能更好。

 

      线程的工作内存中保存了该线程使用的主内存变量的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。

      不同的线程之间无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。

线程的数据共享

      JMM规定了线程之间的通信必须经过主内存,例如:线程a要将变量传给线程b,首先线程a要将本地工作内存中的变量刷新到主内存中的共享变量,然后线程b去主内存中读取刚刚被线程a更新后的共享变量,才能完成变量的传递。

      线程间的传递需要将变量数据在工作内存和主内存之间来回传递。JMM 定义了 8 个操作来完成主内存和工作内存之间的交互操作。

 

 

  • assign(赋值):作用于工作内存的变量,把一个从执行引擎接收到的值赋值给工作内存的变量;
  • store (存储):作用于工作内存的变量,把工作内存中的变量的值传送到主内存中 , 以便随后的写入操作;
  • write (写入):作用于主内存的变量, 它把传送给主内存的变量的值写入到主内存的变量中;
  • unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来 , 释放后的变量才可以被其他线程锁定;
  • lock(锁定):作用于主内存的变量,另外一个需要使用这个变量的线程对变量锁定,把一个主内存的变量标识为线程独占状态;
  • read(读取):作用于主内存的变量,把变量值从主内存的变量中读取出来,以便随后载入工作内存的变量副本;
  • load(载入):作用于工作内存的变量,它把从主内存中得到的变量值放入工作内存的变量副本中;
  • use (使用):作用于工作内存的变量 ,将工作内存中的变量值传递给执行引擎使用。

至此,两个线程完成了变量的传递。

JMM的三大特性

      JMM是一个抽象的概念,是围绕着并发编程中原子性、可见性、有序性这三个特性来建立的。上面介绍的Java 内存交互的8个基本操作,都遵循这三个特性。

  • 原子性:一个操作不能被打断,要么全部执行完毕,要么不执行。在这点上有点类似于事务操作,要么全部执行成功,要么回退到执行该操作之前的状态。
  • 可见性:一个线程对共享变量做了修改之后,其他的线程立即能够看到该变量的这种修改。JMM是通过主内存作为变量传递媒介来实现的。
  • 有序性:在本线程内观察,所有的操作都是有序的;观察另外的线程,所有的操作都是无序的。编译器和处理器可能会对操作做重排序。对于单线程,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序,但不保证多线程的数据依赖。

        多线程共享数据时,为了防止指令重排导致程序运行紊乱,我们需要线程同步和互斥技术来保证多线程之间协同工作。

理解好Java内存模型,有利于我们做好JVM多线程并发的编程开发。

6 如何保证变量对多线程的可见性

可见性问题

      前文《Java面试必考问题:如何理解Java内存模型? 》介绍了Java内存模型(JMM: Java Memory Model)。JMM的设计始终围绕着多线程并发的三个重要特性:原子性(Atomicity)可见性(Visibility)有序性(Ordering)。在并发编程中,我们要始终注意如何处理这三个特性的问题。

       本文重点讨论共享变量对于多线程的可见性。多线程可见性是指线程对共享变量修改的可见性。当一个线程修改了共享变量的值,其他线程能够立刻得知这个修改。

       在真实的情况下,线程缓存会导致共享变量的不可见问题。为了读写性能,线程通常会把共享变量(obj.count = 1)读取到CPU缓存中。如果运行在一个CPU的线程修改了缓存中的变量(obj.count = 2),却并没有同步到内存中,那么这个修改对于另外一个CPU上运行的线程是不可见的,对另外一个线程来说,这个变量的值还是原来的值(obj.count = 1)。

可见性问题的解决

      JMM是通过依赖将主内存作为共享变量传递媒介的方式来实现可见性的,换句话说,就是 "变量修改后将新值同步回主内存, 变量读取前从主内存刷新变量值" 。

      在Java语言中,解决可见性问题主要有以下三种方式:

      volatile:用 volatile 关键词修饰的变量,可以保证新值能立即同步到主内存,以及每个线程在每次使用 volatile 变量前都立即从主内存刷新。volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

        synchronized:synchronized 可以修饰方法或代码块,声明同步方法同步代码块。在同步方法/同步块开始时(Monitor Enter),使用共享变量时会从主内存中读取最新的变量值到工作内存中,在同步方法/同步块结束时(Monitor Exit),会将工作内存中的变量值同步到主内存中去。使用 Lock 接口实现 ReentrantLock (重入锁) 也可以实现相同语义,

        final:被 final 修饰的字段在构造器中一旦被初始化完成,且构造器没有把 this 引用传递出去,那么在其他线程就能看见 final 字段的值。

      final 变量在并发当中,原理是通过禁止CPU的指令重排序来实现可见性。编译器和处理器要遵守两个重排序规则:

  • 在构造函数内对一个final域的写入,与随后把这个对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  • 初次读一个包含final域的对象的引用,与随后初次读这个对象的final域,这两个操作之间不能重排序。

       final 主要用于不可变变量,只要在变量初始化前保证读写顺序,一旦初始化完成,final 变量的值立刻回写到主内存。初始化之后也就不存在问题了。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值