一.内存管理
1.什么是并发,并行?
并发:同一时间同时发生,内部可能存在串行或者并行.又称共行性,是指处理多个同时性活动的能力,。
并行:同一时间点同时执行,不存在阻塞.指同时发生两个并发事件,具有并发的含义。并发不一定并行,也可以说并发事件之间不一定要同一时刻发生。
并发当有多个线程在操作时,如果系统只有一个 CPU,则它根本不可能真正同时进行一个以上的线程,它只能把 CPU 运行时间划分成若干个时间段,再将时间 段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状。.这种方式我们称之为并发(Concurrent)。
并行:当系统有一个以上 CPU 时,则线程的操作有可能非并发。当一个 CPU 执行一个线程时,另一个 CPU 可以执行另一个线程,两个线程互不抢占 CPU 资源,可以同时进行,这种方式我们称之为并行(Parallel)。
区别:并发和并行是即相似又有区别的两个概念,并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔内发生。在多道程序环境下,并发性是指在一段时间内宏观上有多个程序在同时运行,但在单处理机系统中,每一时刻却仅能有一道程序执行,故微观上这些程序只能是分时地交替执行。倘若在计算机系统中有多个处理机,则这些可以并发执行的程序便可被分配到多个处理机上,实现并行执行,即利用每个处理机来处理一个可并发执行的程序,这样,多个程序便可以同时执行。
2.JVM 主要包括哪四部分?
- 类加载器(ClassLoader):在 JVM 启动时或者在类运行时将需要的 class 加载到 JVM 中。
- 执行引擎:负责执行 class 文件中包含的字节码指令
- 内存区(也叫运行时数据区):是在 JVM 运行的时候操作所分配的内存区.
- 本地方法接口:主要是调用 C 或 C++实现的本地方法及返回结果。
3.创建对象的过程?
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程,在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。
假设 Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种内存分配方式称为“指针碰撞”(Bump the Pointer)。
如果 Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。
选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用 Serial ParNew 等带 Compact 过程的收集器时,系统采用的分配算法是指针碰撞,而使用 CMS 这种基于 Mark-Sweep 算法的收集器时,通常采用空闲列表。
除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案,一种是对分配内存空间的动作进行同步处理–实际上虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread LocalAllocationBuffer,TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定。虚拟机是否使用 TLAB,可以通过-XX:±UseTLAB 参数来设定。
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用 TLAB,这一工作过程也可以提前至 TLAB 分配时进行。这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例 如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
从 Java 程序的视角来看,对象创建才刚刚开始 init 方法还没有执行,所有的字段都还为零。所以,一般来说,执行 new 指令之后会接着执行 init 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
4.两种内存分配方式?
内存分配有 2 种方式:指针碰撞和空闲列表,在多线程情况下,2 种方式都不是线程安全的.
JVM 提供的解决方案是,CAS 加失败重试和 TLAB
TLAB 分配内存:为每一个线程在 Java 堆的 Eden 区分配一小块内存,哪个线程需要分配内存,就从哪个线程的 TLAB 上分配 ,只有 TLAB 的内存不够用,或者用完的情况下,再采用 CAS 机制
5.对象的内存布局?
在 HotSpot 虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
HotSpot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32bit 和 64bit,官方称它为“MarkWord”。对象需要存储的运行时数据很多,其实已经超出了 32 位、64 位 Bitmap 结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,MarkWord 被设计成一个非固定的数据结松以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如,在 32 位的 HotSpot 虚拟机中,如果对象处于未被锁定的状态下,那么 MarkWord 的 32bit 空间中的 25bit 用于存储对象哈希码,4bit 用于存储对象分代年龄,2bit 用于存储锁标志位,1bit 固定为 0.
对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说。查找对象的元数据信息并不一定要经过对象本身,另外,如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中却无法确定数组的大小。
实例数据是真正存储有效信息的部分.父类信息,子类信息都会记录下来.相同宽度的字段总是分配在一起.子类较窄的变量也可能插入到父类变量的缝隙之中.
对其填充不是必须的,主要是占位符的作用,对象的大小必须是 8 字节的整数倍,对象头是 8 字节的整数倍,实例数据需要被对齐填充.
6.对象的访问定位的方式?
Student student = new Student();
具体是如何操作 student 对象呢?有以下两种方式
- 使用句柄
- 直接指针
如果使用句柄访问的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息.
如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象地址
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针。而 reference 本身不需要修改。使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。
7.方法调用的 2 种形式?
一种形式是解析,另一种是分派.
所有方法调用的目标方法在 class 文件的常量池中都有一个对应的符号引用,在类加载阶段,会将符号引用转换为直接引用,前提条件是调用之前就知道调用的版本,且在运行期间是不可变的,这种方法的调用被称为解析.
调用不同类型的方法,使用的字节码指令不同,具体如下:
-
invokestatic。用于调用静态方法。
-
invokespecial。用于调用实例构造器 init()方法、私有方法和父类中的方法。
-
invokevirtual。用于调用所有的虚方法。
-
invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。
-
invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
只要能被 invokestatic 和 invokespecial 调用的方法都能在解析阶段确定调用的版本,符合这个条件的方法有静态方法,私有方法,实例构造器,父类方法.再加上 final 修饰的方法.尽管 final 修饰的方法是通过 invokevirtual 调用的.这 5 种方法会在类加载阶段就将符号引用转化为直接引用,这些方法统称为“非虚方法”.与之相反的,被称为虚方法.
分派分为静态分派和动态分派.
所有依赖静态类型来决定执行方法版本的,统称为静态分派,最典型的就是方法重载,静态分派发生在编译阶段,这一点也是有些资料将其归为解析而不是分派的原因
动态分派–重写.动态定位到实现类的方法进行调用.
8.说说对 invoke 包的理解?
与反射调用的区别?
jdk1.7 开始引入 java.lang.invoke 包,这个包的主要作用是在之前的单纯依符号引用来确定调用的目标方法外,提供一种新的动态确定目标方法的机制,称为“方法句柄(method handler)”.
Reflection 和 MethodHandler 机制本质上都是在模拟方法调用,Reflection 是 java 代码级别的模拟,MEthodHandler 是字节码级别的模拟.在 java.lang.invoke 包下的 MethodHandlers.LookUp(内部类)有 3 个重载的方法,findStatic,findVirtual,findSpecial3 个方法正是对应 invokeStatic,invokeVirtual,invokeSpecial 三个字节码指令.这 3 个方法是为了校验字节码权限的校验.这些底层的逻辑 Reflection 是不用去处理的.
Reflection 中的 java.lang.reflect.Method 对象远比 MethodHandler 机制中的 java.lang.invoke.MethodHandler 对象所包含的信息多,前者是 java 代码的全面映射,包含方法签名,描述符和方法属性表中各种属性,还包含执行权限等信息,而方法句柄仅包含该方法的相关信息,通俗来讲,反射是重量级的,方法句柄是轻量级的.
9.新创建对象占多少内存?
64 位的 jvm,new Object()新创建的对象在 java 中占用多少内存
MarkWord 8 字节,因为 java 默认使用了 calssPointer 压缩,classpointer 4 字节,对象实例 0 字节, padding 4 字节因此是 16 字节。
如果没开启 classpointer 默认压缩,markword 8 字节,classpointer 8 字节,对象实例 0 字节,padding 0 字节也是 16 字节。
-XX:+UseCompressedOops #相当于在64位机器上运行32位
-XX:-UseCompressedClassPointers -XX:-UseCompressedOops
public class Wang_09_jol_03 {static MyObject myobject = new MyObject();public static void main(String[] args) throws InterruptedException {System.out.println(ClassLayout.parseInstance(myobject).toPrintable());}static class MyObject {int a = 1;float b = 1.0F;boolean c = true;String d = "hello";}
}
对象头 8 字节,class 压缩关闭 8 字节,int 字段 a 占用 4 字节,flout 字段 b 占用 4 字节,boolean 字段 c 占用 1 字节,内部对齐填充 7 字节
String 类型指针占用 8 字节,一共 40 字节.
10.内存申请的种类?
java 一般内存申请有两种:静态内存和动态内存。
很容易理解,编译时就能够确定的内存就是静态内存,即内存是固定的,系统一次性分配,比如 int 类型变量;动态内存分配就是在程序执行时才知道要分配的存储空间大小,比如 java 对象的内存空间。根据上面我们知道,java 栈、程序计数器、本地方法栈都是线程私有的,线程生就生,线程灭就灭,栈中的栈帧随着方法的结束也会撤销,内存自然就跟着回收了。所以这几个区域的内存分配与回收是确定的,我们不需要管的。但是 java 堆和方法区则不一样,我们只有在程序运行期间才知道会创建哪些对象,所以这部分内存的分配和回收都是动态的。一般我们所说的垃圾回收也是针对的是堆和方法区。
11.什么是直接内存?
直接内存(DirectMemory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区场或。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemo oryError 异常出现,所以我们放到这里一起讲解。
在 JDK1.4 中新加入了 NIO(NewInput/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 1/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
直接内存大小不受堆内存限制,但受本机总内存限制.
12.java 异常分类?
运行时异常与非运行时异常的区别:
- 运行时异常是 RuntimeException 类及其子类的异常,是非受检异常,如 NullPointerException、IndexOutOfBoundsException 等。由于这类异常要么是系统异常,无法处理,如网络问题;
要么是程序逻辑错误,如空指针异常;JVM 必须停止运行以改正这种错误,所以运行时异常可以不进行处理(捕获或向上抛出,当然也可以处理),而由 JVM 自行处理。Java Runtime 会自动 catch 到程序 throw 的 RuntimeException,然后停止线程,打印异常。 - 非运行时异常是 RuntimeException 以外的异常,类型上都属于 Exception 类及其子类,是受检异常。非运行时异常必须进行处理(捕获或向上抛出),如果不处理,程序将出现编译错误。一般情况下,API 中写了 throws 的 Exception 都不是 RuntimeException。
常见运行时异常:
常见非运行时异常:
13.StringBuffer 为什么是可变类?
public final class StringBuffer extends AbstractStringBuilder implements java.io.Serializable, CharSequence {@Overridepublic synchronized StringBuffer append(String str) {toStringCache = null;super.append(str);return this;}
}
public AbstractStringBuilder append(String str) {if (str == null)return appendNull();int len = str.length();ensureCapacityInternal(count + len);str.getChars(0, len, value, count);count += len;return this;}
private void ensureCapacityInternal(int minimumCapacity) {// overflow-conscious codeif (minimumCapacity - value.length > 0)expandCapacity(minimumCapacity);}void expandCapacity(int minimumCapacity) {int newCapacity = value.length * 2 + 2;if (newCapacity - minimumCapacity < 0)newCapacity = minimumCapacity;if (newCapacity < 0) {if (minimumCapacity < 0) // overflowthrow new OutOfMemoryError();newCapacity = Integer.MAX_VALUE;}value = Arrays.copyOf(value, newCapacity);}
public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {if (srcBegin < 0) {throw new StringIndexOutOfBoundsException(srcBegin);}if (srcEnd > value.length) {throw new StringIndexOutOfBoundsException(srcEnd);}if (srcBegin > srcEnd) {throw new StringIndexOutOfBoundsException(srcEnd - srcBegin);}System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin);}
StringBuffer 的 append 的实现其实是 System 类中的 arraycopy 方法实现的,这里的浅复制就是指复制引用值,与其相对应的深复制就是复制对象的内容和值;
14.jvm 中几种常见的 JIT 优化?
- 公共子表达式消除——相同的不重复计算
- 数组边界检查消除——抛出异常
- 方法内联——频繁调用,换为方法本身
- 逃逸分析——内存分配
- 栈上分配——没有返回到方法外
- 标量替换—–没发生逃逸时,细化到最细粒度 一个对象里面有 a,b,c 只用到了 a,b
- 同步锁消除——append 方法
15.JVM 表示浮点数
-
浮点数的组成:符号位、指数位、尾数位。
-
符号位: 占 1 位,表示正负数;
-
指数位: 占 8 位;
-
尾数位: 占 23 位。
-
-
浮点数的表示:
- 取值: sflag _ m _ 2(e-127)
- sflag :符号位。 s = 0, sflag = 1; s = 1, sflag = -1
- m : 尾数位。实际占用 23 位,但是根据 e 的取值,有 24 位。当 e 全为 0 时,尾数位附加 0,否则附加 1
- e :指数位。
-
具体解析:(以 - 5 为例)
-
内部形式:1 10000001 01000000000000000000000
-
符号位:s = 1 得出 sflag = -1;
-
指数位: 二进制为 10000001,得出十进制 e = 129;
-
尾数位: 二进制为 01000000000000000000000, 得出: m = 20 + 2-2 = 1.25;
-
最终取值: sflag _ m _ 2(e-127) = -1 _ 1.25 _ 2(129 - 127) = -5
-
16.为什么匿名内部类只能访问 final 变量
是变量的作用域的问题,因为匿名内部类是出现在一个方法的内部的,如果它要访问这个方法的参数或者方法中定义的变量,则这些参数和变量必须被修饰为 final。因为虽然匿名内部类在方法的内部,但实际编译的时候,内部类编译成 Outer.Inner,这说明内部类所处的位置和外部类中的方法处在同一个等级上,外部类中的方法中的变量或参数只是方法的局部变量,这些变量或参数的作用域只在这个方法内部有效。因为编译的时候内部类和方法在同一级别上,所以方法中的变量或参数只有为 final,内部类才可以引用。
二.虚拟机执行子系统
1.说说运行时数据区?
- 方法区(Method Area)
- 堆区(Heap)
- 虚拟机栈(VM Stack)
- 本地方法栈(Native Method Stack)
- 程序计数器(Program Counter Register)
- java 堆(Heap):存储 java 实例或者对象的地方。这块是 GC 的主要区域(后面解释)。从存储的内容我们可以很容易知道,方法区和堆是被所有 java 线程共享的。
- 本地方法栈(Native Method Stack):和 java 栈的作用差不多,只不过是为 JVM 使用到的 native 方法服务的。
2.什么是程序计数器?
程序计数器(PC Register):程序计数器(Program CounterRegister)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
3.什么是 java 虚拟机栈?
与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(StackFrame)注用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
4.说说局部变量表?
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。
其中 64 位长度的 long 和 double 类型的数据会占用 2 个局部变量空间(Slot),其余的数据类型只占用 1 个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
5.说说方法区?
方法区主要存储的是什么数据?
方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
虽然 Java 虚拟机规范把方法区描述头堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。也称作永久代,GC 相关,垃圾回收效果不好.
方法区的内存回收主要是对常量池的回收和对类型的卸载.
说到方法区,不得不提一下“永久代”这个概念,尤其是在 JDK 8 以前,许多 Java 程序员都习惯在 HotSpot 虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(Permanent Generation),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的 HotSpot 虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得 HotSpot 的垃圾收集器能够像管理 Java 堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。
到了 JDK 8,终于完全废弃了永久代的概念,改用元空间(Meta-space)来代替,把 JDK 7 中永久代还剩余的内容(主要是类型信息)全部移到元空间中。元空间是位于直接内存中的.
《Java 虚拟机规范》对方法区的约束是非常宽松的,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。
6.说说运行时常量池?
jvm 在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm 就会将 class 常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在上面我也说了,class 常量池中存的是字面量和符号引用,也就是说他们存的并不是对象的实例,而是对象的符号引用值。而经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询全局字符串池 StringTable,以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。
运行时常量池(RuntimeConstant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(ConstantPoolTable),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
JDK1.6 中,运行时常量池,字符串常量池,类常量池都在方法区中;
JDK1.7 中,字符串常量池移动到了堆中;
JDK1.8 中,运行时常量池,类常量池还在方法区,方法区变为元空间;
public class GcDemo {public static void main(String [] args) {String str = new String("lonely")+new String("wolf");System.out.println(str == str.intern());}
}
这段代码在 jdk1.6 中打印 false,在 jdk1.7 和 jdk1.8 中打印 true。 关于 intern()方法:
- jdk1.6:调用 String.intern()方法,会先去检查常量池中是否存在该字符串,如果不存在,则会在方法区中创建一个字符串,而 new String()创建的字符串在堆中,两个字符串的地址当然不相等。
- jdk1.8:字符串常量池从方法区的运行时常量池移到了堆中,调用 String.intern()方法,首先会检查常量池是否存在,如果不存在,那么就会创建一个常量,并将引用指向堆,也就是说不会再重新创建一个字符串对象了,两者都会指向堆中的对象,所以返回 true。
执行 String.intern()如果在 1.7 和 1.8 中会检查字符串常量池,发现没有 lonelywolf 的字符串,所以会在字符串常量池创建一个,指向堆中的字符串。 但是在 jdk1.6 中不会指向堆,会重新创建一个 lonelywolf 的字符串放到字符串常量池,所以才会产生不同的结果
7.说说全局字符串池?
全局字符串池(string pool 也有叫做 string literal pool)
全局字符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到 string pool 中(记住:string pool 中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的。)。 在 HotSpot VM 里实现的 string pool 功能的是一个 StringTable 类,它是一个哈希表,里面存的是驻留字符串(也就是我们常说的用双引号括起来的)的引用(而不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个 StringTable 引用之后就等同被赋予了”驻留字符串”的身份。这个 StringTable 在每个 HotSpot VM 的实例只有一份,被所有的类共享。
8.说说 class 文件常量池?
我们都知道,class 文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。 字面量就是我们所说的常量概念,如文本字符串、被声明为 final 的常量值等。 符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用区分一下,直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)。一般包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符 常量池的每一项常量都是一个表,一共有如下表所示的 11 种各不相同的表结构数据,这每个表开始的第一位都是一个字节的标志位(取值 1-12),代表当前这个常量属于哪种常量类型。
常量池的项目类型 每种不同类型的常量类型具有不同的结构,具体的结构本文就先不叙述了,本文着重区分这三个常量池的概念(读者若想深入了解每种常量类型的数据结构可以查看《深入理解 java 虚拟机》第六章的内容)。
9.三个常量池之间的关系?
举例说明全过程
public class HelloWorld {public static void main(String []args) {String str1 = "abc";String str2 = new String("def");String str3 = "abc";String str4 = str2.intern();String str5 = "def";System.out.println(str1 == str3);//trueSystem.out.println(str2 == str4);//falseSystem.out.println(str4 == str5);//true}
上面程序的首先经过编译之后,在该类的 class 常量池中存放一些符号引用,然后类加载之后,将 class 常量池中存放的符号引用转存到运行时常量池中,然后经过验证,准备阶段之后,在堆中生成驻留字符串的实例对象(也就是上例中 str1 所指向的”abc”实例对象),然后将这个对象的引用存到全局 String Pool 中,也就是 StringTable 中,最后在解析阶段,要把运行时常量池中的符号引用替换成直接引用,那么就直接查询 StringTable,保证 StringTable 里的引用值与运行时常量池中的引用值一致,大概整个过程就是这样了。
回到上面的那个程序,现在就很容易解释整个程序的内存分配过程了, 首先,在堆中会有一个”abc”实例,全局 StringTable 中存放着”abc”的一个引用值 然后在运行第二句的时候会生成两个实例,一个是”def”的实例对象,并且 StringTable 中存储一个”def”的引用值,还有一个是 new 出来的一个”def”的实例对象,与上面那个是不同的实例。 当在解析 str3 的时候查找 StringTable,里面有”abc”的全局驻留字符串引用,所以 str3 的引用地址与之前的那个已存在的相同。 str4 是在运行的时候调用 intern()函数,返回 StringTable 中”def”的引用值,如果没有就将 str2 的引用值添加进去,在这里,StringTable 中已经有了”def”的引用值了,所以返回上面在 new str2 的时候添加到 StringTable 中的 “def”引用值、 最后 str5 在解析的时候就也是指向存在于 StringTable 中的”def”的引用值
10.运行时常量池和 class 文件常量池的关系?
运行时常量池(Runtime Constant Pool)是方法区的一部分。 Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性, Java 语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern()方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OOM 异常。
11.说说运行时栈帧结构?
java 虚拟机以方法作为最基本的执行单元,栈帧是用于虚拟机对方法执行调用的数据结构.它也是虚拟机运行时虚拟机栈的栈元素.栈帧中有局部变量表,操作数栈,动态链接,方法出口等信息.每一个方法从调用到执行完毕,都对应一个栈帧从入栈到出栈的过程.
在虚拟机编译源码阶段,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的 code 属性中,换句话说,一个栈帧需要多大内存,不会因为运行时变量数据多少而改变,取决于程序源码和虚拟机栈的内存布局.
对于执行引擎来说,只有位于栈顶的方法才是当前运行的,只有位于栈顶的栈帧才是生效的,被称为当前栈帧,与这个栈帧关联的方法被称为当前方法,执行引擎所执行的字节码指令都是针对于当前栈帧进行操作的.
12.说说你对栈帧中局部变量表的理解和变量的存储?
局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的变量,在编译为 class 文件时,就在方法的 code 属性,设置了 max_locals 数据项中确定局部变量表的大小.
局部变量表是以变量槽为单位的,java 虚拟机规范并没有指出变量槽的具体占用空间,只是每个变量槽都应该能存放一个 boolean,byte,char,short,int,float,reference 和 returnAddress 类型的数据.这 8 种数据类型都是以 32 位或者更小的内存来存储的.但这种描述和每个变量槽用 32 位来存储是有差别的,它允许变量槽随着环境的变化而变化.
对于 64 位的类型数据,java 虚拟机会以高位对其的方式分配 2 个连续的变量槽空间.java 语言中 64 位只有 long 和 double 类型,由于局部变量表是在线程栈中创建的,线程私有,不会出现线程安全问题.
public int calc(){int a=100;int b=200;int c = 300;return (a + b) * c;
}
public calc()IBIPUSH 100ISTORE 1SIPUSH 200ISTORE 2SIPUSH 300ISTORE 3ILOAD 1ILOAD 2IADDILOAD 3IMULIRETURNMAXSTACK = 2MAXLOCALS = 4
istore 指令, _1 代表存入变量槽 1,是将局部变量存入局部变量表。槽位 0 代表了 this,所以看不到 0,如果使用到了 this,则会 ALOAD 0
13.栈桢的局部变量表是如何定位变量的?是如何完成实参到形参传递的?
java 虚拟机通过索引定位的方式来使用局部变量表。索引值从 0 开始,至局部变量表变量槽最大的数量。如果是访问的 32 位数据类型的变量,索引 n 就代表了第 n 个变量槽,如果是 64 位,则访问的是第 n 和 n+1 两个变量槽。对于 64 位的 2 个变量槽,不允许任何方式访问其中一个变量槽。如果出现这种情况,在类加载校验阶段会抛出异常。
当一个方法被调用时,java 虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法,那局部变量表的第 0 位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过 this 关键字来访问到这个隐含的参数。其余参数则按照顺序在变量槽中排列,参数表分配完后,在根据方法体内部定义的变量和作用域来分配其余的变量槽。
14.说说栈桢中的操作数栈?
操作数栈也被成为操作栈,它是一个后入先出的栈。同局部变量表一样,操作数栈的最大栈深度在编译的时候已经被写入到 code 属性的 max——stacks 数据项之中。操作数栈的每一个元素都可以是包括 long 和 double 在内的任意 java 类型的数据,32 的为类型的所占栈容量为 1,64 位所占栈容量为 2.
当一个方法被执行时,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈写入和读取数据,也就是入栈和出栈的过程。比如算术运算的时候通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又比如调用其他方法的时候通过操作数栈来实现参数的传递。举个例子,两个整数相加,指令为 iadd,这条指令在运行的时候要求操作数栈顶的 2 个元素已经存入 2 个 int 类型的值,当执行这个指令的时候,会将这 2 个数取出,相加后的结果再压入到栈中。
15.一个线程中 2 个栈桢是否会有重叠的区域?
2 个不同栈桢作为不同方法的虚拟机栈的元素,是完全独立的。但是大多数的虚拟机实现里会进行一些优化处理。会存在共享存储空间的部分,比如下面栈桢的操作数栈与上面栈桢的局部变量表重叠在一起,这样做不仅节约了空间,更重要的是在进行方法调用时就可以直接共用一部分数据,不用再额外进行参数的复制传递。
16.栈帧中的动态链接和返回地址有什么用?
每一个栈帧都包含一个指向运行时常量中该栈帧所属方法的引用,持有这个引用是为了方法调用时的动态链接.class 文件的常量池中存在大量的符号引用,有一些符号引用在类加载阶段或者第一次使用时转换为直接引用,这个过程叫静态解析.还有一部分是每次执行的时候都会转化为直接引用,这一部分叫动态连接.
当一个方法执行后,退出的方式有 2 种,一种是正常退出,一种是异常退出.无论哪种退出方式,都必须要返回到调用者位置,代码才会继续执行.方法正常退出时,主调方法的程序计数器值可以作为返回地址.栈帧中很可能会保存这个值,当方法异常退出时,会通过异常处理器表来记录,栈帧中不会有记录.
方法退出的过程,相当于栈帧的出栈,退出时的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入到调用者的操作数栈中,调整 pc 计数器的值指向方法调用指令后一条指令.
17.java 堆的结构
- 新⽣代通常占 JVM 堆内存的 1/3,因为新⽣代存储都是新创建的对象,⽐较⼩的对象,⽽⽼年代存的都是⽐较⼤的,活的久的 对象,所以⽼年代占 JVM 堆内存较⼤;
- 新⽣代⾥的 Eden 区通常占年轻代的 4/5,两个 Survivor 分别占新⽣代的 1/10。因为 Survivor 中存储的是 GC 之后幸存的对象,实际上只有很少⼀部分会幸存,所以 Survivor 占的⽐例⽐较⼩。
三.垃圾回收
1.jvm 是如何判断对象是否存活的?
- 引用计数算法:无法解决相互引用问题
- 可达性分析算法
可达性分析算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。
可达性分析算法是需要一个理论上的前提的:该算法的全过程都需要基于一个能保障一致性的快照中才能够分析,这意味着必须全程冻结用户线程的运行。
并发标记会产生浮动垃圾和出现对象消失的问题,都可以解决.
2.GC Roots 是什么?
在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:
- 在虚拟机栈中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
- 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized 关键字)持有的对象。
- 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等
3.java 中对象的引用类型有什么?垃圾回收下的表现是什么样的?
在 JDK 1.2 版之后,Java 对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。
- 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 版之后提供了 SoftReference 类来实现软引用。
- 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1.2 版之后提供了 WeakReference 类来实现弱引用。
- 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 版之后提供了 PhantomReference 类来实现虚引用。
4.如果对象不可达,jvm 会立即回收这个对象吗?
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
- 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记
- 随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。
- 假如对象没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。会真正回收这个对象.
如果这个对象被判定为确有必要执行 finalize()方法,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize()方法。
5.方法区有垃圾回收吗?
《Java 虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,方法区垃圾收集的“性价比”通常也是比较低的:在 Java 堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收 70%至 99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。
6.什么是无用的类?
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
7.垃圾收集的算法有哪几种?说说利弊
有 3 种
标记-清除算法
算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。
它的主要缺点有两个:第一个是执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
标记-复制算法
常被简称为复制算法。为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,也称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费。
HotSpot 虚拟机的 Serial 、ParNew 等新生代收集器均采用了这种策略来设计新生代的内存布局。具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor 。发生垃圾搜集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间。 HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8 ∶1
标记-整理算法
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费 50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存.
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。
分代收集算法:分为新生代和老年代,新生代使用复制算法,老年代使用标记整理算法.
8.根节点枚举有了解过吗?
迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的。现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的 CMS 、 G1 、ZGC 等收集器,枚举根节点时也是必须要停顿的。
由于目前主流 Java 虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机是有办法直接得到哪些地方存放着对象引用的。在 HotSpot 的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等 GC Roots 开始查找。
9.什么是 jvm 的安全点?
在 OopMap 的协助下,HotSpot 可以快速准确地完成 GC Roots 枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说导致 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。
实际上 HotSpot 也的确没有为每条指令都生成 OopMap,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
10.如何让垃圾回收时,让所有线程都跑到最近的安全点?
有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension),抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。
而主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。
11.程序不执行的时候如何到达安全点?
安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于 Sleep 状态或者 Blocked 状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
12.垃圾回收是如何处理跨代引用问题的?
跨代引用举例:假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。
并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如 G1 、ZGC 收集器,都会面临相同的问题, JVM 为了用尽量少的资源消耗解决跨代引用下的垃圾回收问题,引入了记忆集。记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。目前最常用的一种记忆集实现形式种称为“卡表”,卡表中的每个记录精确到一块内存区域(每块内存区域称之为卡页),该区域内有对象含有跨代指针。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 1,称为这个元素变脏(Dirty),没有则标识为 0 。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中一并扫描。
13.说说垃圾回收的三色标记理论?
了当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。在根节点枚举这个步骤中,由于 GC Roots 相比起整个 Java 堆中全部的对象毕竟还算是极少数,且在各种优化技巧(如 OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。
“标记”阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器。
-
白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
-
黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
-
灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
14.说说什么是对象消失?垃圾收集器如何避免对象消失?
“对象消失”的问题,即原本应该是黑色的对象被误标为白色
当且仅当以下两个条件同时满足时,会产生“对象消失”的问题:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SATB)。
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了(因为新加入的白色节点未被扫描过)。
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在 HotSpot 虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如, CMS 是基于增量更新来做并发标记的, G1 则是用原始快照实现的.
增量更新用的是写后屏障(Post-Write Barrier),记录了所有新增的引用关系。
原始快照用的是写前屏障(Pre-Write Barrier),将所有即将被删除的引用关系的旧引用记录下来。
15.说说垃圾回收器的种类?
图中展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器.
16.说说 serial 收集器的特点和使用场景?
该收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。
迄今为止,它依然是 HotSpot 虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;对于单核处理器或处理器核心数较少的环境来说, Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的内存,桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以, Serial 收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
17.ParNew 收集器是如何进行垃圾回收的?
ParNew 收集器实质上是 Serial 收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World 、对象分配规则、回收策略等都与 Serial 收集器完全一致,在实现上这两种收集器也共用了相当多的代码。
ParNew 收集器除了支持多线程并行收集之外,其他与 Serial 收集器相比并没有太多创新之处,但它却是不少运行在服务端模式下的 HotSpot 虚拟机,尤其是 JDK 7 之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。
在 JDK 5 发布时,HotSpot 推出了一款在强交互应用中几乎可称为具有划时代意义的垃圾收集器——CMS 收集器。这款收集器是 HotSpot 虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。
遗憾的是, CMS 作为老年代的收集器,却无法与 JDK 1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作[1],所以在 JDK 5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。 ParNew 收集器是激活 CMS 后(使用-XX:+UseConcMarkSweepGC 选项)的默认新生代收集器,也可以使用-XX:+/-UseParNewGC 选项来强制指定或者禁用它。
ParNew 收集器在单核心处理器的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能百分之百保证超越 Serial 收集器。当然,随着可以被使用的处理器核心数量的增加,ParNew 对于垃圾收集时系统资源的高效利用还是很有好处的。它默认开启的收集线程数与处理器核心数量相同,在处理器核心非常多(譬如 32 个,现在 CPU 都是多核加超线程设计,服务器达到或超过 32 个逻辑核心的情况非常普遍)的环境中,可以使用-XX: ParallelGCThreads 参数来限制垃圾收集的线程数。
18.ParNew 收集器是如何进行垃圾回收的?
ParNew 收集器实质上是 Serial 收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World 、对象分配规则、回收策略等都与 Serial 收集器完全一致,在实现上这两种收集器也共用了相当多的代码。
ParNew 收集器除了支持多线程并行收集之外,其他与 Serial 收集器相比并没有太多创新之处,但它却是不少运行在服务端模式下的 HotSpot 虚拟机,尤其是 JDK 7 之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。
在 JDK 5 发布时,HotSpot 推出了一款在强交互应用中几乎可称为具有划时代意义的垃圾收集器——CMS 收集器。这款收集器是 HotSpot 虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。
遗憾的是, CMS 作为老年代的收集器,却无法与 JDK 1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作[1],所以在 JDK 5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。 ParNew 收集器是激活 CMS 后(使用-XX:+UseConcMarkSweepGC 选项)的默认新生代收集器,也可以使用-XX:+/-UseParNewGC 选项来强制指定或者禁用它。
ParNew 收集器在单核心处理器的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能百分之百保证超越 Serial 收集器。当然,随着可以被使用的处理器核心数量的增加,ParNew 对于垃圾收集时系统资源的高效利用还是很有好处的。它默认开启的收集线程数与处理器核心数量相同,在处理器核心非常多(譬如 32 个,现在 CPU 都是多核加超线程设计,服务器达到或超过 32 个逻辑核心的情况非常普遍)的环境中,可以使用-XX: ParallelGCThreads 参数来限制垃圾收集的线程数。
19.Parallel Scavenge 收集器是如何工作的?有什么特点?
Parallel Scavenge 收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器.Parallel Scavenge 的诸多特性从表面上看和 ParNew 非常相似,那它有什么特别之处呢?
Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同, CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:
吞吐量=(运行用户代码时间)/(运行用户代码时间+垃圾收集时间)
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX: MaxGCPauseMillis 参数以及直接设置吞吐量大小的-XX: GCTimeRatio 参数。
-XX: MaxGCPauseMillis 参数允许的值是一个大于 0 的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集 300MB 新生代肯定比收集 500MB 快,但这也直接导致垃圾收集发生得更频繁,原来 10 秒收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次、每次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。
-XX: GCTimeRatio 参数的值则应当是一个大于 0 小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如把此参数设置为 19,那允许的最大垃圾收集时间就占总时间的 5%(即 1/(1+19)),默认值为 99,即允许最大 1%(即 1/(1+99))的垃圾收集时间。
由于与吞吐量关系密切,Parallel Scavenge 收集器也经常被称作“吞吐量优先收集器”。除上述两个参数之外,Parallel Scavenge 收集器还有一个参数-XX:+UseAdaptiveSizePolicy 值得我们关注。这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、 Eden 与 Survivor 区的比例(-XX: SurvivorRatio)、晋升老年代对象大小(-XX: PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)
20.说说 Serial Old 收集器的特点以及使用场景?
Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的 HotSpot 虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在 JDK 5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用,另外一种就是作为 CMS 收集器发生失败时的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
需要说明一下,Parallel Scavenge 收集器架构中本身有 PS MarkSweep 收集器来进行老年代收集,并非直接调用 Serial Old 收集器,但是这个PS MarkSweep 收集器与Serial Old的实现几乎是一样的,所以在官方的许多资料中都是直接以 Serial Old 代替 PS MarkSweep 进行讲解
21.说说 Parallel old 收集器的特点以及使用场景?
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。这个收集器是直到 JDK 6 时才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直处于相当尴尬的状态,原因是如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器以外别无选择,其他表现良好的老年代收集器,如 CMS 无法与它配合工作。由于老年代 Serial Old 收集器在服务端应用性能上的“拖累”,使用 Parallel Scavenge 收集器也未必能在整体上获得吞吐量最大化的效果。同样,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不一定比 ParNew 加 CMS 的组合来得优秀。
直到 Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器这个组合。
22.说说 CMS 收集器的特点?
CMS (Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网网站或者基于浏览器的 B/S 系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。 CMS 收集器就非常符合这类应用的需求。
从名字(包含“Mark Sweep”)上就可以看出 CMS 收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快;并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。标记清除算法.
23.CMS 收集器的缺点?
CMS 是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,一些官方公开文档里面也称之为“并发低停顿收集器”(Concurrent Low Pause Collector)。 CMS 收集器是 HotSpot 虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度,至少有以下三个明显的缺点:
首先, CMS 收集器对处理器资源非常敏感。事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。 CMS 默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过 25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时, CMS 对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。为了缓解这种情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的 CMS 收集器变种,所做的事情和以前单核处理器年代 PC 机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样,是在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些,直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显。实践证明增量式的 CMS 收集器效果很一般,从 JDK 7 开始,i-CMS 模式已经被声明为“deprecated”,即已过时不再提倡用户使用,到 JDK 9 发布后 i-CMS 模式被完全废弃。
然后,由于 CMS 收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Concurrent Mode Failure”失败进而导致另一次完全“Stop The World”的 Full GC 的产生。在 CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此 CMS 收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在 JDK 5 的默认设置下, CMS 收集器当老年代使用了 68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX: CMSInitiatingOccu-pancyFraction 的值来提高 CMS 的触发百分比,降低内存回收频率,获取更好的性能。到了 JDK 6 时, CMS 收集器的启动阈值就已经默认提升至 92%。但这又会更容易面临另一种风险:要是 CMS 运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。所以参数-XX:CMSInitiatingOccupancyFraction 设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。
还有最后一个缺点,在本节的开头曾提到, CMS 是一款基于“标记-清除”算法实现的收集器,如果对前面对垃圾回收器算法讲解还有印象的话,就可能想到这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次 Full GC 的情况。为了解决这个问题, CMS 收集器提供了一个-XX:+UseCMS-CompactAtFullCollection 开关参数(默认是开启的,此参数从 JDK 9 开始废弃),用于在 CMS 收集器不得不进行 Full GC 时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在 Shenandoah 和 ZGC 出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数-XX: CMSFullGCsBeforeCompaction(此参数从 JDK 9 开始废弃),这个参数的作用是要求 CMS 收集器在执行过若干次(数量由参数值决定)不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为 0,表示每次进入 Full GC 时都进行碎片整理)。只能预防,不能根治。
24.CMS GC 失败后的预案
如果 CMS 运行期间预留的内存无法满足程序需要,那么就会出现一次“Concurrent Mode Failure”失败,此时 JVM 将启动后备预案:
使用 Serial Old 收集器重新对老年代进行 gc,这样一来,停顿时间就会很长。
25.CMS GC 的两种模式
background 模式:
触发条件:old 的内存占比超过多少的时候就可能触发一次 background 式的 cms gc
其中 background 顾名思义是在后台做的,也就是可以不影响正常的业务线程跑,这个过程会经历 CMS GC 的所有阶段,该暂停的暂停,该并行的并行,效率相对来说还比较高,毕竟有和业务线程并行的 gc 阶段;
foreground 模式:
触发条件:比如业务线程请求分配内存,但是内存不够了,于是可能触发一次 cms gc
这个过程就必须是要等内存分配到了线程才能继续往下面走的,因此整个过程必须是 STW 的,因此 CMS GC 整个过程都是暂停应用的,但是为了提高效率,它并不是每个阶段都会走的,只走其中一些阶段,这些省下来的阶段主要是并行阶段,Precleaning、AbortablePreclean,Resizing 这几个阶段都不会经历,其中 sweep 阶段是同步的,但不管怎么说如果走了类似 foreground 的 cms gc,那么整个过程业务线程都是不可用的,效率会影响挺大。
26.说说你对 G1 收集器的了解?
Garbage First(简称 G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。
G1 是一款主要面向服务端应用的垃圾收集器。 HotSpot 开发团队最初赋予它的期望是(在比较长期的)未来可以替换掉 JDK 5 中发布的 CMS 收集器。现在这个期望目标已经实现过半了, JDK 9 发布之日, G1 宣告取代 Parallel Scavenge 加 Parallel Old 组合,成为服务端模式下的默认垃圾收集器,而 CMS 则沦落至被声明为不推荐使用( Deprecate)的收集器。如果对 JDK 9 及以上版本的 HotSpot 虚拟机使用参数-XX:+UseConcMarkSweepGC 来开启 CMS 收集器的话,用户会收到一个警告信息,提示 CMS 未来将会被废弃。
在 G1 收集器出现之前的所有其他收集器,包括 CMS 在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个 Java 堆(FullGC)。而 G1 跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称 CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。
27.G1 收集器的 Region 堆内存布局是什么原理?
虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异: G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。 G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。每个 Region 的大小可以通过参数-XX: G1HeapRegionSize 设定,取值范围为 1MB ~ 32MB,且应为 2 的 N 次幂。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中, G1 的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待。
虽然 G1 仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。
28.G1 收集器为什么可以建立可预测停顿时间模型?
G1 收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。
更具体的处理思路是让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX: MaxGCPauseMillis 指定,默认值是 200 毫秒),优先处理回收价值收益最大的那些 Region,这也就是“Garbage First”名字的由来。这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率。
29.多个 Region 里面存在跨 Region 引用问题,如何解决?
使用记忆集避免全堆作为 GC Roots 扫描,但在 G1 收集器上记忆集的应用其实要复杂很多,它的每个 Region 都维护有自己的记忆集,这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。 G1 的记忆集在存储结构的本质上是一种哈希表, Key 是别的 Region 的起始地址, Value 是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于 Region 数量比传统收集器的分代数量明显要多得多,因此 G1 收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验, G1 至少要耗费大约相当于 Java 堆容量 10%至 20%的额外内存来维持收集器工作。
28.G1 收集器在并发标记阶段,如何保证用户线程和垃圾回收线程互不影响?
CMS 收集器采用增量更新算法实现,而 G1 收集器则是通过原始快照(SATB)算法来实现的。此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建, G1 为每一个 Region 设计了两个名为 TAMS (Top at Mark Start)的指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。 G1 收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与 CMS 中的“Concurrent Mode Failure”失败会导致 Full GC 类似,如果内存回收的速度赶不上内存分配的速度, G1 收集器也要被迫冻结用户线程执行,导致 Full GC 而产生长时间“Stop The World”。
30.G1 收集器垃圾回收的步骤?
- 初始标记(Initial Marking):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
- 并发标记(Concurrent Marking):从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。
- 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
- 筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
从上述阶段的描述可以看出, G1 收集器除了并发标记外,其余阶段也是要完全暂停用户线程的。
31.jvm 堆内存结构?
java 堆分区新生代和老年代,新生代和老年代的比例为 新生代:老年代=1:2
新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor 。发生垃圾搜集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间。 HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8 ∶1。
32.新创建的对象可以直接分配到老年代吗?
大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。
大对象就是指需要大量连续内存空间的 Java 对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,我们写程序的时候应注意避免。
在 Java 虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。 HotSpot 虚拟机提供了-XX: PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在 Eden 区及两个 Survivor 区之间来回复制,产生大量的内存复制操作。
33.老年代存放的都是什么对象?
HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。
对象通常在 Eden 区里诞生,如果经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,该对象会被移动到 Survivor 空间中,并且将其对象年龄设为 1 岁。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX: MaxTenuringThreshold 设置。
34.jvm 中对象的年龄必须达到阈值才能晋升老年代吗?
为了能更好地适应不同程序的内存状况, HotSpot 虚拟机并不是永远要求对象的年龄必须达到-XX: MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX: MaxTenuringThreshold 中要求的年龄。
一共有四种情况:
- Survivor 空间不足以装下对象
- 大对象直接进入老年代
- 长期存活的对象-15(对象头 4 字节)
- 动态对象年龄判定(相同年龄的对象占用超过存储空间的一半)
35.什么是空间分配担保?
在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看-XX: HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;不够的话继续进行一次 Full GC. 如果小于,或者-XX: HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次 Full GC。
36.JVM 运行时 GC 相关的参数
-Xms4096m //初始堆大小-Xmx4096m //最大堆大小-Xmn1536m //新生代大小 eden + from + to-Xss512K //线程大小-XX:NewRatio=2 //新生代和老年代的比例-XX:MaxPermSize=64m //持久代最大值-XX:PermSize=16m //持久代初始值-XX:SurvivorRatio=8 // eden 区和survivor区的比例-verbose:gc-Xloggc:gc.log //输出gc日志文件-XX:+UseGCLogFileRotation //使用log文件循环输出-XX:NumberOfGCLogFiles=1 //循环输出文件数量-XX:GCLogFileSize=8k //日志文件大小限制-XX:+PrintGCDateStamps //gc日志打印时间-XX:+PrintTenuringDistribution //查看每次minor GC后新的存活周期的阈值-XX:+PrintGCDetails //输出gc明细-XX:+PrintGCApplicationStoppedTime //输出gc造成应用停顿的时间-XX:+PrintReferenceGC //输出堆内对象引用收集时间-XX:+PrintHeapAtGC //输出gc前后堆占用情况-XX:+UseParallelGC //年轻代并行GC,标记-清除-XX:+UseParallelOldGC //老年代并行GC,标记-清除-XX:ParallelGCThreads=23 //并行GC线程数, cpu<=8?cpu:5*cpu/8+3-XX:+UseAdaptiveSizePolicy //默认,自动调整年轻代各区大小及晋升年龄-XX:MaxGCPauseMillis=15 //每次GC最大停顿时间,单位为毫秒-XX:+UseParNewGC //Serial多线程版-XX:+UseConcMarkSweepGC //CMS old gc-XX:+UseCMSCompactAtFullCollection //FullGC后进行内存碎片整理压缩-XX:CMSFullGCsBeforeCompaction=n //n次FullGC后执行内存整理-XX:+CMSParallelRemarkEnabled //启用并行重新标记,只适用ParNewGC-XX:CMSInitiatingOccupancyFraction=80 //cms作为垃圾回收是,回收比例80%-XX:ParallelGCThreads=23 //并行GC线程数,cpu<=8?cpu:5*cpu/8+3-XX:-UseSerialGC //默认不启用,client使用时启用-XX:+UseG1GC //启用G1收集器-XX:-UseAdaptiveSizePolicy //默认,不自动调整各区大小及晋升年龄-XX:PretenureSizeThreshold=2097152 //直接晋升到老年代的对象大小-XX:MaxTenuringThreshold=15(default) //晋升到老年代的对象年龄,PSGen无效-XX:-DisableExplicitGC //禁止在运行期显式地调用?System.gc()-XX:+HeapDumpOnOutOfMemoryError //在OOM时输出堆内存快照-XX:HeapDumpPath=./java_pid<pid>.hprof //堆内存快照的存储路径-XX:+CMSScavengeBeforeRemark //执行CMS重新标记之前,尝试执行一此MinorGC-XX:+CMSPermGenSweepingEnabled //开启永久代的并发垃圾收集
37.触发 GC 的类型?
Java 虚拟机会把每次触发 GC 的信息打印出来,可以根据日志来分析触发 GC 的原因。
- GC_FOR_MALLOC:表示是在堆上分配对象时内存不足触发的 GC。
- GC_CONCURRENT:当我们应用程序的堆内存达到一定量,或者可以理解为快要满的时候,系统会自动触发 GC 操作来释放内存。
- GC_EXPLICIT:表示是应用程序调用 System.gc、VMRuntime.gc 接口或者收到 SIGUSR1 信号时触发的 GC。
- GC_BEFORE_OOM:表示是在准备抛 OOM 异常之前进行的最后努力而触发的 GC。
38.查看当前使用的垃圾收集器?
java -XX:+PrintCommandLineFlags -version
39.垃圾收集器比较?
40.Minor GC 与 Full GC
-
新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
-
老年代 GC(Major GC / Full GC):指发生在老年代的垃圾收集动作,出现了 Major GC,经常会伴随至少一次 Minor GC(非绝对),MajorGC 的速度一般会比 Minor GC 慢 10 倍以上。
-
Minor GC 与 Full GC 触发条件:
-
- 当 Eden 区没有足够的空间进行分配时
- 老年代最大可用连续空间大于Minor GC 历次晋升到老年代对象的平均大小
-
Full GC 触发条件:
-
- 调用 System.gc()时(系统建议执行 Full GC,但是不必然执行)
- 老年代空间不足时
- 方法区空间不足时
- 老年代最大可用连续空间小于Minor GC 历次晋升到老年代对象的平均大小
- CMS GC 在垃圾回收的时候,当对象从 Eden 区进入 Survivor 区,Survivor 区空间不足需要放入老年代,而老年代空间也不足时
41.为什么 GC 分代年龄为 15?
因为对象头中的 Mark Word 采用 4 个 bit 位来保存年龄,4 个 bit 位能表示的最大数就是 15
对象头包含两类信息:
一种是 Mark Word:用于存储对象自身的运行时数据,如 HashCode,GC 的分代年龄,锁状态标志,线程持有的锁,偏向线程 ID,偏向时间戳等。这部数据的长度在 32 位和 64 位的虚拟机中分别为 32 比特和 64 比特。(Mark Word 的 32 个比特存储空间中的 25 个比特用于存储对象的 HashCode,4 个比特存储对象分代年龄,2 个比特存储锁标志位,一个比特固定为 0)
另一种是 Klass Pointer 类型指针,即对象指向它的类型元数据的指针,Java 通过这个指针确定该对象是哪个类的实例。但是并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息不一定要经过对象本身。
MetaSpace 主要由 Klass Metaspace 和 NoKlass Metaspace 两大部分组成。
- Klass MetaSpace: 就是用来存 Klass 的,就是 Class 文件在 JVM 里的运行时数据结构,这部分默认放在 Compressed Class Pointer Space 中,是一块连续的内存区域,紧接着 Heap。Compressed Class Pointer Space 不是必须有的,如果设置了
-XX:-UseCompressedClassPointers
,或者-Xmx
设置大于 32 G,就不会有这块内存,这种情况下 Klass 都会存在 NoKlass Metaspace 里。 - NoKlass MetaSpace: 专门来存 Klass 相关的其他的内容,比如 Method,ConstantPool 等,可以由多块不连续的内存组成。虽然叫做 NoKlass Metaspace,但是也其实可以存 Klass 的内容.
42.说说 ZGC?
- 在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停颇时间限制在十毫秒以内的低延迟。
- ZGC 收集器是一款基于 Region 内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器。
- ZGC 的工作过程可以分为 4 个阶段:并发标记 - 并发预备重分配 - 并发重分配 - 并发重映射等。
- ZGC 几乎在所有地方并发执行的,除了初始标记的是 STW 的。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。
四.Class 文件结构
1.谈谈你对 class 文件的了解
ClassFile {u4 magic;//魔数u2 minor_version;//次版本号u2 major_version;//主版本号u2 constant_pool_count;//常量池数量cp_info constant_pool[constant_pool_count-1];//常量池信息u2 access_flags;//访问标志u2 this_class;//类索引u2 super_class;//父类索引u2 interfaces_count;//接口数(2位,所以一个类最多65535个接口)u2 interfaces[interfaces_count];//接口索引u2 fields_count;//字段数field_info fields[fields_count];//字段表集合u2 methods_count;//方法数method_info methods[methods_count];//方法集合u2 attributes_count;//属性数attribute_info attributes[attributes_count];//属性表集合
}
class 文件是以一组 8 个字节为基础单位的二进制流,各个数据项严格按照顺序紧凑排列在文件中,中间没有任何分隔符,这使得 class 文件存储的都是程序运行的必要数据,没有空隙存在.
class 文件格式采用一种类似 c 语言结构体的伪结构体老存储数据,这种伪结构体只包含 2 种数据类型:无符号数和表
无符号数属于基本的数据类型,以 u1,u2,u4,u8 分别来表示 1 个字节,2 个字节,4 个字节,8 个字节的无符号数.无符号数可以用来描述数字,索引引用,数量值或者按照 utf-8 编码构成的字符串值.
表是由多个无符号数或者其他表作为数据项构成的复合型数据结构,为了便于区分,表通常以_info 结尾.用于描述复杂的结构,整个 class 文件可以看成一个大表.如上图所示.
2.class 文件中的魔数和主次版本号?
每个 class 文件的头 4 个字节被称为魔数,它的唯一作用是确定这个文件是否能为一个虚拟机所接受的 class 文件.
紧接着是 class 文件的版本号,第五和第六字节是次版本号,第七第八是主版本号,java 的版本号是从 45 开始的,jdk1.1 开始每个 jdk 大版本发布主版本号向上加 1
3.为什么常量池计数器从 1 开始,而不是从 0 开始?
由于常量池中常量的数量是不固定的,所以常量池中的入口需要放置一项 u2 类型的数据,代表常量池容量计数器,constant_pool_count,这个计数器是从 1 开始的不是从 0 开始的,如下图所示,十六进制数 0x0016,十进制就是 22,代表着常量池中有 21 项常量,索引范围为 1~21.第 0 项表示不引用任何一个常量池项目.class 文件只有常量池的容量是从 1 开始的,对于其他的集合类型,包括接口索引集合,字段表集合,方法表集合等的容量计数器都是从 0 开始的.
举例:匿名内部类本身没有类名称,进行名称引用时,会将 index 指向 0,Object 类的 class 文件父类索引指向 0
4.class 文件常量池中存放的是什么内容?
常量池中主要存放两大类常量:字面量和符号引用
字面量比较接近于 java 语言层面的常量概念,如文本字符串,被声明为 final 的常量值等
符号引用则属于偏编译方面的概念,主要包含以下几类常量:
- 被模块导出或者开放的包 package
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
- 方法句柄和方法类型
- 动态调用点和动态常量
5.常量池的项目类型?
表开始的第一位都是 u1 类型的标志位(tag),表示这个常量属于哪个类型.这 14 种常量都有自己的结构.
CONSTANT_Class_info 代表类或接口的符号引用.数据结构如下.
tag 标志位用于区分常量类型,name_index 是一个索引值,指向常量池中一个 CONSTANT_Utf8_info 类型的常量.此常量代表了这个类或接口的全限定名.
CONSTANT_Utf8_info 的数据结构如下
tag 是标志位区分类型
length 代表数据长度,最大为 65535
bytes 代表实际数据
常量 | 项目 | 类型 | 描述 |
---|---|---|---|
CONSTANT_Utf8_info | tag | u1 | 值为 1 |
length | u2 | UTF-8 编码的字符串占用的字节数 | |
bytes | u1 | 长度为 length 的 UTF-8 编码的字符串 | |
CONSTANT_Integer_info | tag | u1 | 值为 3 |
bytes | u4 | 按照高位在前存储的 int 值 | |
CONSTANT_Float_info | tag | u1 | 值为 4 |
bytes | u4 | 按照高位在前存储的 float 值 | |
CONSTANT_Long_info | tag | u5 | 值为 5 |
bytes | u8 | 按照高位在前存储的 long 值 | |
CONSTANT_Double_info | tag | u1 | 值为 6 |
bytes | u8 | 按照高位在前存储的 double 值 | |
CONSTANT_Class_info | tag | u1 | 值为 7 |
index | u2 | 指向全限定名常量项的索引 | |
CONSTANT_String_info | tag | u1 | 值为 8 |
index | u2 | 指向字符串字面量的索引 | |
CONSTANT_Fieldref_info | tag | u1 | 值为 9 |
index | u2 | 指向声明字段的类或者接口描述符 CONSTANT_Class_info 的索引项 | |
index | u2 | 指向字段描述符 CONSTANT_NameAndType 的索引项 | |
CONSTANT_Methodref_info | tag | u1 | 值为 10 |
index | u2 | 指向声明方法的类描述符 CONSTANT_ Class_info 的索引项 | |
index | u2 | 指向名称及类型描述符 CONSTANT_NameAndType 的索引项 | |
CONSTANT_Interface_Methodref _info | tag | u1 | 值为 11 |
index | u2 | 指向声明方法的接口描述符 CONSTANT_Class_info 的索引项 | |
index | u2 | 指向名称及类型描述符 CONSTANT_NameAndType 的索引项 | |
CONSTANT_NameAndType_info | tag | u1 | 值为 12 |
index | u2 | 指向该字段或方法名称常量项的索引 | |
index | u2 | 指向该字段或方法描述符常量项的索引 | |
CONSTANT_MethodHandle_info | tag | u1 | 值为 15 |
reference_kind | u1 | 值必须在 1~9 之间(包括 1 和 9)它决定了方法句柄的类型,方法句柄类型的值表示方法句柄的字节码行为 | |
reference_index | u2 | 值必须是对常量池的有效索引 | |
CONSTANT_MethodType_info | tag | u1 | 值为 16 |
descriptor_index | u2 | 值必须是对常量池的有效索引,常量池在该索引处的项必须是 CONSTANT Utf8info 结构,表示方法的描述符 | |
CONSTANT_Invoke_Dynamic_info | tag | u1 | 值为 18 |
bootstrap_method_attr_index | u2 | 值必须是对当前 Class 文件中引导方法表的 bootstrap_methods 数组的有效索引 | |
name_and_type_index | u2 | 值必须是对当前常量池的有效索引,常量池在该索引处的项必须是 CONSTANT_NameAndType_info 结构,表示方法名和方法描述符 |
5.java 字段名和方法名长度限制是多少,为什么?
字段名和方法名都存储在常量池中
存储这 2 个名称需要用到常量池中的 constant_utf8_info 类型来存储,以下是 constant_utf8_info 的存储结构,
constant_utf8_info 的最大长度也是 java 中方法名字段名的长度,这里最大长度就是 length 的最大值,即 u2 类型能表达的最大值为 65535,所以 java 程序中定义了超过 64kb 英文字符的变量或者方法名,即使规则和名字符号全部合法,也无法编译.
1kb=1024 字节
64kb=65536 字节是临街值,不能等于 64kb
6.class 文件的访问标志作用?
常量池结束后,是 2 个字节的访问标志,用于标示类和接口层次的访问信息.包括这个 class 是类还是接口,是否定义为 public 类型,是否定义为 abstract 类型,如果是类的话,是否声明为 final 等等.
7.类索引,父类索引,和接口索引集合的作用,并举例详细说说类索引的过程?
类索引(this_class)和父类索引(super_class)都是一个 u2 类型的数据.而接口索引集合(interfaces)是一组 u2 类型的数据集合,class 文件中由这三项数据来确定该类型的继承实现关系.
类索引用于确定这个类的全限定名(全限定名称存储于常量池),父类索引用于确定这个类的父类的全限定名.这里说的索引,是指向常量池中的 constant_class_info 类型,constant_class_info 又指向了 constant_utf8_info 从而确定全限定名.
由于 java 语言不支持多继承,所以父类索引只有一个,除了 java.lang.object 之外,所有的 java 类都有父类,且父类索引都不为 0,接口索引集合就是用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 关键字后的接口顺序从左到右排列在接口索引集合中.
类索引,父类索引,接口索引集合都按顺序排列在访问标志之后,类索引和父类索引,用 2 个 u2 类型的值表示,他们各自指向一个 constant_class_info 的类描述符常量,通过 constant_class_info 类型的常量中的索引值可以找到定义在 constant_utf8_info 类型的常量中的全限定名字符串.
8.class 文件的字段表存储的什么信息,与常量池有什么联系?
字段表(field_info)用于描述接口或者类中声明的变量,java 语言中的字段包括类级变量以及实例级变量,但不包含在方法内部声明的局部变量.
字段表存储的是变量的修饰符+字段的描述符索引(索引指向常量池)+字段名称索引(索引指向常量池).
修饰符:字段可以包括的修饰符有字段的作用域(public,private,protected 修饰符),是实例变量还是类变量(static 修饰符),可变性(final),并发可见性(volatile 修饰符,是否强制从主内存读写),可否被序列化(transient 修饰符)等.
描述符:字段类型.
public final static String number=“1”,public final 和 static 是访问修饰符(access_flags),这些都存在 class 文件的字段表中,String 是字段描述符,存放于常量池中(name_index),number 是字段的名称(descrip_index),存放于常量池,这两部分的关联,是通过字段表的 name_index 指向常量池中的字段名称 number 和 descrip_index 指向常量池中的描述符.
9.class 文件的方法表存储的是什么信息?与常量池有什么联系?
class 文件存储格式中对方法的描述,采用的方式和字段表一致,方法表的结构和字段表一致,包含访问标示(access_flags),名称索引(name_index),描述符索引(descriptor_index),属性表集合(attributes)
10.说说 class 文件中的属性表的理解,以及接触过的属性?
属性表在 class 文件,字段表,方法表都有自己的属性表集合,以描述某些场景专有的信息.与 class 文件中其他数据项要求严格的顺序,长度和内容不同,属性表限制稍微宽松一些,不再要求严格的顺序.只要属性名不重复,允许写入自己定义的属性信息.
对于每一个属性,它的名称都是从常量池中引用一个 constant_utf8_info 类型的常量来表示,而属性值的结构是完全自定义的,只需要通过一个 u4 的长度属性去说明属性值所占用的字节的位数即可.
Code 属性:java 代码经过 javac 编译之后,最终变成字节码指令存储在 code 属性中.code 属性出现在方法表的属性集合中.但并非所有的方法表都必须存在这个属性,譬如接口和抽象类的方法就不存在 code 属性,如果方法表有 code 属性存在,那么它的结构如下:
LineNumberTable 属性:LineNumberTable 属性用于描述 java 源码的行号和字节码行号之间的偏移量的对应关系.它不是运行时必需的属性,但默认会生成到 class 文件之中,可以在 javac 中使用-g:none 或者-g:lines 选项来取消或者要求生成这项信息,如果选择不生成 LineNumberTable 属性,对程序的影响是,抛出异常时,堆栈中将不显示出错的行号,并且在调试程序的时候,也无法按照源码行来设置断点.
ConstantValue 属性:主要作用是为静态变量赋值.只有被 static 修饰的变量才能使用这个属性. int x=123 和 static int x=123 这样的定义在 java 中很常见,但虚拟机对这 2 种方式的赋值的方式和时刻有所不同.对于非 static 修饰的变量,在实例构造器 init 方法中进行赋值.对于类变量,有 2 种方式,通过类构造器的 clinti 方法赋值或者使用 ConstantValue 属性.如果同时使用 final 和 static 关键字修饰同一个变量,并且变量类型是基本数据类型或者 string,就会使用 ConstantValue 属性来进行初始化,如果没有用 final 修饰,或者非基本类型或者字符串,会在构造器 clinit 方法中初始化.
举例说明:public static final String NUMBER=“111111”;
static 和 final 同时修饰的变量,会在编译的过程中把 NUBER 的值进行初始化,放到 ConstanValue 属性中,否则需要到最后一步初始化时在 clinit 方法中进行初始化.
五.类加载
1.jvm 类加载的整体流程?
- 通过一个类的全限定名来获取此类的二进制字节流(加载阶段)
- Class 文件的格式验证(连接–>验证–>文件格式验证)
- 将这个字节流所代表的的静态存储(class 文件本身)结构转化为方法区的运行时数据结构(加载阶段)
- 在内存(堆内存)中生成这个类的 java.lang.class 对象,作为方法区这个类的各种数据的访问入口(加载阶段)
- 元数据验证(连接–>验证–>元数据验证)
- 字节码验证(连接–>验证–>字节码验证)
- 准备阶段,赋初始值(连接–>准备)
- 解析/符号引用验证(连接–>解析–>连接–>验证–>符号引用验证)
- 初始化(初始化)
2.加载阶段 JVM 具体进行了什么操作?
加载阶段主要完成了 3 件事情
- 通过一个类的全限定名生成类的二进制字节流
- 将这个二进制字节流的静态存储结构转化为在方法区中虚拟机支持的运行时数据结构(将虚拟机外部的字节流转化为虚拟机所设定的格式存储在方法区中)
- 在内存中生成一个代表这个类的 java.lang.class 对象,作为方法区这个类的各种数据的访问入口
相对于类加载的过程,非数组类型的加载阶段(准确的说,是获取类的二进制字节流的动作)是开发人员可控性最强的阶段,可以使用虚拟机内置的类加载器来完成,也可以由用户自定义的类加载器来完成,开发人员通过自定义的类加载器去控制字节流的获取方式(重写一个类的类加载器的 findClass 方法或者 loadClass 方法),根据需求获取运行代码的动态性.
3.JVM 加载数组和加载类有什么区别和联系?
对于数组而言,情况有所不同,数组类本身不通过类加载器创建,它是由 java 虚拟机直接在内存中动态构建出来的,但是数组跟类加载器还是密切关联的,因为数组类的元素类型最终还是需要通过类加载器加载完成.
如果数组的元素类型是引用类型,那么遵循 JVM 的加载过程,去加载这个组件类型.数组类将被标识在加载该组件类型的类加器的类命名空间上.(这一点很重要,一个类必须与类加载器一起确定唯一性)
如果数组类的组件类型不是引用类型(比如 int[]),java 虚拟机会把数组类标记为与启动类加载器关联.
数组类的可访问性和它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类型的可访问性默认是 public,可被所有的接口和类访问到.
4.验证阶段 JVM 主要做了什么?
验证是连接的第一步.这一阶段的主要的目的是确保 class 的文件的二进制字节流中包含的信息符合 java 虚拟机规范的全部约束要求,保证这些信息被当做代码运行后,不会对虚拟机自身造成危害.
验证阶段是非常重要的,从代码量和耗费的执行性能的角度上来讲,验证阶段的工作量在虚拟机整个类加载过程中占比相当大.
验证主要分为四个验证: 文件格式验证,元数据验证,字节码验证,符号引用验证
文件格式验证
- 是否以魔数开头
- 主次版本号是否在当前虚拟机支持的范围内
- 常量池中是否含有不被支持的常量类型(检查常量 tag 标志)
- 指向常量的各种索引值中是否含有指向不存在的常量或者不符合类型的常量
- constant_utf8_info 型的常量是否存在不符合 utf8 编码的数据
- class 文件中各个部分以及文件本身是否有被删除的或者附加的其他信息
这个阶段是基于二进制字节流进行的,只有通过这个验证,二进制字节流才会到达方法区进行存储,后面的三个验证也是基于方法区的存储结构,不会直接读取字节流了
元数据验证
- 这个类是否包含父类(除了 java.lang.object 外,所有类都要有父类)
- 这个类的父类是否继承了不允许被继承的类(final 修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或者接口中要求实现的所有方法
- 类中的字段,方法是否与父类中产生矛盾,(例如覆盖了父类的 final 字段,或者出现了不符合规范的方法重载)
字节码验证
这一阶段是验证阶段最为复杂的阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的,符合逻辑的.这阶段主要是校验 class 文件的 code 属性.
- 保证任意时刻操作数栈的数据类型和指令代码序列都能配合工作,例如不会出现类似于“在操作数栈放置了一个 int 类型的数据,使用的时候按 long 类型来加载入本地变量表中”这样的情况
- 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
- 保证方法体之内的类型转换都是有效的,比如,可以把一个子类赋值给父类数据类型,这是安全的,但是把父类赋值给子类数据类型,甚至是毫无关系的数据类型,这是危险的,不合法的.
由于数据流和控制流的高度复杂性,为了避免过多的时间消耗,在 jdk1.6 之后的 javac 编译和 java 虚拟机里进行了一项联合优化,把尽可能多的校验移到 javac 编译器里进行.具体做法是给方法体 code 属性的属性表中添加一项 StackMapTable 的新属性,这个属性描述了方法体所有的基本块,开始时本地变量表和操作数栈应有的状态,在字节码验证期间,java 虚拟机就不需要根据程序推导了,只需要检查 StackMapTable 的记录是否合法即可.这样字节码验证的类型推导就变成了类型检查,从而节省了大量的校验时间.
符号引用验证:
最后一个验证阶段发生在虚拟机将符号引用转化为直接引用的时候,这个过程在连接的第三个阶段解析阶段发生.
符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗讲就是验证该类是否缺少或者被禁止访问它依赖的某些外部类,方法,字段等资源.
- 符号引用中通过字符串的全限定名能否找到对应的类
- 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的字段和方法
- 符号引用中的类,字段,方法的可访问性是否能被当前类访问
符号引用的目的是确保解析行为能够正常执行,如果无法通过符号引用验证,java 虚拟机将会抛出一个 java.lang.IncompatibleClassChangeError 的子类异常,典型的如:java.lang.IllegalAccessError,java.lang.NoSuchFieldError,java.lang.NoSuchMethodError 等
5.类加载器连接的准备阶段做了什么?
准备阶段是正式为类中定义的变量(静态变量,被 static 修饰的变量),分配内存并设置初始值的过程
这些变量使用的空间应当在方法区中进行分配,在 jdk1.7 及之前,hotspot 使用永久代来实现方法区,在 jdk1.8 之后,类变量会随着 class 对象一起存放在堆中.
准备阶段进行内存分配的只有类变量不包含实例变量,实例变量需要在对象实例化后在堆中分配,通常情况下,初始值一般为零值.这些内存都将在方法区内分配内存,不包含实例变量,实例变量在堆内存中,而且实例变量是在对象初始化时才赋值
public static int value=123;
准备阶段初始值是 0 不是 123,因为在此时还没执行任务 java 方法,而把 value 赋值为 123 是在 putstatic 指令是程序被编译后,存放在类构造器的 clinit 方法中,赋值为 123 在类的初始化阶段.上面说的通常情况是初始值为零值,特殊情况下被 final,static 修饰时,直接赋予ConstantValue属性值.比如 final static String
6.类加载器连接的解析阶段做了什么?
解析阶段是将常量池内的符号引用转化为直接引用的过程
符号引用: 用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时可以定位到目标即可
直接引用: 直接引用可以直接指向目标的指针,相对偏移量或者一个能间接定位到目标的句柄.如果有了直接引用,那么引用的目标在虚拟机的内存中一定存在.
并未明确指出解析的具体时间,可以是在加载时就解析常量池中的符号引用,或者是等到第一个符号引用将要被使用前解析它,这个 java 虚拟机规范中没有明确说明.对同一个符号引用进行多次解析是存在的,虚拟机可以对第一次解析的结果进行缓存,譬如运行时直接引用常量池中的状态,并把常量标示为已解析状态,从而避免了重复动作.
解析动作主要针对类或接口,字段,类方法,接口方法,方法类型,方法句柄,调用点限定符等 7 类符号引用进行,分别对应了常量池中的 constant_class_info,constant_firldref_info,constant_methodref_info,constant_interfacemethodref_info,constant_methodtype_info,constant_methodhandle_info,constant_dynamic_info 和 constant_invokedyanmic_info 一共 8 种常量类型.
7.类加载器初始化阶段做了什么?
类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由 Java 虚拟机来主导控制。直到初始化阶段,Java 虚拟机才真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。
进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器 clinit()方法的过程。clinit()并不是程序员在 Java 代码中直接编写的方法,它是 Javac 编译器的自动生成物。
8.说说你对 clinit 方法的理解?
clinit 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的.编译器收集的顺序是由源文件中出现的顺序决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在之后的变量发,在前面的静态语句块可以赋值,但是不能访问.
public class Jvm_0999_static {static {i = 0;//给变量赋值可以正常编译通过System.out.print(i);//这句编译器会提示“非法向前引用”}static int i = 1;
}
clinit 方法与类的构造函数不同,它不需要显式的调用父类构造器,java 虚拟机会保证在子类的 clinit 方法执行之前,父类的 clinit 方法已经执行完毕,因此在 java 虚拟机中第一个被执行的 clinit 方法的类型肯定是 java.lang.object 类型
public class Jvm_09999_Parent {//依次为A=0,A=1,A=2public static int A = 1;static {A = 2;}static class Sub extends Jvm_09999_Parent {public static int B = A;}public static void main(String[] args) {System.out.println(Sub.B);}
}
clinit 方法对于类或者接口不是必须的,如果一个类没有静态语句块,也没有对变量的赋值行为,那么编译器就不会生成这个类的 clinit 方法.
接口中不能使用静态语句块,但是可以有变量赋值操作,因此接口和类一样也会生成 clinit 方法,但是接口和类不同的是,接口的 clinit 方法不需要先执行父类的 clinit 方法,因为只有当父接口中被定义的接口被使用时,父接口才会初始化.接口的实现类在初始化时一样不会执行 clinit 方法.
java 虚拟机必须保证一个类的 clinit 方法在多环境中被正确的同步加锁.如果是多线程去初始化一个类,那么只会有一个线程去执行这个类的 clinit 方法,其他线程需要阻塞等待,直到活动线程执行完 clinit 方法.其他线程被唤醒后不会继续执行 clinit 方法.
9.什么情况下 JVM 会立即对类进行初始化?
对于初始化阶段,java 虚拟机规范严格规定有且只有 6 种情况必须对类进行“初始化”
- 遇到 new,getstatic,putstatic,invokestatic 这四条指令时,如果类型没有进行初始化,则需要触发其初始化阶段,这四条指令的 java 场景
- 使用 new 关键字实例化对象的时候
- 读取或设置一个类型的静态字段(被 final 修饰,已在编译器把结果放入常量池的静态字段除外)的时候
- 调用一个类型的静态方法的时候
- 使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有初始化,则需要先触发其初始化
- 当初始化类时,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化
- 当虚拟机宕机时,用户需要指定一个主类,(包含 main 方法的那个类),虚拟机会先初始化这个类
- 当使用 jdk1.7 新加入的动态语言支持时,如果一个 java.lang.invoke.methodhandle 实例最后的解析结果为 ref_getstatic,ref_putstatic,ref_invokestatic.ref_newinvokespecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发其初始化.
- 当一个接口中定义了默认方法,如果这个类的实现类发生了初始化,这个接口要在其之前被初始化.
public class SuperClass {static {System.out.println("SuperClass init");}public static int value = 123;
}public class SubClass extends SuperClass {static {System.out.println("SubClass init");}
}public class NotInitialization {public static void main(String[] args){System.out.println(SubClass.value);}
}
上述代码只会执行“SuperClass init”,对于静态字段,只有直接定义这个字段的类才会被初始化,因此只会触发父类的初始化,不会触发子类的初始化.
public class NotInitialization {public static void main(String[] args){SuperClass[] sc = new SuperClass[10];}
}
通过数组引用的类,不会触发此类的初始化.
public class ConstClass {static {System.out.println("ConstClass init");}public static final String HELLO_WORD = "hello world";
}public class NotInitialization {public static void main(String[] args){System.out.println(ConstClass.HELLO_WORD);}
}
final static 常量不会触发类的初始化编译器就放入到属性表的 ConstantValue 中
10.不同的类加载器对 instanceof 关键字运算的结果有影响吗?
public class ClassLoaderTest {public static void main(String[] args) throws Exception {ClassLoader myLoader = new ClassLoader(){@Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException {try {String fileName = name.substring(name.lastIndexOf(".")+1)+".class";InputStream resourceAsStream = getClass().getResourceAsStream(fileName);if (resourceAsStream == null){return super.loadClass(name);}byte[] bytes = new byte[resourceAsStream.available()];resourceAsStream.read(bytes);return defineClass(name, bytes,0, bytes.length);} catch (IOException e){throw new ClassNotFoundException();}}};Object o = myLoader.loadClass("com.xiaofei.antbasic.demo4.ClassLoaderTest").newInstance();System.out.println(o.getClass());System.out.println(o instanceof com.xiaofei.antbasic.demo4.ClassLoaderTest);}
}
class com.xiaofei.antbasic.demo4.ClassLoaderTest
false
虚拟机中存在了 2 个 ClassLoaderTest 类,一个是虚拟机的类加载器加载的,另一个是我们自定义的类加载器加载的,即使这 2 个类源自同一个 class 文件,被同一个 java 虚拟机加载,但是类加载器不同,那么 2 个类必不相等.
11.说下双亲委派模型?
启动类加载器:负责加载存在 java_home/lib 下的,或者是被-xbootclasspath 参数所指定的路径中存放,而且能被虚拟机所识别的(按照文件名称识别,如 rt.jar,tools.jar)类库加载到虚拟机内存中.启动类加载器无法被用户直接引用,如果需要委派加载请求给启动类加载器,直接使用 null 代替即可.
扩展类加载器:它负责加载 java_home\lib\ext 目录中的,或者被 java.ext.dirs 所指定路径中的类库.
应用程序类加载器:这个类加载器是 ClassLoader 类中 getSystem-ClassLoader 方法的返回值.所以也称为系统类加载器.它负责加载类路径 classpath 上所有的类库,如果没有自定义类加载器,应用程序类加载器就是默认的类加载器.
双亲委派模型要求,除了顶层的类加载器除外,其他的类加载器都要有父类加载器,这里的父子不是继承关系,而是组合关系来复用父加载器的代码.
双亲委派工作流程:
当一个类加载器收到类加载的请求,首先不会自己去加载此类,而是请求父类去加载这个类,层层如此,如果父类加载器不能加载此类(在搜索范围内没有找到需要的类),子类才会去尝试加载.
使用双亲委派的好处是,java 中的类随着类加载器具备了一种优先级的层次关系,例如 java.lang.object,它存放在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都委派给顶层的启动类加载器去加载,因此 object 在各个类加载器环境中都能保证是同一个类.
ClassLoader 源码
protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException
{synchronized (getClassLoadingLock(name)){// First, check if the class has already been loadedClass<?> c = findLoadedClass(name);if (c == null){long t0 = System.nanoTime();try {if (parent != null){c = parent.loadClass(name, false);} else {c = findBootstrapClassOrNull(name);}} catch (ClassNotFoundException e){// ClassNotFoundException thrown if class not found// from the non-null parent class loader}if (c == null){// If still not found, then invoke findClass in order// to find the class.long t1 = System.nanoTime();c = findClass(name);// this is the defining class loader; record the statssun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);sun.misc.PerfCounter.getFindClasses().increment();}}if (resolve){resolveClass(c);}return c;}
}
12.双亲委派的破坏?
自定义类加载器,然后重写 loadClass()方法
13.为什么 Tomcat 打破双亲委派?
tomcat 的类加载机制是违反了双亲委托原则的,对于一些未加载的非基础类(Object,String 等),各个 web 应用自己的类加载器(WebAppClassLoader)会优先加载,加载不到时再交给 commonClassLoader 走双亲委托。
一个 web 容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
打破双亲委派的真正目的是,当 classpath、catalina.properties 中 common.loader 对应的路径、web 应用/WEB-INF,下有同样的 jar 包时,优先使用/WEB-INF 下的 jar 包.
tomcat 不遵循双亲委派机制,只是自定义的 classLoader 顺序不同,但顶层还是相同的,还是要去顶层请求 classloader.
六.JVM 调优
1.jvm 查看进程工具命令 jps 有了解吗?
可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class, main()函数所在的类)名称以及这些进程的本地虚拟机唯一 ID (LVMID, Local Virtual Machine Identifier)。虽然功能比较单一,但它绝对是使用频率最高的 JDK 命令行工具,因为其他的 JDK 工具大多需要输入它查询到的 LVMID 来确定要监控的是哪一个虚拟机进程。对于本地虚拟机进程来说, LVMID 与操作系统的进程 ID (PID, Process Identifier)是一致的,使用 Windows 的任务管理器或者 UNIX 的 ps 命令也可以查询到虚拟机进程的 LVMID,但如果同时启动了多个虚拟机进程,无法根据进程名称定位时,那就必须依赖 jps 命令显示主类的功能才能区分了。
- -q:只输出进程 id
- -m:输出虚拟机启动时传递给 main()方法的参数
- -l:输出主类的全名,如果执行的是 jar 包,输出 jar 包的路径
- -v:输出启动虚拟机的参数
2.使用过 jstat 监控工具吗?
jstat (JVM Statistics Monitoring Tool)是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据,在没有 GUI 图形界面、只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的常用工具。
#参数interval和count代表查询间隔和次数,如果省略这2个参数,说明只查询一次。假设需要每1000毫秒查询一次进程42339垃圾收集状况,一共查询10次,那命令应当是:
jstat -gc 42339 1000 10
上图显示了各个区以及垃圾回收的情况,具体代表含义如下(C 代表 Capacity,U 代表 Used 已使用大小)
- S0C 和 S1C 代表 Survivor 区的 S0 和 S1 的大小
- S0U 和 S1U 表示已使用空间
- EC 表示 Eden 区大小,EU 表示 Eden 区已使用容量
- OC 表示老年代大小,OU 代表老年代已使用容量
- MC 表示方法区大小,MU 表示方法区已使用容量
- CCSC 表示压缩类空间大小,CCSU 表示压缩类空间已使用大小
- YGC 表示新生代 GC 次数,YGT 表示新生代 GC 总耗时
- FGC 表示 Full GC 次数,FGCT 表示 FULL GC 总耗时
- GCT 表示 GC 总耗时时间
选项 option 代表用户希望查询的虚拟机信息,主要分为三类:类加载、垃圾收集、运行期编译状况。
3.jstack 工具是做什么的?
jstack (Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为 threaddump 或者 javacore 文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的目的通常是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂起等,都是导致线程长时间停顿的常见原因。线程出现停顿时通过 jstack 来查看各个线程的调用堆栈,就可以获知没有响应的线程到底在后台做些什么事情,或者等待着什么资源。
option 选项的合法值与具体含义下图所示。
从 JDK 5 起, java.lang.Thread 类新增了一个 getAllStackTraces()方法用于获取虚拟机中所有线程的 StackTraceElement 对象。使用这个方法可以通过简单的几行代码完成 jstack 的大部分功能,在实际项目中不妨调用这个方法做个管理员页面,可以随时使用浏览器来查看线程堆栈
4.jinfo 工具是做什么的?
jinfo (Configuration Info for Java)的作用是实时查看和调整虚拟机各项参数。注意,如果是修改,参数类型是 manageable 类型才能修改。
jinfo -flag name PID #查看某个java进程的name属性的值
jinfo -flags PID #查看已经复制的JVM参数
5.jmap 工具是做什么用的?
jmap (Memory Map for Java)命令用于生成堆转储快照(一般称为 heapdump 或 dump 文件)。
jmap 的作用并不仅仅是为了获取堆转储快照,它还可以查询 finalize 执行队列、Java 堆和方法区的详细信息,如空间使用率、当前用的是哪种收集器等。
jmap -heap PID #打印出堆内存相关信息
jmap -dump:format=b,file=/usr/heap.hprof PID #生成dump文件
上面常用参数可以设置,一旦发生 OOM 之后就会自动生成 dump 文件
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap.hprof
jmap 可以添加的参数如下:
6.jhat 工具有使用过吗?
JDK 提供 jhat (JVM Heap Analysis Tool)命令与 jmap 搭配使用,来分析 jmap 生成的堆转储快照。
jhat heap.hprof
然后访问地址http://localhoust:7000/, 可以看到这款工具展示的信息比较简单。
7.jvm 调优实用工具?
-
jconsole 工具
-
VisualVM 工具
-
监控应用程序的 CPU、GC、堆。方法区和线程信息(jstack 和 jstat 的功能)
-
dump 文件以及分析(jmap 和 jhat 的功能)
-
方法级的程序性能分析,可以找出被调用最多,运行时间最长的方法
-
离线程序快照:收集程序运行时配置、线程 dump。内存 dump 等信息建立一个快照,并可以将快照发送给开发者进行 bug 反馈。
-
插件化处理,有无限扩展可能
-
8.打印 gc 日志的命令?
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -Xloggc:/Users/lizhengqiang/Documents/gc.log
找到 gc.log 文件,刚开始没有发生 GC,所以文件是空的,等到发生 GC 后打开
9.内存泄漏和内存溢出?
内存泄漏:是指创建的对象已经没有用处,正常情况下应该会被垃圾收集器回收,但是由于该对象仍然被其他对象进行了无效引用,导致不能够被垃圾收集器及时清理,这种现象称之为内存泄漏。内存泄漏会导致内存堆积,最终发生内存溢出,导致 OOM。
内存溢出:java 堆用于存储对象实例,只要不断的创建实例,并保证 GC roots 到对象是可达的,避免被回收,对象数量达到堆的最大容量时就会出现内存溢出异常.
出现 Java 堆内存溢出时,异常堆栈信息“java.lang.OutOfMemory Error”会跟着进一步提示“Java heap space”。
//设置jvm参数 VM Args:-Xms20m-Xmx20m -XX:+HeapDumpOnOutOfMemoryError
public class Jvm_01_HeapOOM {static class OOMObject {}public static void main(String[] args) {List<OOMObject> list = new ArrayList<OOMObject>();while (true) {list.add(new OOMObject());}}
}
内存溢出分三种情况
OutOfMemoryError: PermGen space:
Permanent Generation space 这个区域主要用来保存加来的 Class 的一些信息,在程序运行期间属于永久占用的,Java 的 GC 不会对他进行释放,所以如果启动的程序加载的信息比较大,超出了这个空间的大小,就会发生溢出错误;
解决的办法:增加空间分配——增加 java 虚拟机中的 XX:PermSize 和 XX:MaxPermSize 参数的大小,其中 XX:PermSize 是初始永久保存区域大小,XX:MaxPermSize 是最大永久保存区域大小。
OutOfMemoryError:Java heap space
heap 是 Java 内存中的堆区,主要用来存放对象,当对象太多超出了空间大小,GC 又来不及释放的时候,就会发生溢出错误。Java 中对象的创建是可控的,但是对象的回收是由 GC 自动的,一般来说,当已存在对象没有引用(即不可达)的时候,GC 就会定时的来回收对象,释放空间。但是因为程序的设计问题,导致对象可达但是又没有用(即前文提到的内存泄露),当这种情况越来越多的时候,问题就来了。
针对这个问题,我们需要做一下两点:
1、检查程序,减少大量重复创建对象的死循环,减少内存泄露。
2、增加 Java 虚拟机中 Xms(初始堆大小)和 Xmx(最大堆大小)参数的大小。
StackOverFlowError
stack 是 Java 内存中的栈空间,主要用来存放方法中的变量,参数等临时性的数据的,发生溢出一般是因为分配空间太小,或是执行的方法递归层数太多创建了占用了太多栈帧导致溢出。针对这个问题,除了修改配置参数-Xss 参数增加线程栈大小之外,优化程序是尤其重要。
10.什么是对象逃逸?对象逃逸优化有哪几种?
逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
#在Java代码运行时,通过JVM参数可指定是否开启逃逸分析,
-XX:+DoEscapeAnalysis : 表示开启逃逸分析
-XX:-DoEscapeAnalysis : 表示关闭逃逸分析。
优化有三种:栈上分配;标量替换;锁消除(或称同步消除)。
栈上分配(Stack Allocations):在 Java 虚拟机中, Java 堆上分配创建对象的内存空间几乎是 Java 程序员都知道的常识, Java 堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据。虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸。
标量替换(Scalar Replacement):若一个数据已经无法再分解成更小的数据来表示了, Java 虚拟机中的原始数据类型(int 、 long 等数值类型及 reference 类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。相对的,如果一个数据可以继续分解,那它就被称为聚合量(Aggregate), Java 中的对象就是典型的聚合量。如果把一个 Java 对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
同步消除(Synchronization Elimination):线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉。
public String concatString(String s1, String s2, String s3) {StringBuffer sb = new StringBuffer();sb.append(s1);sb.append(s2);sb.append(s3);return sb.toString();
}
每个 StringBuffer.append()方法中都有一个同步块,锁就是 sb 对象在 concatString()方法内部。也就是 sb 的所有引用都永远不会逃逸到 concatString()方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地消除掉 。在解释执行时这里仍然会加锁,但在经过服务端编译器的即时编译之后,这段代码就会忽略。虚拟机观察变量 sb,经过逃逸分析后会发现它的动态作用域被限制所有的同步措施而直接执行。
11.什么是锁粗化?
12.有过 jvm 调优经验吗?
JVM 调优情况十分复杂,各种情况都可能导致垃圾回收不能够达到预想的效果。对于场景问题,可以从如下几个大方向进行设计:
-
在大访问压力下, MinorGC 频繁, MinorGC 是针对新生代进行回收的,每次在 MGC 存活下来的对象,会移动到 Survivor1 区。先到这里为止,大访问压力下, MGC 频繁一些是正常的,只要 MGC 延迟不导致停顿时间过长或者引发 FGC,那可以适当的增大 Eden 空间大小,降低频繁程度,同时要保证,空间增大对垃圾回收时间产生的停顿时间增长也是可以接受的。
继续说,如果 MinorGC 频繁,且容易引发 Full GC 。需要从如下几个角度进行分析。a:每次 MGC 存活的对象的大小,是否能够全部移动到 S1 区,如果 S1 区大小< MGC 存活的对象大小,这批对象会直接进入老年代。注意了,这批对象的年龄才 1 岁,很有可能再多等 1 次 MGC 就能被回收了,可是却进入了老年代,只能等到 Full GC 进行回收,很可怕。这种情况下,应该在系统压测的情况下,实时监控 MGC 存活的对象大小,并合理调整 eden 和 s 区的大小以及比例。还有一种情况会导致对象在未达到 15 岁之前,直接进入老年代,就是 S1 区的对象,相同年龄的对象所占总空间大小>s1 区空间大小的一半,所以为了应对这种情况,对于 S 区的大小的调整就要考虑:尽量保证峰值状态下, S1 区的对象所占空间能够在 MGC 的过程中,相同对象年龄所占空间不大于 S1 区空间的一半,因此对于 S1 空间大小的调整,也是十分重要的。
-
由于大对象创建频繁,导致 Full GC 频繁。对于大对象, JVM 专门有参数进行控制,-XX: PretenureSizeThreshold 。超过这个参数值的对象,会直接进入老年代,只能等到 full GC 进行回收,所以在系统压测过程中,要重点监测大对象的产生。如果能够优化对象大小,则进行代码层面的优化,优化如:根据业务需求看是否可以将该大对象设置为单例模式下的对象,或者该大对象是否可以进行拆分使用,或者如果大对象确定使用完成后,将该对象赋值为 null,方便垃圾回收。如果代码层面无法优化,则需要考虑: a:调高-XX: PretenureSizeThreshold 参数的大小,使对象有机会在 eden 区创建,有机会经历 MGC 以被回收。但是这个参数的调整要结合 MGC 过程中 Eden 区的大小是否能够承载,包括 S1 区的大小承载问题。 b:这是最不希望发生的情况,如果必须要进入老年代,也要尽量保证,该对象确实是长时间使用的对象,放入老年代的总对象创建量不会造成老年代的内存空间迅速长满发生 Full GC,在这种情况下,可以通过定时脚本,在业务系统不繁忙情况下,主动触发 full gc。
-
MGC 与 FGC 停顿时间长导致影响用户体验。其实对于停顿时间长的问题无非就两种情况: a: gc 真实回收过程时间长,即 realtime 时间长。这种时间长大部分是因为内存过大导致,导致从标记到清理的过程中需要对很大的空间进行操作,导致停顿时间长。 b: gc 真实回收时间 real time 并不长,但是 user time(用户态执行时间)和 systime (核心态执行时间)时间长,导致从客户角度来看,停顿时间过长。对于 a 情况,要考虑减少堆内存大小,包括新生代和老年代,比如之前使用 16G 的堆内存,可以考虑将 16G 内存拆分为 4 个 4G 的内存区域,可以单台机器部署 JVM 逻辑集群,也可以为了降低 GC 回收时间进行 4 节点的分布式部署,这里的分布式部署是为了降低 GC 垃圾回收时间。对于 b 情况,要考虑线程是否及时达到了安全点,通过-XX:+PrintSafepointStatistics 和-XX: PrintSafepointStatisticsCount=1 去查看安全点日志,如果有长时间未达到安全点的线程,再通过参数-XX:+SafepointTimeout 和-XX: SafepointTimeoutDelay=2000 两个参数来找到大于 2000ms 到达安全点的线程,这里的 2000ms 可以根据情况自己设置,然后对代码进行针对的调整。除了安全点问题,也有可能是操作系统本身负载比较高,导致处理速度过慢,线程达到安全点时间长,因此需要同时检测操作系统自身的运行情况。
-
内存泄漏导致的 MGC 和 FGC 频繁,最终引发 oom 。
-
纯代码级别导致的 MGC 和 FGC 频繁。如果是这种情况,那就只能对代码进行大范围的调整,这种情况就非常多了,而且会很糟糕。如大循环体中的 new 对象,未使用合理容器进行对象托管导致对象创建频繁,不合理的数据结构使用等等。
总之, JVM 的调优无非就一个目的,在系统可接受的情况下达到一个合理的 MGC 和 FGC 的频率以及可接受的回收时间。
13.JVM 两种常见异常?
StackOverFlowError
: 如果 Java 虚拟机栈容量不能动态扩展,而此时线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
OutOfMemoryError
: 如果 Java 虚拟机栈容量可以动态扩展,当栈扩展的时候,无法申请到足够的内存(Java 虚拟机堆中没有空闲内存,垃圾回收器也没办法提供更多内存)
14.栈溢出异常?
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;
如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展,只不过 Java 虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemorvError 异常。
如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。
如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常。
单线程 用-Xss 减少栈内存容量,来模拟 StackOverflowError 异常.
多线程,-Xss 设置大一些,模拟 OutOfMemoryError 异常.
15.方法区运行时常量池异常?
String.intern()是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。在 JDK1.6 及之前的版本中,由于常量池分配在永久代内,我们可以通过-Xx:PermSize 和-
XX:MaxPermSize 限制方法区大小,从而间接限制其中常量池的容量.
public class Jvm_04_RuntimeConstantPooloOM {public static void main(String[] args) {//使用List保持着常量池引用,避免FullGC回收常量池行为List<String> list = new ArrayList<String>();// 10MB的PermSize在integer范围内足够产生00M了int i = 0;while (true) {list.add(String.valueOf(i++).intern());}}
}
OutOfMemoryError 后面跟随的提示信息是“PermGen space”
16.本机直接内存异常?
DirectMemory 容量可通过-XX:MaxDirectMemorySize 指定,如果不指定,则默认与 Java 堆最大值(-Xmx 指定)一样,直接通过反射获取 Unsafe 实例进行内存分配(Unsafe 类的 getUnsafe))方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有 rtjar 中的类才能使用 Unsafe 的功能)。因为,虽然使用 DirectByteBuffer 分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是 unsafeallocateMemorv)。
public class Jvm_05_DirectMemory0OM {private static final int _1MB = 1024 * 1024;public static void main(String[] args) throws Exception {Field unsafeField = Unsafe.class.getDeclaredFields()[0];unsafeField.setAccessible(true);Unsafe unsafe = (Unsafe) unsafeField.get(null);while (true) {unsafe.allocateMemory(_1MB);}}
}
17.JVM 参数
JVM 参数
所谓 JVM 调优就是设置一个合理的 JVM 参数,适合当前系统运行。JVM 参数分为三类:标准参数,-X 参数和-XX 参数。
标准参数
以"-"开头的参数称为标准参数,是任何一个 JDK 版本都支持的,比较稳定,不会随 jdk 版本更新和改变。例如-version,-help,-server。
-X 参数
以-X 开头的参数是在特定版本 HotSpot 支持的命令,jdk 版本变化之后,参数可能变化,这个参数用的较少。
-XX 参数
-XX 是不稳定的参数,也是主要参数,分为 Boolean 类型和非 Boolean 类型。
Boolean 型
Boolean 型的-XX 参数使用格式为:
-XX:[+-]<name>:+或-表示启用或者禁用name属性
例如:
-XX:+UseConcMarkSweepGC #表示启用CMS垃圾收集器
-XX:+UseG1GC #表示启用G1垃圾收集器
-XX:+PrintFlagsFinal #表示打印出所有的JVM参数信息
非 Boolean 型
非 Boolean 型的-XX 参数的使用格式为:
-XX<name>=<value> #name表示属性,value表示属性对应的值
例如:
-XX:MaxMetaspaceSize=5M #设置最大永久代空间大小为5M
其他参数
还有一些非常有用的参数,比如-Xms,-Xmx,-Xss,实际上这几种参数也是属于-XX 参数,只是简写了。
-Xms1000等价于-XX:InitialHeapSize=1000
-Xmx1000等价于-XX:MaxHeapSize=1000
-Xss1000等价于-XX:ThreadStackSize=1000
常用 JVM 参数
设置 | 说明 |
---|---|
-XX:ClCompilerCount=3 | 最大并行编译数,大于 1 时可以提高编译速度,但会影响系统稳定性 |
-XX:InitialHeapSize=100m | 初始堆大小,可以简写为-Xms100 |
-XX:MaxHeapSize | 最大堆大小,可以简写为-Xmx100 |
-XX:NewSize=20m | 设置年轻代大小 |
-XX:MaxNewSize | 设置年轻代最大值 |
-XX:OldSize=50m | 设置老年代大小 |
-XX:MetaspaceSize=50m | 设置方法区大小,jdk1.8 才有,用元空间代替方法区 |
-XX:+UseParallelGC | 设置 Parallel Scanvage 作为新生代收集器,系统默认会选择 Parallel Old 作为老年代收集器 |
-XX:NewRatio | 新生代和老年代的比值,比如 -XX:NewRatio=4 表示新生代:老年代=1:4 |
-XX:SurvivorRatio | 表示两个 S 区和 Eden 区的比值,比如-XX:SurvivorRatio=8 表示(S0+S1):Eden=2:8 |
18.CPU 过高问题排查?
I.top 命令
使用 top 查询到 cup 过高的进程 PID
II.查应用
ps -ef | grep java
查看所有的 java 进程,在结果中找到进程号为 12836 的进程,即可查看是哪个应用占用的该进程
III.查线程
top -H -p 进程PID
查看十六进制的异常线程 PID
printf "%x\n" 线程PId
这是十进制的数据,将 3034 转成十六进制为 0Xbda
根据线程号查出对应的 java 线程,进行处理
jstack -l 3033 > ./3033.stack
然后执行,grep 命令,看线程 0xbda 做了什么
cat 3033.stack |grep 'bda' -C 8
IV.异常信息
jstack 进程号|grep 16进制异常线程号 -A100
19.OOM 问题定位?
I.确定本身内存大小?
jmap -heap pid
可以查看新生代,老年代堆内存的分配大小以及使用情况,看是否本身分配过小
II.找到最耗内存的对象
jmap -histo:live pid | more
结果以表格的形式显示存活对象的信息,并按照所占内存大小排序:
实例数,所占内存大小,类名
如果发现某类对象占用内存很大,很可能是类对象创建太多,且一直未释放。例如:
(1)申请完资源后,未调用 close 释放资源
(2)消费者消费速度慢,生产者不断往队列中投递任务,导致队列中任务累积过多
七.GC 问题分类-高阶
I.IO 密集型和计算密集型
在 JVM 中解决方案:
- IO 交互型: 互联网上目前大部分的服务都属于该类型,例如分布式 RPC、MQ、HTTP 网关服务等,对内存要求并不大,大部分对象在 TP9999 的时间内都会死亡, Young 区越大越好。
- MEM 计算型: 主要是分布式数据计算 Hadoop,分布式存储 HBase、Cassandra,自建的分布式缓存等,对内存要求高,对象存活时间长,Old 区越大越好。
II.GC 问题分类
-
Unexpected GC: 意外发生的 GC,实际上不需要发生,我们可以通过一些手段去避免。
-
- Space Shock: 空间震荡问题,参见“场景一:动态扩容引起的空间震荡”。
- Explicit GC: 显示执行 GC 问题,参见“场景二:显式 GC 的去与留”。
-
Partial GC: 部分收集操作的 GC,只对某些分代/分区进行回收。
-
- CMS: Old GC 频繁,参见“场景五:CMS Old GC 频繁”。
- CMS: Old GC 不频繁但单次耗时大,参见“场景六:单次 CMS Old GC 耗时长”。
- ParNew: Young GC 频繁,参见“场景四:过早晋升”。
- Young GC: 分代收集里面的 Young 区收集动作,也可以叫做 Minor GC。
- Old GC: 分代收集里面的 Old 区收集动作,也可以叫做 Major GC,有些也会叫做 Full GC,但其实这种叫法是不规范的,在 CMS 发生 Foreground GC 时才是 Full GC,CMSScavengeBeforeRemark 参数也只是在 Remark 前触发一次 Young GC。
-
Full GC: 全量收集的 GC,对整个堆进行回收,STW 时间会比较长,一旦发生,影响较大,也可以叫做 Major GC,参见“场景七:内存碎片&收集器退化”。
-
MetaSpace: 元空间回收引发问题,参见“场景三:MetaSpace 区 OOM”。
-
Direct Memory: 直接内存(也可以称作为堆外内存)回收引发问题,参见“场景八:堆外内存 OOM”。
-
JNI: 本地 Native 方法引发问题,参见“场景九:JNI 引发的 GC 问题”。
III.场景分析
1.动态扩容引起空间震荡
现象服务刚刚启动时 GC 次数较多,最大空间剩余很多但是依然发生 GC,这种情况我们可以通过观察 GC 日志或者通过监控工具来观察堆的空间变化情况即可。GC Cause 一般为 Allocation Failure,且在 GC 日志中会观察到经历一次 GC ,堆内各个空间的大小会被调整
原因在 JVM 的参数中 -Xms
和 -Xmx
设置的不一致,在初始化时只会初始 -Xms
大小的空间存储信息,每当空间不够用时再向操作系统申请,这样的话必然要进行一次 GC,另外,如果空间剩余很多时也会进行缩容操作.
解决尽量将成对出现的空间大小配置参数设置成固定的,如 -Xms
和 -Xmx
,-XX:MaxNewSize
和 -XX:NewSize
,-XX:MetaSpaceSize
和 -XX:MaxMetaSpaceSize
等。
2.显示 GC 的去与留
现象:除了扩容缩容会触发 CMS GC 之外,还有 Old 区达到回收阈值、MetaSpace 空间不足、Young 区晋升失败、大对象担保失败等几种触发条件,如果这些情况都没有发生却触发了 GC ?这种情况有可能是代码中手动调用了 System.gc 方法,此时可以找到 GC 日志中的 GC Cause 确认下。那么这种 GC 到底有没有问题,翻看网上的一些资料,有人说可以添加 -XX:+DisableExplicitGC
参数来避免这种 GC,也有人说不能加这个参数,加了就会影响 Native Memory 的回收。先说结论,笔者这里建议保留 System.gc,那为什么要保留?我们一起来分析下。
原因:找到 System.gc 在 Hotspot 中的源码,可以发现增加 -XX:+DisableExplicitGC
参数后,这个方法变成了一个空方法,如果没有加的话便会调用 Universe::heap()::collect
方法,继续跟进到这个方法中,发现 System.gc 会引发一次 STW 的 Full GC,对整个堆做收集。
JVM_ENTRY_NO_ENV(void, JVM_GC(void))JVMWrapper("JVM_GC");if (!DisableExplicitGC) {Universe::heap()->collect(GCCause::_java_lang_system_gc);}
JVM_END
保留 System.gc 时会使用 Foreground Collector 时将会带来非常长的 STW.
如果禁用掉的话就会带来另外一个内存泄漏问题,此时就需要说一下 DirectByteBuffer,它有着零拷贝等特点,被 Netty 等各种 NIO 框架使用,会使用到堆外内存。堆内存由 JVM 自己管理,堆外内存必须要手动释放,DirectByteBuffer 没有 Finalizer,它的 Native Memory 的清理工作是通过 sun.misc.Cleaner
自动完成的,是一种基于 PhantomReference 的清理工具,比普通的 Finalizer 轻量些
解决:保留 System.gc
3.MetaSpace 区 OOM
现象:在最底层,JVM 通过 mmap 接口向操作系统申请内存映射,每次申请 2MB 空间,这里是虚拟内存映射,不是真的就消耗了主存的 2MB,只有之后在使用的时候才会真的消耗内存。申请的这些内存放到一个链表中 VirtualSpaceList,作为其中的一个 Node。
JVM 在启动后或者某个时间点开始,MetaSpace 的已使用大小在持续增长,同时每次 GC 也无法释放,调大 MetaSpace 空间也无法彻底解决。
原因:
MetaSpace 内存管理: 类和其元数据的生命周期与其对应的类加载器相同,只要类的类加载器是存活的,在 Metaspace 中的类元数据也是存活的,不能被回收。每个加载器有单独的存储空间,通过 ClassLoaderMetaspace 来进行管理 SpaceManager* 的指针,相互隔离的。
MetaSpace 弹性伸缩:可以动态的调整大小.
关键原因就是 ClassLoader 不停地在内存中 load 了新的 Class ,一般这种问题都发生在动态类加载等情况上
解决:dump 快照之后通过 JProfiler 或 MAT 观察 Classes 的 Histogram(直方图) 即可,或者直接通过命令即可定位, jcmd 打几次 Histogram 的图,看一下具体是哪个包下的 Class 增加较多就可以定位了。
4.过早晋升
现象:
分配速率接近于晋升速率,对象晋升年龄较小。
Full GC 比较频繁,且经历过一次 GC 之后 Old 区的变化比例非常大。
过早晋升的危害:
- Young GC 频繁,总的吞吐量下降。
- Full GC 频繁,可能会有较大停顿。
原因:
- Young/Eden 区过小: 过小的直接后果就是 Eden 被装满的时间变短,本应该回收的对象参与了 GC 并晋升,Young GC 采用的是复制算法,也就是 Young GC 耗时本质上就是 copy 的时间,没来及回收的对象增大了回收的代价,所以 Young GC 时间增加,同时又无法快速释放空间,Young GC 次数也跟着增加。
- 分配速率过大: 可以观察出问题前后内存的分配速率,如果有明显波动可以尝试观察网卡流量、存储类中间件慢查询日志等信息,看是否有大量数据被加载到内存中。
解决:增大 Young 区.
如果是分配速率过大
- 偶发较大:通过内存分析工具找到问题代码,从业务逻辑上做一些优化。
- 一直较大:当前的 Collector 已经不满足 Mutator 的期望了,这种情况要么扩容 Mutator 的 VM,要么调整 GC 收集器类型或加大空间。
5.Old GC 频繁?
现象:Old 区频繁的做 CMS GC,但是每次耗时不是特别长,整体最大 STW 也在可接受范围内,但由于 GC 太频繁导致吞吐下降比较多。
原因:这种情况比较常见,基本都是一次 Young GC 完成后,负责处理 CMS GC 的一个后台线程 concurrentMarkSweepThread 会不断地轮询,使用 shouldConcurrentCollect()
方法做一次检测,判断是否达到了回收条件。如果达到条件,使用 collect_in_background()
启动一次 Background 模式 GC。轮询的判断是使用 sleepBeforeNextCycle()
方法,间隔周期为 -XX:CMSWaitDuration
决定,默认为 2s。
解决:
- 内存 Dump: 使用 jmap、arthas 等 dump 堆进行快照时记得摘掉流量,同时分别在 CMS GC 的发生前后分别 dump 一次。
- 分析 Top Component: 要记得按照对象、类、类加载器、包等多个维度观察 Histogram,同时使用 outgoing 和 incoming 分析关联的对象,另外就是 Soft Reference 和 Weak Reference、Finalizer 等也要看一下。
- 分析 Unreachable: 重点看一下这个,关注下 Shallow 和 Retained 的大小。如下图所示,笔者之前一次 GC 优化,就根据 Unreachable Objects 发现了 Hystrix 的滑动窗口问题。
6.单次 CMS Old GC 耗时长
现象:CMS GC 单次 STW 最大超过 1000ms,不会频繁发生。某些场景下会引起“雪崩效应”,这种场景非常危险,我们应该尽量避免出现。
原因:CMS 在回收的过程中,STW 的阶段主要是 Init Mark 和 Final Remark 这两个阶段,也是导致 CMS Old GC 最多的原因,在初始标记阶段,整个过程比较简单,从 GC Root 出发标记 Old 中的对象,处理完成后借助 BitMap 处理下 Young 区对 Old 区的引用,整个过程基本都比较快,很少会有较大的停顿。
Final Remark 是最终的第二次标记,这种情况只有在 Background GC 执行了 InitialMarking 步骤的情形下才会执行,如果是 Foreground GC 执行的 InitialMarking 步骤则不需要再次执行 FinalRemark。Final Remark 的开始阶段与 Init Mark 处理的流程相同,但是后续多了 Card Table 遍历、Reference 实例的清理并将其加入到 Reference 维护的 pend_list
中,如果要收集元数据信息,还要清理 SystemDictionary、CodeCache、SymbolTable、StringTable 等组件中不再使用的资源。
解决:对 FinalReference 的分析主要观察 java.lang.ref.Finalizer
对象的 dominator tree,找到泄漏的来源。经常会出现问题的几个点有 Socket 的 SocksSocketImpl
、Jersey 的 ClientRuntime
、MySQL 的 ConnectionImpl
7.内存碎片&收集器退化
现象:并发的 CMS GC 算法,退化为 Foreground 单线程串行 GC 模式,STW 时间超长,有时会长达十几秒。其中 CMS 收集器退化后单线程串行 GC 算法有两种:
- 带压缩动作的算法,称为 MSC,上面我们介绍过,使用标记-清理-压缩,单线程全暂停的方式,对整个堆进行垃圾收集,也就是真正意义上的 Full GC,暂停时间要长于普通 CMS。
- 不带压缩动作的算法,收集 Old 区,和普通的 CMS 算法比较相似,暂停时间相对 MSC 算法短一些。
原因:晋升失败,增量收集担保失败,显示 GC
解决:
- 内存碎片: 通过配置
-XX:UseCMSCompactAtFullCollection=true
来控制 Full GC 的过程中是否进行空间的整理(默认开启,注意是 Full GC,不是普通 CMS GC),以及-XX: CMSFullGCsBeforeCompaction=n
来控制多少次 Full GC 后进行一次压缩。 - 增量收集: 降低触发 CMS GC 的阈值,即参数
-XX:CMSInitiatingOccupancyFraction
的值,让 CMS GC 尽早执行,以保证有足够的连续空间,也减少 Old 区空间的使用大小,另外需要使用-XX:+UseCMSInitiatingOccupancyOnly
来配合使用,不然 JVM 仅在第一次使用设定值,后续则自动调整。 - 浮动垃圾: 视情况控制每次晋升对象的大小,或者缩短每次 CMS GC 的时间,必要时可调节 NewRatio 的值。另外就是使用
-XX:+CMSScavengeBeforeRemark
在过程中提前触发一次 Young GC,防止后续晋升过多对象。
8.堆外内存 OOM
现象:内存使用率不断上升,甚至开始使用 SWAP 内存,同时可能出现 GC 时间飙升,线程被 Block 等现象,通过 top 命令发现 Java 进程的 RES 甚至超过了 -Xmx
的大小。出现这些现象时,基本可以确定是出现了堆外内存泄漏。
原因:
- 通过
UnSafe#allocateMemory
,ByteBuffer#allocateDirect
主动申请了堆外内存而没有释放,常见于 NIO、Netty 等相关组件。 - 代码中有通过 JNI 调用 Native Code 申请的内存没有释放。
解决:
JVM 使用 -XX:MaxDirectMemorySize=size
参数来控制可申请的堆外内存的最大值。在 Java 8 中,如果未配置该参数,默认和 -Xmx
相等。
9.JNI 引发的 GC 问题
现象:在 GC 日志中,出现 GC Cause 为 GCLocker Initiated GC。
原因:JNI(Java Native Interface)意为 Java 本地调用,它允许 Java 代码和其他语言写的 Native 代码进行交互。由于 Native 代码直接使用了 JVM 堆区的指针,如果这时发生 GC,就会导致数据错误。因此,在发生此类 JNI 调用时,禁止 GC 的发生,同时阻止其他线程进入 JNI 临界区,直到最后一个线程退出临界区时触发一次 GC。
解决:
- 添加
-XX+PrintJNIGCStalls
参数,可以打印出发生 JNI 调用时的线程,进一步分析,找到引发问题的 JNI 调用。 - JNI 调用需要谨慎,不一定可以提升性能,反而可能造成 GC 问题。
- 升级 JDK 版本到 14,避免 JDK-8048556 导致的重复 GC。