menu

面试JVM篇

JAVA面试大纲-JVM篇
  1. 虚拟机的处理流程图 JVM流程图
    首先java源代码文件(.Java后缀)会被java编译器编译为字节码文件(.class后缀),然后由jvm中的类加载器加载各个类的字节码文件,加载完毕之后,交由jvm执行引擎执行。在整个程序执行过程中,jvm会用一段空间来存储程序执行过程期间需要用到的数据和相关信息,这段空间一般被称作为runtime Data Area(运行时数据区)也就是我们常说的jvm内存。因此,在java中我们常常说到内存管理就是针对这段空间进行管理(如何分配和管理内存空间)。
  2. JVM数据区说明
    JVM数据区说明
    根据《java虚拟机规范》的规定,运行时数据取通常包括这几个部分:程序计时器(program counter register)、Java栈(jvm Stack)、本地方法栈(Native Method Stack)、方法区(Method Area)、堆(Heap)
  3. 运行时数据区的每部分到底存储了那些数据?
    3.1 程序计数器(Program Counter Register),也有称作为PC寄存器。想必学过汇编语言的朋友对程序计数器这个概念并不陌生,在汇编语言中,程序计数器是指CPU中的寄存器,他保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址)当CPU需要执行指令是,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到的指令,在得到指令之后,程序计数器便自动加1或者根据转移性指针得到下一条指令的地址,如此循环,直至执行完所有的指令。 虽然jvm中的程序计数器并不像汇编语言中的程序计数器一样是物理概念上的CPU寄存器,但是jvm中的程序计数器的功能跟汇编语言中的程序计数器的功能在逻辑上是等同的,也就是说是用来只是执行那条指令的。 由于在jvm中,多线程是通过线程轮流切换来获得CPU执行时间的,因此,在任一具体时刻,一个CPU的内核只会执行一条线程中的指令。因此,为了能够使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序。因此,可以这么说,程序计数器是每个线程所私有的。 在jvm规范中规定,如果线程执行的是非native方法,则程序计数器中保存的是当前需要执行的的指令地址;如果线程执行的是native方法,则程序计数器中的值是undefined。 由于程序计数器中存储的数据所占空间大小不会随程序的执行而发生改变,因此,对于程序计数器是不会发生内存溢出现象(OutofMemory)的。
    3.2 java栈 java栈也称作虚拟机栈(Java Vitual Machine Stack),也就是我们常常所说的栈。跟C语言的数据段中栈类似,事实上,java栈是java方法执行的内存模型。为什么这么说呢?下面我们就来解释一下其中的原因。 JAVA栈中存放的是一个个的栈帧,每个栈帧对应一个被调用的方法,在栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向当前方法所属的类的运行时常量池的引用、方法返回地址(Return Address)和一些额外的附加信息。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。 因此可知,线程当前执行的方法所对应的栈帧必定位于java栈的顶部。讲到这里 大家就应该会明白为什么 在 使用 递归方法的时候容易导致栈内存溢出的现象了以及为什么栈区的空间不用程序员去管理了(当然在Java中,程序员基本不用关系到内存分配和释放的事情,因为Java有自己的垃圾回收机制),这部分空间的分配和释放都是由系统自动实施的。对于所有的程序设计语言来说,栈这部分空间对程序员来说是不透明的。下图表示了一个Java栈的模型:

    https://mp.weixin.qq.com/s?__biz=MzI4Njc5NjM1NQ==&mid=2247484719&idx=1&sn=c0f3a3d518dd89a08acad5f0ef0cdca6&scene=19&key=2360adfbb4382791242e50e31716b27a3af2c36e198d4a5999d2fc952cfbce6add55b0fcbaa6971f168845fa247c2304d894734400cec9929e3852de9f4c518312f9818c0983a84d0245f9d593a5a4f5&ascene=7&uin=MjMyNTI1MzcyMA%3D%3D&devicetype=Windows+10&version=6206034e&lang=zh_CN&pass_ticket=6TfnEKDyiyyDwPWc6ACJrWHdaNJRVp1WYWaxXF31sHa3P%2Fa5gZdEqNym%2BfuAEiKE&winzoom=1

    局部变量表,顾名思义,想必不用解释大家应该明白它的作用了吧。就是用来存储方法中的局部变量(包括在方法中声明的非静态变量以及函数形参)。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用。局部变量表的大小在编译器就可以确定其大小了,因此在程序执行期间局部变量表的大小是不会改变的。 操作数栈,想必学过数据结构中的栈的朋友想必对表达式求值问题不会陌生,栈最典型的一个应用就是用来对表达式求值。想想一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根到底就是进行计算的过程。因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的。 指向运行时常量池的引用,因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。 方法返回地址,当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。 由于每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的Java栈,互不干扰。
    3.3 本地方法栈 本地方法栈与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。在JVM规范中,并没有对本地方发展的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。
    3.4 .堆 在C语言中,堆这部分空间是唯一一个程序员可以管理的内存区域。程序员可以通过malloc函数和free函数在堆上申请和释放空间。那么在Java中是怎么样的呢? Java中的堆是用来存储对象本身的以及数组(当然,数组引用是存放在Java栈中的)。只不过和C语言中的不同,在Java中,程序员基本不用去关心空间释放的问题,Java的垃圾回收机制会自动进行处理。因此这部分空间也是Java垃圾收集器管理的主要区域。另外,堆是被所有线程共享的,在JVM中只有一个堆。
    3.5 方法区 方法区在JVM中也是一个非常重要的区域,它与堆一样,是被线程共享的区域。在方法区中,存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。 在Class文件中除了类的字段、方法、接口等描述信息外,还有一项信息是常量池,用来存储编译期间生成的字面量和符号引用。 在方法区中有一个非常重要的部分就是运行时常量池,它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。当然并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的intern方法。 在JVM规范中,没有强制要求方法区必须实现垃圾回收。很多人习惯将方法区称为“永久代”,是因为HotSpot虚拟机以永久代来实现方法区,从而JVM的垃圾收集器可以像管理堆区一样管理这部分区域,从而不需要专门为这部分设计垃圾回收机制。不过自从JDK7之后,Hotspot虚拟机便将运行时常量池从永久代移除了。

    https://mp.weixin.qq.com/s?__biz=MzI4Njc5NjM1NQ==&mid=2247484719&idx=1&sn=c0f3a3d518dd89a08acad5f0ef0cdca6&scene=19&key=2360adfbb4382791242e50e31716b27a3af2c36e198d4a5999d2fc952cfbce6add55b0fcbaa6971f168845fa247c2304d894734400cec9929e3852de9f4c518312f9818c0983a84d0245f9d593a5a4f5&ascene=7&uin=MjMyNTI1MzcyMA%3D%3D&devicetype=Windows+10&version=6206034e&lang=zh_CN&pass_ticket=6TfnEKDyiyyDwPWc6ACJrWHdaNJRVp1WYWaxXF31sHa3P%2Fa5gZdEqNym%2BfuAEiKE&winzoom=1

  4. 总结 1 :基础数据类型直接在栈空间分配; 2 :方法的形式参数,直接在栈空间分配,当方法调用完成后从栈空间回收; 3 :引用数据类型,需要用new来创建,既在栈空间分配一个地址空间,又在堆空间分配对象的类变量; 4 :方法的引用参数,在栈空间分配一个地址空间,并指向堆空间的对象区,当方法调用完后从栈空间回收; 5 :局部变量new出来时,在栈空间和堆空间中分配空间,当局部变量生命周期结束,栈空间立刻被回收,堆空间区等待GC回收; 6 :方法调用时传入的实际参数,现在栈空间分配,在方法调用完成后从栈空间释放; 7 :字符创常量在DATA区域分配,this在堆空间分配; 8 :数组既在栈空间分配数组名称,又在堆空间分配数组实际的大小。
  5. JVM参数知一二

    jvm常用参数

  6. 垃圾回收
    6.1 如何确定某个对象是“垃圾”? 在这一小节我们先了解一个最基本的问题:如果确定某个对象是“垃圾”?既然垃圾收集器的任务是回收垃圾对象所占的空间供新的对象使用,那么垃圾收集器如何确定某个对象是“垃圾”?—即通过什么方法判断一个对象可以被回收了。 在java中是通过引用来和对象进行关联的,也就是说如果要操作对象,必须通过引用来进行。那么很显然一个简单的办法就是通过引用计数来判断一个对象是否可以被回收。不失一般性,如果一个对象没有任何引用与之关联,则说明该对象基本不太可能在其他地方被使用到,那么这个对象就成为可被回收的对象了。这种方式成为引用计数法。(废弃) 这种方式的特点是实现简单,而且效率较高,但是它无法解决循环引用的问题,因此在Java中并没有采用这种方式(Python采用的是引用计数法)。看下面这段代码:
     public class Main {
    2	    public static void main(String[] args) {
    3	        MyObject object1 = new MyObject();
    4	        MyObject object2 = new MyObject();
    5	         
    6	        object1.object = object2;
    7	        object2.object = object1;
    8	         
    9	        object1 = null;
    10	        object2 = null;
    11	    }
    12	}
    13	 
    14	class MyObject{
    15	    public Object object = null;
    16	}
    

    最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数都不为0,那么垃圾收集器就永远不会回收它们。 为了解决这个问题,在Java中采取了 (可达性分析法。也叫根搜索法。)该方法的基本思想是通过一系列的“GC Roots”对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的,不过要注意的是被判定为不可达的对象不一定就会成为可回收对象。被判定为不可达的对象要成为可回收对象必须至少经历两次标记过程,如果在这两次标记过程中仍然没有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了。 至于可达性分析法具体是如何操作的我暂时也没有看得很明白,如果有哪位朋友比较清楚的话请不吝指教。 下面来看个例子:

    1	Object aobj = new Object ( ) ;
    2	Object bobj = new Object ( ) ;
    3	Object cobj = new Object ( ) ;
    4	aobj = bobj;
    5	aobj = cobj;
    6	cobj = null;
    7	aobj = null;
    

       第几行有可能会使得某个对象成为可回收对象?第7行的代码会导致有对象会成为可回收对象。至于为什么留给读者自己思考。 再看一个例子:

    1	String str = new String("hello");
    2	SoftReference<String> sr = new SoftReference<String>(new String("java"));
    3	WeakReference<String> wr = new WeakReference<String>(new String("world"));
    

    这三句哪句会使得String对象成为可回收对象?第2句和第3句,第2句在内存不足的情况下会将String对象判定为可回收对象,第3句无论什么情况下String对象都会被判定为可回收对象。 最后总结一下平常遇到的比较常见的将对象判定为可回收对象的情况:

  7. 显示地将某个引用赋值为null或者将已经指向某个对象的引用指向新的对象,比如下面的代码:
    1	Object obj = new Object();
    2	obj = null;
    3	Object obj1 = new Object();
    4	Object obj2 = new Object();
    5	obj1 = obj2;
    2)局部引用所指向的对象,比如下面这段代码:
    1	void fun() {
    2	 
    3	.....
    4	    for(int i=0;i<10;i++) {
    5	        Object obj = new Object();
    6	        System.out.println(obj.getClass());
    7	    }   
    8	}
    

       循环每执行完一次,生成的Object对象都会成为可回收的对象。

  8. 只有弱引用与其关联的对象,比如:
    WeakReference wr = new WeakReference(new String("world")); 6.2 .典型的垃圾收集算法 在确定了哪些垃圾可以被回收后,垃圾收集器要做的事情就是开始进行垃圾回收,但是这里面涉及到一个问题是:如何高效地进行垃圾回收。由于Java虚拟机规范并没有对如何实现垃圾收集器做出明确的规定,因此各个厂商的虚拟机可以采用不同的方式来实现垃圾收集器,所以在此只讨论几种常见的垃圾收集算法的核心思想。
  9. Mark-Sweep(标记-清除)算法 这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。具体过程如下图所示:

