JVM
JDK
- JDK1.8
- Lambda 表达式
- 函数式接口
- 法引用和构造器调用
- Stream API
- 接口中的默认方法和静态方法
- 新时间日期 API
编译
- 前端编译:源代码到字节码
- 把 Java 源码文件(.java)编译成 Class 文件(.class)的过程。
- JDK 的安装目录里有一个 javac 工具,就是它将 Java 代码翻译成字节码,这个工具我们叫做编译器。相对于后面要讲的其他编译器,其因为处于编译的前期,因此又被成为前端编译器。
- 编译器的处理过程:
- 词法、语法分析
- 在这个阶段,JVM 会对源代码的字符进行一次扫描,最终生成一个抽象的语法树。简单地说,在这个阶段 JVM 会搞懂我们的代码到底想要干嘛。就像我们分析一个句子一样,我们会对句子划分主谓宾,弄清楚这个句子要表达的意思一样。
- 填充符号表
- 我们知道类之间是会互相引用的,但在编译阶段,我们无法确定其具体的地址,所以我们会使用一个符号来替代。在这个阶段做的就是类似的事情,即对抽象的类或接口进行符号填充。等到类加载阶段,JVM 会将符号替换成具体的内存地址。
- 注解处理
- 我们知道 Java 是支持注解的,因此在这个阶段会对注解进行分析,根据注解的作用将其还原成具体的指令集。
- 分析与字节码生成
- 到了这个阶段,JVM 便会根据上面几个阶段分析出来的结果,进行字节码的生成,最终输出为 class 文件。
- 词法、语法分析
- 常见的前端编译器有 Sun 的 javac,Eclipse JDT 的增量式编译器(ECJ)。
- 后端编译/即时(JIT)编译:字节码到机器码
- 在运行时把 Class 文件字节码编译成本地机器码的过程。
- 当源代码转化为字节码之后,其实要运行程序,有两种选择。一种是使用 Java 解释器解释执行字节码,另一种则是使用 JIT 编译器将字节码转化为本地机器代码。前者启动速度快但运行速度慢,而后者启动速度慢但运行速度快。
- 解释器不需要像 JIT 编译器一样,将所有字节码都转化为机器码,自然就少去了优化的时间。而当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。所以在实际情况中,为了运行速度以及效率,我们通常采用两者相结合的方式进行 Java 代码的编译执行。
- 通过
java -version
可以看到 JVM 都是 mixed mode(混合模式,也就是解释器和编译器混合使用)。 - 现在主流的商用虚拟机(如 Sun HotSpot、IBM J9)中几乎都同时包含解释器和编译器(三大商用虚拟机之一的 JRockit 是个例外,它内部没有解释器,因此会有启动相应时间长之类的缺点,但它主要是面向服务端的应用,这类应用一般不会重点关注启动时间)。
- 与前端编译相比,二者各有优势
- 当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行;当程序运行后,随着时间的推移,编译器逐渐会返回作用,把越来越多的代码编译成本地代码后,可以获取更高的执行效率。解释执行可以节约内存,而编译执行可以提升效率。
- 两种情况,编译器都是以整个方法作为编译对象,这种编译也是虚拟机中标准的编译方式。
- 要知道一段代码或方法是不是热点代码,是不是需要触发即时编译,需要进行 Hot Spot Detection(热点探测)。目前主要的热点判定方式有以下两种:
- 基于采样的热点探测
- 采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这段方法代码就是“热点代码”。这种探测方法的好处是实现简单高效,还可以很容易地获取方法调用关系,缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
- 基于计数器的热点探测
- 采用这种方法的虚拟机会为每个方法,甚至是代码块建立计数器,统计方法的执行次数,如果执行次数超过一定的阀值,就认为它是“热点方法”。这种统计方法实现复杂一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对更加精确严谨。
- 两个计数器
- 方法调用计数器
- 方法调用计数器用来统计方法调用的次数,在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法被调用的次数。
- 回边计数器
- 回边计数器用于统计一个方法中循环体代码执行的次数(准确地说,应该是回边的次数,因为并非所有的循环都是回边),在字节码中遇到控制流向后跳转的指令就称为“回边”。
- 方法调用计数器
- 在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阀值,当计数器的值超过了阀值,就会触发JIT编译。触发了 JIT 编译后,在默认设置下,执行引擎并不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成为止(编译工作在后台线程中进行)。当编译工作完成后,下一次调用该方法或代码时,就会使用已编译的版本。
- 基于采样的热点探测
- JIT 工作流程
- JIT 编译器在运行程序时有两种编译模式可以选择,并且其会在运行时决定使用哪一种以达到最优性能。这两种编译模式的命名源自于命令行参数(eg:
-client
或者-server
)。JVM Server 模式与 client 模式启动,最主要的差别在于:-server
模式启动时,速度较慢,但是一旦运行起来后,性能将会有很大的提升。原因是:当虚拟机运行在-client
模式的时候,使用的是一个代号为 C1 的轻量级编译器,而-server
模式启动的虚拟机采用相对重量级代号为 C2 的编译器。C2 比 C1 编译器编译的相对彻底,服务起来之后,性能更高。- RednaxelaFX 大佬
- JIT 编译
- 全称 just-in-time compilation,按照其原始的、严格的定义,是每当一部分代码准备要第一次执行的时候,将这部分代码编译,然后跳进编译好的代码里执行。这样,所有执行过的代码都必然会被编译过。早期的 JIT 编译系统对同一个块代码只会编译一次。
- JIT 编译的单元也可以选择是方法/函数级别,或者别的,例如 trace。
- 严格说 JIT 编译与自适应编译相比:
- 前者的编译时机比后者早:第一次执行之前 vs 已经被执行过若干次
- 前者编译的代码比后者多:所有执行过的代码 vs 一部分代码
- JIT 编译与自适应编译都属于“动态编译”(dynamic compilation),或者叫“运行时编译”的范畴。特点是在程序运行的时候进行编译,而不是在程序开始运行之前就完成了编译;后者也叫做“静态编译”(static compilation)或者 AOT 编译(ahead-of-time compilation)。
- 现在“JIT 编译”这个名词已经被泛化为等价于“动态编译”,所以包含了严格的 JIT 编译和自适应编译。就这个角度说,HotSpot VM 仍然在使用“JIT 编译”;里面的 Client Compiler(C1)和 Server Compiler(C2)也常被称为“JIT 编译器“。要注意,这样的说法已经不是指严格意义上的“JIT”了。
- JIT 编译
- RednaxelaFX 大佬
- 以下是具有代表性的 HotSpot 虚拟机的即时编译器在生成代码时采用的代码优化技术:
- 公共子表达式消除(语言无关的经典优化技术之一)
- 如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成为了公共子表达式。对于这种表达式,没必要花时间再对它进行计算,只需要直接使用前面计算过的表达式结果代替 E 就可以了。
- 例子
int d = (c*b) * 12 + a + (a+ b * c) -> int d = E * 12 + a + (a+ E)
- 数组范围检查消除(语言相关的经典优化技术之一)
- 在 Java 语言中访问数组元素的时候系统将会自动进行上下界的范围检查,超出边界会抛出异常。对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑是一种性能负担。Java 在编译期根据数据流分析可以判定范围进而消除上下界检查,节省多次的条件判断操作。
- 方法内联(最重要的优化技术之一)
- 简单的理解为把目标方法的代码“复制”到发起调用的方法中,消除一些无用的代码。只是实际的 JVM 中的内联过程很复杂,在此不分析。
- 逃逸分析(最前沿的优化技术之一)
- 逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
- 对象的逃逸状态
- 全局逃逸(GlobalEscape)
- 即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:
- 对象是一个静态变量
- 对象是一个已经发生逃逸的对象
- 对象作为当前方法的返回值
- 即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:
- 参数逃逸(ArgEscape)
- 即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的。
- 没有逃逸
- 即方法中的对象没有发生逃逸。
- 全局逃逸(GlobalEscape)
- 如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可以为这个变量进行一些高效的优化:
- 栈上分配
- 将不会逃逸的局部对象分配到栈上,那对象就会随着方法的结束而自动销毁,减少垃圾收集系统的压力。
- 同步消除
- 如果该变量不会发生线程逃逸,也就是无法被其他线程访问,那么对这个变量的读写就不存在竞争,可以将同步措施消除掉(同步是需要付出代价的)
- 标量替换
- 标量是指无法在分解的数据类型,比如原始数据类型以及 reference 类型。而聚合量就是可继续分解的,比如 Java 中的对象。标量替换如果一个对象不会被外部访问,并且对象可以被拆散的话,真正执行时可能不创建这个对象,而是直接创建它的若干个被这个方法使用到的成员变量来代替。这种方式不仅可以让对象的成员变量在栈上分配和读写,还可以为后后续进一步的优化手段创建条件。
- 栈上分配
- 公共子表达式消除(语言无关的经典优化技术之一)
- 静态提前编译(Ahead Of Time,AOT 编译):源代码到机器码
- 优点是编译不占用运行时间,可以做一些较耗时的优化,并可加快程序启动。但因为 Java 语言的动态性(如反射)带来了额外的复杂性,影响了静态编译代码的质量。一般静态编译不如 JIT 编译的质量,这种方式用得比较少。
- 目前 Java 体系中主要还是采用前端编译+JIT编译的方式,如 JDK 中的 HotSpot 虚拟机。
VM
- HotSpot VM
- 这个 JVM 最初由 Longview/Animorphic 实现,随着公司被 Sun/JavaSoft 收购而成为 Sun 的 JVM,并于 JDK 1.3.0 开始成为 Sun 的 Java SE 的主要 JVM。在 Sun 被 Oracle 收购后,现在 HotSpot VM 是 Oracle 的 Java SE 的主要 JVM。
- HotSpot VM 得名于它得混合模式执行引擎:这个执行引擎包括解释器和自适应编译器(adaptive compiler)。
- 执行流程
- 默认配置下,一开始所有 Java 方法都由解释器执行。解释器记录着每个方法得调用次数和循环次数,并以这两个数值为指标去判断一个方法的“热度”。显然,HotSpot VM 是以“方法”为单位来寻找热点代码。
- 等到一个方法足够“热”的时候,HotSpot VM 就会启动对该方法的编译。这种在所有执行过的代码里只寻找一部分来编译的做法,就叫做自适应编译(adaptive compilation)。
- JRockit VM
- 这个 JVM 使用纯编译的执行引擎,没有解释器。
- 它有多层编译:第一次执行某个方法之前会用非常低的优化级别去 JIT 编译,然后等到某个方法足够热之后再用较高的优化级别重新编译它。
- 这种系统既是严格意义上的 JIT 编译(第一次执行某个方法前编译它),又是自适应编译(找出热点再进行编译)。
- IBM J9
双亲委派模型
- 工作机制:当类加载器接收到类加载的请求时,它不会自己去尝试加载这个类,而是把这个请求委派给父加载器去完成,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载类。
- 加载流程:
- 当类加载器接收到类加载的请求时,首先检查该类是否已经被当前类加载器加载;
- 若该类未被加载过,当前类加载器会将加载请求委托给父类加载器去完成;
- 若当前类加载器的父类加载器(或父类的父类……向上递归)为 null,会委托启动类加载器完成加载;
- 若父类加载器无法完成类的加载,当前类加载器才会去尝试加载该类。
- 类加载器分类:
- 启动类加载器 Bootstrap ClassLoader
- 是所有类加载器的”老祖宗”,是由 C++ 实现的,不继承于 java.lang.ClassLoader 类。 它在虚拟机启动时会由虚拟机的一段 C++ 代码进行加载,所以它没有父类加载器,在加载完成后,它会负责去加载扩展类加载器和应用类加载器。
- 用于加载 Java 的核心类——位于
<JAVA_HOME>\lib
中,或者被-Xbootclasspath
参数所指定的路径中,并且是虚拟机能够识别的类库(仅按照文件名识别,如rt.jar、tools.jar
,名字不符合的类库即使放在 lib 目录中也不会被加载)。
- 拓展类加载器 Extension ClassLoader
- 拓展类加载器继承于 java.lang.ClassLoader 类,它的父类加载器是启动类加载器,而启动类加载器在 Java 中的显示就是 null。
- 拓展类加载器负责加载
<JAVA_HOME>\lib\ext
目录中的,或者被java.ext.dirs
系统变量所指定的路 径的所有类。 - 需要注意的是扩展类加载器仅支持加载被打包为 .jar 格式的字节码文件。
- 应用类/系统类加载器 App/System ClassLoader
- 应用类加载器继承于 java.lang.ClassLoader 类,它的父类加载器是扩展类加载器。
- 应用类加载器负责加载用户类路径 classpath 上所指定的类库。
- 如果应用程序中没有自定义的类加载器,一般情况下应用类加载器就是程序中默认的类加载器。
- 自定义类加载器 Custom ClassLoader
- 自定义类加载器继承于 java.lang.ClassLoader 类,它的父类加载器是应用类加载器。
- 这是自定义的类加载器,可加载指定路径的字节码文件。
- 自定义类加载器需要继承 java.lang.ClassLoader 类并重写 findClass 方法(下文有说明为什么不重写 loadClass 方法)用于实现自定义的加载类逻辑。
- 启动类加载器 Bootstrap ClassLoader
- 双亲委派模型的好处:
- 基于双亲委派模型规定的这种带有优先级的层次性关系,虚拟机运行程序时就能够避免类的重复加载。
- 双亲委派模型能够避免核心类篡改。一般我们描述的核心类是 rt.jar、tools.jar 这些由启动类加载器加载的类,这些类库在日常开发中被广泛运用,如果被篡改,后果将不堪设想。
- 双亲委派模型的不足:
- 由于历史原因(ClassLoader 类在 JDK1.0 时就已经存在,而双亲委派模型是在 JDK1.2 之后才引入的),在未引入双亲委派模型时,用户自定义的类加载器需要继承 java.lang.ClassLoader 类并重写
loadClass()
方法,因为虚拟机在加载类时会调用ClassLoader#loadClassInternal(String)
方法。而这个方法会调用自定义类加载重写的loadClass()
方法。而在引入双亲委派模型后,ClassLoader#loadClass
方法实际就是双亲委派模型的实现,如果重写了此方法,相当于打破了双亲委派模型。为了让用户自定义的类加载器也遵从双亲委派模型, JDK 新增了findClass
方法,用于实现自定义的类加载逻辑。 - 由于双亲委派模型规定的层次性关系,导致子类类加载器加载的类能访问父类类加载器加载的类,而父类类加载器加载的类无法访问子类类加载器加载的类。为了让上层类加载器加载的类能够访问下层类加载器加载的类,或者说让父类类加载器委托子类类加载器完成加载请求,JDK 引入了线程上下文类加载器,藉由它来打破双亲委派模型的屏障。
- 线程上下文类加载器
- 线程上下文类加载器是定义在 Thread 类中的一个 ClassLoader 类型的私有成员变量,它指向了当前线程的类加载器。这个类加载器可以通过 java.lang.Thread 类的
setContextClassLoaser()
方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。 - 父 ClassLoader 可以使用当前线程
Thread.currentThread().getContextClassLoader()
所指定的 classloader 加载的类。
- 线程上下文类加载器是定义在 Thread 类中的一个 ClassLoader 类型的私有成员变量,它指向了当前线程的类加载器。这个类加载器可以通过 java.lang.Thread 类的
- 线程上下文类加载器
- 当用户需要程序的动态性,比如代码热替换、模块热部署等时,双亲委派模型就不再适用,类加载器会发展为更为复杂的网状结构。
- 由于历史原因(ClassLoader 类在 JDK1.0 时就已经存在,而双亲委派模型是在 JDK1.2 之后才引入的),在未引入双亲委派模型时,用户自定义的类加载器需要继承 java.lang.ClassLoader 类并重写
- SPI(Service Provider Interface)
- 它允许服务商编写具体的代码逻辑来完成该接口的功能。
- 但是 Java 提供的 SPI 接口是在核心类库中,由启动类加载器加载的,厂商实现的具体逻辑代码是在 classpath 中,是由应用类加载器加载的,而启动类加载器加载的类无法访问应用类加载器加载的类,也就是说启动类加载器无法找到 SPI 实现类,单单依靠双亲委派模型就无法实现 SPI 的功能了,所以线程上下文类加载器应运而生。
- 相关问题
- Class.forName 和 classloader 的区别?
Class.forName()
执行初始化过程执行静态代码化。- 内部实际调用的方法是
Class.forName(className, true, classloader);
- 内部实际调用的方法是
ClassLoader.loadClass
不执行初始化过程。- 内部实际调用的方法是
ClassLoader.loadClass(className, false);
- 内部实际调用的方法是
- Class.forName 和 classloader 的区别?
类、对象内存相关
- 类加载
- 加载时机
- 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令的时候。
- 使用 java.lang.reflect 进行反射调用的时候。
- 当初始化一个类的时候,发现其父类还没有初始化,那么先去初始化它的父类。
- 当虚拟机启动的时候,需要初始化 main 函数所在的类。
- 加载流程
- 编译:.java 文件编译后生成 .class 字节码文件
- 加载:获取类的二进制字节流,将其静态存储结构转化为方法区的运行时数据结构
- 连接:细分三步
- 校验:文件格式验证,元数据验证,字节码验证,符号引用验证
- 准备:在方法区中对类的 static 变量分配内存并设置类变量数据类型默认的初始值,不包括实例变量,实例变量将会在对象实例化的时候随着对象一起分配在 Java 堆中
- 解析:将常量池内的符号引用替换为直接引用的过程
- 连接初始化:为类的静态变量赋予正确的初始值(Java 代码中被显式地赋予的值)
- 加载时机
- 对象创建
- 创建过程
- 首先进行类加载的检查
- 虚拟机遇到 new 指令的时候,首先去检查该指令的参数是否能够在常量池中定位到这个类的符号引用,并且检查该符号引用代表的类是否已经被加载过、解析和初始化过。若没有,必须先执行相应的类加载的过程。
- 分配内存
- 类加载检查通过之后,虚拟机为对象分配内存。(内存大小在类加载完后就能确定)。分配的方式有“指针碰撞”和“空闲列表”两种,若 Java 堆是规整的,采用“指针碰撞”;反之采用空闲列表。
- 初始化零值
- 内存分配完之后,虚拟机将分配到的内存空间初始化为零值。保证了对象的实例字段在 Java 代码中可以不赋初值就直接使用。
- 设置对象头
- 始化零值之后,虚拟机要对对象进行必要的设置:对象头的设置。
- 执行 init 方法
- 最后,虚拟机的视角来看,新的对象已经产生了。但是从 Java 程序的视角来看,init 方法还没有执行,所有字段还都是零。一般来说,执行 new 指令之后就会接着执行 init 方法,把对象按照程序员的意愿进行初始化。真正可用的对象就完全产生了。
- 首先进行类加载的检查
- 对象初始化方法执行流程
- 父类的静态成员赋值和静态块
- 子类的静态成员和静态块
- 父类的构造方法
- 父类的成员赋值和初始化块
- 父类的构造方法中的其它语句
- 子类的成员赋值和初始化块
- 子类的构造方法中的其它语句
- 内存分配
- 栈上分配(JIT 性能优化之一)
- 如果确定一个对象的作用域不会逃逸出方法之外,那可以将这个对象分配在栈上,这样,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,无须通过垃圾收集器回收,可以减小垃圾收集器的负载。
- 技术基础:
- 逃逸分析
- 逃逸分析的目的是判断对象的作用域是否有可能逃逸出函数体。
- 标量替换
- 允许将对象打散分配在栈上,比如若一个对象拥有两个字段,会将这两个字段视作局部变量进行分配。
- 逃逸分析
- 设置方法
- 只能在 server 模式下才能启用逃逸分析,参数
-XX:DoEscapeAnalysis
启用逃逸分析,参数-XX:+EliminateAllocations
开启标量替换(默认打开)。Java SE 6u23 版本之后,HotSpot 中默认就开启了逃逸分析,可以通过选项-XX:+PrintEscapeAnalysis
查看逃逸分析的筛选结果。
- 只能在 server 模式下才能启用逃逸分析,参数
- 堆上分配
- 选择那种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
- 分配方式有“指针碰撞”和“空闲列表”两种
- 指针碰撞
- 空闲列表
- 指针碰撞
- 并发问题
- 单线程下,“指针碰撞”和“空闲列表”分配内存不会有线程安全问题,实际开发过程中,创建对象是很频繁的事情,而且是多线程环境,作为虚拟机来说,必须要保证线程是安全的。
- 通常来讲,虚拟机采用两种方式来保证线程安全:
- CAS + 失败重试
- CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB
- 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。
- TLAB 的全称是 Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。相当于线程的私有对象。
- 特点
- 堆是 JVM 中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了 new 对象的开销是比较大的
- Sun Hotspot JVM 为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间 TLAB(Thread Local Allocation Buffer),其大小由 JVM 根据运行的情况计算而得,在 TLAB 上分配对象时不需要加锁,因此 JVM 在给线程的对象分配内存时会尽量的在 TLAB 上分配,在这种情况下 JVM 中分配对象内存的性能和 C 基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配
- TLAB 仅作用于新生代的 Eden Space,因此在编写 Java 程序时,通常多个小的对象比大的对象分配起来更加高效。
- 设置方法
- 如果设置了虚拟机参数
-XX:UseTLAB
,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。 - TLAB 空间的内存非常小,缺省情况下仅占有整个 Eden 空间的 1%,也可以通过选项
-XX:TLABWasteTargetPercent
设置 TLAB 空间所占用 Eden 空间的百分比大小。
- 如果设置了虚拟机参数
- 原理
- TLAB 的本质其实是三个指针管理的区域:start、top 和 end,每个线程都会从 Eden 分配一块空间,例如说 100KB,作为自己的 TLAB,其中 start 和 end 是占位用的,标识出 eden 里被这个 TLAB 所管理的区域,卡住 eden 里的一块空间不让其它线程来这里分配。
- 当一个 TLAB 用满(分配指针 top 撞上分配极限 end 了),就新申请一个 TLAB,而在老 TLAB 里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从 TLAB 分配出来的,而只关心自己是在 eden 里分配的。
- TLAB 只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。从这一点看,它被翻译为线程私有分配区更为合理一点。
- 分配流程
- 从线程当前 TLAB 分配
- 如果启用了 TLAB(默认是启用的, 可以通过
-XX:-UseTLAB
关闭),则首先从线程当前 TLAB 分配内存,如果分配成功则返回,否则根据当前 TLAB 剩余空间与当前最大浪费空间限制大小进行不同的分配策略。
- 如果启用了 TLAB(默认是启用的, 可以通过
- 重新申请 TLAB 分配
- 如果当前 TLAB 剩余空间大于当前最大浪费空间限制(这个初始值为 期望大小/TLABRefillWasteFraction),直接在堆上分配。否则,重新申请一个 TLAB 分配。
- 为什么需要最大浪费空间呢?
- 当重新分配一个 TLAB 的时候,原有的 TLAB 可能还有空间剩余。原有的 TLAB 被退回堆之前,需要填充好 dummy object。由于 TLAB 仅线程内知道哪些被分配了,在 GC 扫描发生时返回 Eden 区,如果不填充的话,外部并不知道哪一部分被使用哪一部分没有,需要做额外的检查,如果填充已经确认会被回收的对象,也就是 dummy object,GC 会直接标记之后跳过这块内存,增加扫描效率。反正这块内存已经属于 TLAB,其他线程在下次扫描结束前是无法使用的。这个 dummy object 就是 int 数组。为了一定能有填充 dummy object 的空间,一般 TLAB 大小都会预留一个 dummy object 的 header 的空间,也是一个
int[]
的 header,所以 TLAB 的大小不能超过 int 数组的最大大小,否则无法用 dummy object 填满未使用的空间。 - 但是,填充 dummy 也造成了空间的浪费,这种浪费不能太多,所以通过最大浪费空间限制来限制这种浪费。
- 新的 TLAB 大小,取如下两个值中较小的那个:
- 当前堆剩余给 TLAB 可分配的空间,大部分 GC 的实现其实就是对应的 Eden 区剩余大小
- TLAB 期望大小 + 当前需要分配的空间大小
- 当重新分配一个 TLAB 的时候,原有的 TLAB 可能还有空间剩余。原有的 TLAB 被退回堆之前,需要填充好 dummy object。由于 TLAB 仅线程内知道哪些被分配了,在 GC 扫描发生时返回 Eden 区,如果不填充的话,外部并不知道哪一部分被使用哪一部分没有,需要做额外的检查,如果填充已经确认会被回收的对象,也就是 dummy object,GC 会直接标记之后跳过这块内存,增加扫描效率。反正这块内存已经属于 TLAB,其他线程在下次扫描结束前是无法使用的。这个 dummy object 就是 int 数组。为了一定能有填充 dummy object 的空间,一般 TLAB 大小都会预留一个 dummy object 的 header 的空间,也是一个
- 为什么需要最大浪费空间呢?
- 如果当前 TLAB 剩余空间大于当前最大浪费空间限制(这个初始值为 期望大小/TLABRefillWasteFraction),直接在堆上分配。否则,重新申请一个 TLAB 分配。
- 直接从堆上分配
- 直接从堆上分配是最慢的分配方式。一种情况就是,如果当前 TLAB 剩余空间大于当前最大浪费空间限制,直接在堆上分配。并且,还会增加当前最大浪费空间限制,每次有这样的分配就会增加 TLABWasteIncrement 的大小,这样在一定次数的直接堆上分配之后,当前最大浪费空间限制一直增大会导致当前 TLAB 剩余空间小于当前最大浪费空间限制,从而申请新的 TLAB 进行分配。
- 从线程当前 TLAB 分配
- CAS + 失败重试
- 栈上分配(JIT 性能优化之一)
- 创建过程
- 对象的访问
- Java 程序通过栈上的 reference(引用)数据来操作堆上的具体对象。
- 主流的访问方式
- 使用句柄
- 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息
- 使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
- 直接指针
- 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址
- 使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
- 使用句柄
- 对象在内存中分为三块区域
- 对象头
- Mark Word(标记字段):默认存储对象的 HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间 Mark Word 里存储的数据会随着锁标志位的变化而变化。
- 64 bit
- Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
- 64 bit
- Mark Word(标记字段):默认存储对象的 HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间 Mark Word 里存储的数据会随着锁标志位的变化而变化。
- 实例数据
- 这部分主要是存放类的数据信息,父类的信息。
- 对其填充
- 由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。
- Tip:不知道大家有没有被问过一个空对象占多少个字节?就是8个字节,是因为对齐填充的关系哈,不到8个字节对其填充会帮我们自动补齐。
- 由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。
- 对象头
内存模型
- 内存分区
- 方法区(Method Area)
- 《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:
- 它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
- 方法区结构
- 类型信息
- 对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)
- 这个类型的修饰符(public,abstract,final的某个子集)
- 这个类型直接接口的一个有序列表
- 对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息:
- 域(Field)信息
- JVM必 须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
- 域的相关信息包括:
- 域名称
- 域类型
- 域修饰符(public,private,protected,static,final,volatile,transient 的某个子集)
- 域信息特殊情况
- non-final 类型的类变量
- 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
- 类变量被类的所有实例共享,即使没有类实例时,你也可以访问它
- 全局常量:static final
- 全局常量就是使用 static final 进行修饰
- 被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。
- non-final 类型的类变量
- 方法(Method)信息
- JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
- 方法名称
- 方法的返回类型(包括 void 返回类型),void 在 Java 中对应的类为 void.class
- 方法参数的数量和类型(按顺序)
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集)
- 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外)
- 异常表(abstract和native 方法除外),异常表记录每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
- JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
- 类型信息
- 常量池、运行时常量池
- 常量池、运行时常量池
- 方法区,内部包含了运行时常量池
- 字节码文件,内部包含了常量池
- 要弄清楚方法区,需要理解清楚 ClassFile,因为加载类的信息都在方法区。
- 要弄清楚方法区的运行时常量池,需要理解清楚 ClassFile 中的常量池。
- 常量池
- 一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外
- 还包含一项信息就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用
- 常量池中有啥
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
- 运行时常量池
- 运行时常量池(Runtime Constant Pool)是方法区的一部分。
- 常量池表(Constant Pool Table)是 Class 字节码文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
- 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
- JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
- 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
- 运行时常量池,相对于 Class 文件常量池的另一重要特征是:具备动态性。
- 运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富一些。
- 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛 OutofMemoryError 异常。
- 常量池、运行时常量池
- 版本区别
- 在 JDK1.7 及之前,HotSpot VM 的实现就是将其放在永久代中,这样的好处就是可以直接使用堆中的 GC 算法来进行管理,但坏处就是经常会出现内存溢出,即 PermGen Space 异常。
- JDK1.6及以前
- 有永久代(permanent generation),静态变量存储在永久代上
- JDK1.7
- 有永久代,但已经逐步“去永久代”,字符串常量池,静态变量移除,保存在堆中
- JDK1.6及以前
- 在 JDK1.8 中,HotSpot VM 取消了永久代,用元空间取而代之,元空间直接使用本地内存,理论上电脑有多少内存它就可以使用多少内存,所以不会再出现 PermGen Space 异常。引用类型的常量,只会在堆上(Java8)
- 在 JDK1.7 及之前,HotSpot VM 的实现就是将其放在永久代中,这样的好处就是可以直接使用堆中的 GC 算法来进行管理,但坏处就是经常会出现内存溢出,即 PermGen Space 异常。
- 《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:
- 堆(Heap)
- 几乎所有对象、数组等都是在此分配内存的,在 JVM 内存中占的比例也是极大的,也是 GC 垃圾回收的主要阵地
- 可以处于物理上不连续但逻辑上连续的空间
- 分区
- 新生代
- Eden
- From Survivor、To Survivor
- 老年代
- 永久代【HotSpot VM 取消了永久代】
- 新生代
- 虚拟机栈(Java Stack)
当 JVM 在执行方法时,会在此区域中创建一个栈帧来存放方法的各种信息,比如返回值,局部变量表和各种对象引用等,方法开始执行前就先创建栈帧入栈,执行完后就出栈。【虚拟机栈描述的是 Java 方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。 】- 出现栈溢出的情况
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;
- 根本原因是,某个线程所需的栈内存超过了JVM的限制,而此时物理内存仍有足够的可用空间。
- 如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展,只不过 Java 虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出 OutOfMemoryError 异常。
- 根本原因是,(操作系统管理的)物理内存已没有足够的可用内存分配给JVM的栈使用。
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;
- 本地方法栈(Native Method Stack)
- 和虚拟机栈类似,不过区别是专⻔提供给 Native 方法用的。
- 程序计数器(Program Counter Register)
- 占用很小的一片区域,我们知道 JVM 执行代码是一行一行执行字节码,所以需要一个计数器来记录当前执行的行数。
- 方法区(Method Area)
- 内存泄漏
- 当某些对象不再被应用程序所使用,但是由于仍然被引用而导致垃圾收集器不能释放他们。
- 内存泄漏场景
- 静态集合类
- 各种连接,如数据库连接、网络连接和 IO 连接等。
- 变量不合理的作用域。
- 内部类持有外部类
- 如果一个外部类的实例对象的方法返回了一个内部类的实例对象,这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄露。
- 改变哈希值
- 当一个对象被存储进 HashSet 集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了,否则,对象修改后的哈希值与最初存储进 HashSet 集合中时的哈希值就不同了,在这种情况下,即使在 contains 方法使用该对象的当前引用作为的参数去 HashSet 集合中检索对象,也将返回找不到对象的结果,这也会导致无法从 HashSet 集合中单独删除当前对象,造成内存泄露
- 监听器的使用,在释放对象的同时没有相应删除监听器的时候也可能导致内存泄露。
- 内存泄露解决的原则:
- 尽量减少使用静态变量,类的静态变量的生命周期和类同步的。
- 声明对象引用之前,明确内存对象的有效作用域,尽量减小对象的作用域,将类的成员变量改写为方法内的局部变量;
- 减少长生命周期的对象持有短生命周期的引用;
- 使用 StringBuilder 和 StringBuffer 进行字符串连接,String 和 StringBuilder 以及 StringBuffer 等都可以代表字符串,其中 String 字符串代表的是不可变的字符串,后两者表示可变的字符串。如果使用多个 String 对象进行字符串连接运算,在运行时可能产生大量临时字符串,这些字符串会保存在内存中从而导致程序性能下降。
- 对于不需要使用的对象手动设置 null 值,不管 GC 何时会开始清理,我们都应及时的将无用的对象标记为可被清理的对象;
- 各种连接(数据库连接,网络连接,IO 连接)操作,务必显示调用 close 关闭。
引用类型
- 强引用
- 被强引用关联的对象不会被回收。
- 使用 new 一个新对象的方式来创建强引用。
- 软引用
- 被软引用关联的对象只有在内存不够的情况下才会被回收。
- 使用 SoftReference 类来创建软引用。
- 弱引用
- 被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。
- 使用 WeakReference 类来创建弱引用。
- 虚引用
- 又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。
- 为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。
- 使用 PhantomReference 来创建虚引用。
垃圾回收
- 垃圾回收算法
- 标记清除
- 标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。
- 流程
- 在标记阶段首先通过根节点(GC Roots),标记所有从根节点开始的对象,未被标记的对象就是未被引用的垃圾对象。然后,在清除阶段,清除所有未被标记的对象。
- 复制算法
- 流程
- 从根集合节点进行扫描,标记出所有的存活对象,并将这些存活的对象复制到一块儿新的内存(图中下边的那一块儿内存)上去,之后将原来的那一块儿内存(图中上边的那一块儿内存)全部回收掉
- 流程
- 标记整理
- 复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。
- 这种情况在新生代经常发生,但是在老年代更常⻅的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活的对象较多,复制的成本也将很高。
- 分代收集算法
- 分代收集算法就是目前虚拟机使用的回收算法。在不同年代使用不同的算法,从而使用最合适的算法,新生代存活率低,可以使用复制算法。而老年代对象存活率搞,没有额外空间对它进行分配担保,所以只能使用标记清除或者标记整理算法。
- 标记清除
- 判断一个对象是否可被回收
- 引用计数算法
- 为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。
- 在两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。正是因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。
- 可达性分析算法
- 通过 Roots 对象作为起点进行搜索,搜索走过的路径称为“引用链”,当一个对象到 Roots 没有任何的引用链相连时时,证明此对象不可用,当然被判定为不可达的对象不一定就会成为可回收对象。
- GC ROOTS 可以的对象有
- 虚拟机栈中的引用对象
- 方法区的类变量的引用
- 方法区中的常量引用
- 本地方法栈中的对象引用
- GC ROOTS 可以的对象有
- 被判定为不可达的对象要成为可回收对象必须至少经历两次标记过程,如果在这两次标记过程中仍然没 有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了,能否被回收其实主要还是要看
finalize()
方法有没有与引用链上的对象关联,如果在finalize()
方法中有关联则自救成功,改 对象不可被回收,反之如果没有关联则成功被二次标记成功,就可以称为要被回收的垃圾了。
- 通过 Roots 对象作为起点进行搜索,搜索走过的路径称为“引用链”,当一个对象到 Roots 没有任何的引用链相连时时,证明此对象不可用,当然被判定为不可达的对象不一定就会成为可回收对象。
- 方法区的回收
- 因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。
- 主要是对常量池的回收和对类的卸载。
- 为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。
- 类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:
- 该类所有的实例都已经被回收,此时堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
- 引用计数算法
- 垃圾回收器
- Serial
- 复制算法,单线程,新生代
- ParNew
- 复制算法,多线程,新生代
- Parallel Scavenge
- 多线程,复制算法,新生代,高吞吐量
- Serial Old
- 标记-整理算法,老年代
- Parallel Old
- 标记-整理算法,老年代,注重吞吐量的场景下
- JDK8 默认采用 Parallel Scavenge + Parallel Old 的组合
- CMS
- 是一种以获取最短回收停顿时间为目标的收集器
- 基于“标记-清除”算法实现
- 运作过程:
- 初始标记
- 需要“stop the world”
- stop the world
- Stop-The-World 机制简称 STW,是在执行垃圾收集算法时,Java 应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外)。
- Java 中一种全局暂停现象,全局停顿,所有 Java 代码停止,native 代码可以执行,但不能与 JVM 交互;这些现象多半是由于 gc 引起。
- stop the world
- 仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快
- 需要“stop the world”
- 并发标记
- 进行 GC Roots Tracing
- 重新标记
- 需要“stop the world”
- 是为了修正并发标记期间因用户程序继续运作而导致标记产生表动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍⻓点,但远比并发标记的时间短
- 并发清除
- 初始标记
- 优点
- 并发收集、低停顿。
- 缺点
- CMS 收集器对 CPU 资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
- CMS 收集器无法处理浮动垃圾,可能会出现“Concurrent Mode Failure(并发模式故障)”失败而导致 Full GC 产生。
- 浮动垃圾
- 由于 CMS 并发清理阶段用户线程还在运行着,伴随着程序运行自然就会有新的垃圾不断产生,这部分垃圾出现的标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 中再清理。
- 浮动垃圾
- 容易出现大量空间碎片。当空间碎片过多,将会给大对象分配带来很大的麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次 Full GC。
- G1
- 从整体来看是基于“标记整理”算法实现的收集器; 从局部上来看是基于“复制”算法实现的。
- JDK9 默认垃圾收集器 G1
- G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。
- 通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
- 每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
- 虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。它能够采用不同的方式去处理新创建的对象和已经存活了一段时间,熬过多次 GC 的旧对象以获取更好的收集效果。
- 对象分配策略
- TLAB(Thread Local Allocation Buffer)线程本地分配缓冲区
- Eden 区中分配
- Humongous 区分配
- Humongous:如果一个对象占用的空间超过了分区容量 75% 以上,G1 收集器就认为这是一个巨型对象。
- 这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。
- 为了解决这个问题,G1 划分了一个 Humongous 区,它用来专门存放巨型对象。
- 如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储。为了能找到连续的H区,有时候不得不启动 Full GC。
- 运作步骤:
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
- 用来统计每个 region 中的中被标记为存活的对象的数量,这个阶段如果发现完全没有活对象的 region 就会将其整体回收到可分配 region 列表中。
- 把一部分 region 里活的对象拷贝到空的 region 里面,然后回收原本的 region 空间,此阶段可以选择任意多个 region 来构成收集集合(Collection Set),选定好收集集合之后,便可以将 Collection Set 中的对象并行拷贝到新的 region 中。
- 特点
- G1 能充分利用 CPU、多核环境下的硬件优势,使用多个CPU(CPU 或者 CPU 核心)来缩短 stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
- 相关问题
- G1 的个显著特点他能够让用户设置应用的暂停时间,为什么 G1 能做到这一点呢?
- G1 回收的第 4 步,它是“选择一些内存块”,而不是整代内存来回收,这是 G1 跟其它 GC 非常不同的一点,其它 GC 每次回收都会回收整个 Generation 的内存(Eden, Old),而回收内存所需的时间就取决于内存的大小,以及实际垃圾的多少,所以垃圾回收时间是不可控的;而 G1 每次并不会回收整代内存,到底回收多少内存就看用户配置的暂停时间,配置的时间短就少回收点,配置的时间长就多回收点,伸缩自如。
- G1 的个显著特点他能够让用户设置应用的暂停时间,为什么 G1 能做到这一点呢?
- Serial
- 垃圾标记
- 三色标记法
- 颜色说明
- 白色:尚未访问过。
- 黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。
- 灰色:本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色。
- 流程
- 停止线程标记
- 初始时,所有对象都在【白色集合】中;
- 将GC Roots 直接引用到的对象挪到【灰色集合】中;
- 从灰色集合中获取对象:
- 将本对象引用到的其他对象全部挪到【灰色集合】中;
- 将本对象挪到【黑色集合】里面。
- 重复步骤3,直至【灰色集合】为空时结束。
- 结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收。
- 并发标记
- 问题
- 多标-浮动垃圾
- 此刻之后,对象E/F/G是“应该”被回收的。然而因为E已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮GC不会回收这部分内存。
- 这部分本应该回收 但是 没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。
- 另外,针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。
- 漏标-读写屏障
- 读取对象 E 的成员变量 fieldG 的引用值,即对象 G;
- 对象 E 往其成员变量 fieldG,写入 null 值。
- 对象 D 往其成员变量 fieldG,写入对象 G;
- 我们只要在上面这三步中的任意一步中做一些“手脚”,将对象 G 记录起来,然后作为灰色对象再进行遍历即可。比如放到一个特定的集合,等初始的 GC Roots 遍历完(并发标记),该集合的对象遍历即可(重新标记)。
- 此时切回GC线程继续跑,因为E已经没有对G的引用了,所以不会将G放到灰色集合;尽管因为D重新引用了G,但因为D已经是黑色了,不会再重新做遍历处理。
- 最终导致的结果是:G会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。
- 不难分析,漏标只有同时满足以下两个条件时才会发生:
- 灰色对象断开了白色对象的引用(直接或间接的引用);即灰色对象原来成员变量的引用发生了变化。
- 黑色对象重新引用了该白色对象;即黑色对象成员变量增加了新的引用。
- 解决方法
- 写屏障(Store Barrier)
- 写屏障 + SATB
- 这种做法的思路是:尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning,SATB),当某个时刻的 GC Roots 确定后,当时的对象图就已经确定了。
- 比如当时 D 是引用着 G 的,那后续的标记也应该是按照这个时刻的对象图走(D 引用着 G)。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来。
SATB 破坏了条件一:【灰色对象 断开了 白色对象的引用】,从而保证了不会漏标。
- 写屏障 + 增量更新
- 这种做法的思路是:不要求保留原始快照,而是针对新增的引用,将其记录下来等待遍历,即增量更新(Incremental Update)。
增量更新破坏了条件二:【黑色对象 重新引用了 该白色对象】,从而保证了不会漏标。
- 写屏障 + SATB
- 读屏障(Load Barrier)
- 当读取成员变量时,一律记录下来
- 写屏障(Store Barrier)
- Java HotSpot VM 中,其并发标记时对漏标的处理方案如下:
- CMS:写屏障 + 增量更新
- G1:写屏障 + SATB
- ZGC:读屏障
- 多标-浮动垃圾
- 问题
- 停止线程标记
- 颜色说明
- 三色标记法
- GC 流程
- 内存分配策略
- 对象优先在 Eden 分配
- 大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。
- 大对象直接进入老年代
- 大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。
- 经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。
-XX:PretenureSizeThreshold
,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。
- 长期存活的对象进入老年代
- 为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。
-XX:MaxTenuringThreshold
用来定义年龄的阈值。
- 动态对象年龄判定
- 虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
- 空间分配担保
- 在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
- 如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。
- 对象优先在 Eden 分配
- 垃圾回收分类
- Minor GC
- 回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
- Minor GC 的触发条件
- 当 Eden 空间满时,就将触发一次 Minor GC。
- 正式 Minor GC 前的检查
- 在正式 Minor GC 前,JVM 会先检查新生代中对象,是比老年代中剩余空间大还是小。
- 为什么要做这样的检查呢?
- 原因很简单,假如 Minor GC 之后 Survivor 区放不下剩余对象,这些对象就要进入到老年代,所以要提前检查老年代是不是够用。这样就有两种情况:
- 老年代剩余空间大于新生代中的对象大小,那就直接 Minor GC,GC 完 survivor 不够放,老年代也绝对够放
- 老年代剩余空间小于新生代中的对象大小,这个时候就要查看是否启用了「老年代空间分配担保规则」,具体来说就是看
-XX:-HandlePromotionFailure
参数是否设置了(一般都会设置)- 老年代空间分配担保规则是这样的。如果老年代中剩余空间大小,大于历次 Minor GC 之后剩余对象的大小,那就允许进行 Minor GC。因为从概率上来说,以前的放的下,这次的也应该放的下。那就有两种情况:
- 老年代中剩余空间大小,大于历次 Minor GC 之后剩余对象的大小,进行 Minor GC
- 老年代中剩余空间大小,小于历次 Minor GC 之后剩余对象的大小,进行 Full GC,把老年代空出来再检查
- 老年代空间分配担保规则是这样的。如果老年代中剩余空间大小,大于历次 Minor GC 之后剩余对象的大小,那就允许进行 Minor GC。因为从概率上来说,以前的放的下,这次的也应该放的下。那就有两种情况:
- 原因很简单,假如 Minor GC 之后 Survivor 区放不下剩余对象,这些对象就要进入到老年代,所以要提前检查老年代是不是够用。这样就有两种情况:
- 为什么要做这样的检查呢?
- 在正式 Minor GC 前,JVM 会先检查新生代中对象,是比老年代中剩余空间大还是小。
- Minor GC 后的处境
- 前面说了,开启老年代空间分配担保规则只能说是大概率上来说,Minor GC 剩余后的对象够放到老年代,所以当然也会有万一,Minor GC 后会有这样三种情况:
- Minor GC 之后的对象足够放到 Survivor 区,皆大欢喜,GC 结束
- Minor GC 之后的对象不够放到 Survivor 区,接着进入到老年代,老年代能放下,那也可以,GC 结束
- Minor GC 之后的对象不够放到 Survivor 区,老年代也放不下,那就只能 Full GC
- 前面说了,开启老年代空间分配担保规则只能说是大概率上来说,Minor GC 剩余后的对象够放到老年代,所以当然也会有万一,Minor GC 后会有这样三种情况:
- 实在不行只能 OOM
- 前面都是成功 GC 的例子,还有 3 种情况,会导致 GC 失败,报 OOM:
- 紧接上一节 Full GC 之后,老年代任然放不下剩余对象,就只能 OOM
- 未开启老年代分配担保机制,且一次 Full GC 后,老年代任然放不下剩余对象,也只能 OOM
- 开启老年代分配担保机制,但是担保不通过,一次 Full GC 后,老年代任然放不下剩余对象,也是能 OOM
- Full GC
- 回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。
- Full GC 的触发条件
- 调用
System.gc()
- 只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
- 老年代空间不足
- 老年代空间不足的常见场景为大对象直接进入老年代、长期存活的对象进入老年代等。
- 为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过
-XX:MaxTenuringThreshold
调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
- 空间分配担保失败
- 使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。
- JDK 1.7 及以前的永久代空间不足
- 在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。
- 当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。
- 为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。
- Concurrent Mode Failure
- 执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。
- 调用
- Minor GC
- 内存分配策略
相关问题
- 为何 Java 代码越执行越快?
- JIT 编译优化
- TLAB 预热