从图中可以很容易看出标记-清除算法实现起来比较容易,但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。

  1. Copying(复制)算法 为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:

这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。 很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。

  1. Mark-Compact(标记-整理)算法 为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。具体过程如下图所示:

  2. Generational Collection(分代收集)算法 分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。 目前大部分垃圾收集器对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。 而由于老年代的特点是每次回收都只回收少量对象,一般使用的是Mark-Compact算法。 注意,在堆区之外还有一个代就是永久代(Permanet Generation),它用来存储class类、常量、方法描述等。对永久代的回收主要回收两部分内容:废弃常量和无用的类。 6.3 .典型的垃圾收集器 垃圾收集算法是 内存回收的理论基础,而垃圾收集器就是内存回收的具体实现。下面介绍一下HotSpot(JDK 7)虚拟机提供的几种垃圾收集器,用户可以根据自己的需求组合出各个年代使用的收集器。
  3. Serial/Serial Old Serial/Serial Old收集器是最基本最古老的收集器,它是一个单线程收集器,并且在它进行垃圾收集时,必须暂停所有用户线程。Serial收集器是针对新生代的收集器,采用的是Copying算法,Serial Old收集器是针对老年代的收集器,采用的是Mark-Compact算法。它的优点是实现简单高效,但是缺点是会给用户带来停顿。
  4. ParNew ParNew收集器是Serial收集器的多线程版本,使用多个线程进行垃圾收集。
  5. Parallel Scavenge Parallel Scavenge收集器是一个新生代的多线程收集器(并行收集器),它在回收期间不需要暂停其他用户线程,其采用的是Copying算法,该收集器与前两个收集器有所不同,它主要是为了达到一个可控的吞吐量。
  6. Parallel Old Parallel Old是Parallel Scavenge收集器的老年代版本(并行收集器),使用多线程和Mark-Compact算法。
  7. CMS CMS(Current Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它是一种并发收集器,采用的是Mark-Sweep算法。
  8. G1 G1收集器是当今收集器技术发展最前沿的成果,它是一款面向服务端应用的收集器,它能充分利用多CPU、多核环境。因此它是一款并行与并发收集器,并且它能建立可预测的停顿时间模型。 下面补充一下关于内存分配方面的东西:

对象的内存分配,往大方向上讲就是在堆上分配,对象主要分配在新生代的Eden Space和From Space,少数情况下会直接分配在老年代。如果新生代的Eden Space和From Space的空间不足,则会发起一次GC,如果进行了GC之后,Eden Space和From Space能够容纳该对象就放在Eden Space和From Space。在GC的过程中,会将Eden Space和From  Space中的存活对象移动到To Space,然后将Eden Space和From Space进行清理。如果在清理的过程中,To Space无法足够来存储某个对象,就会将该对象移动到老年代中。在进行了GC之后,使用的便是Eden space和To Space了,下次GC时会将存活对象复制到From Space,如此反复循环。当对象在Survivor区躲过一次GC的话,其对象年龄便会加1,默认情况下,如果对象年龄达到15岁,就会移动到老年代中。 一般来说,大对象会被直接分配到老年代,所谓的大对象是指需要大量连续存储空间的对象,最常见的一种大对象就是大数组,比如: byte[] data = new byte[410241024] 这种一般会直接在老年代分配存储空间。 当然分配的规则并不是百分之百固定的,这要取决于当前使用的是哪种垃圾收集器组合和JVM的相关参数。 参考资料: 《深入理解Java虚拟机》

来自 https://blog.csdn.net/u013835855/article/details/78908714

  1. JVM调优

    JVM调优参考