JVM深度解析:实用指南

发表时间: 2023-10-13 09:33

JVM(Java Virtual Machine),包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收、堆和一个存储方法域。是运行在操作系统之上的,它与硬件没有直接交互。JVM包含两个子系统和两个组件,两个子系统为Class loader(类装载)、Execution engine(执行引擎);两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)。

  • Class loader:根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime data area中的Method Area
  • Execution engine:执行classes中的指令。
  • Native Interface:与native libraries交互,是其它编程语言交互的接口。
  • Runtime data area:JVM内存。

Java代码运行及JVM

Java 源代码经过 Java 编译器编译成字节码后,通过类加载器加载到内存中,才能被实例化,然后到 Java 虚拟机中解释执行,最后通过操作系统操作 CPU 执行获取结果。具体的 javac 编译和类加载器如下图:

类的加载是指把类的 .class 文件中的二进制数据读入内存中,把它存放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class 对象,用来封装类在方法区内的数据结构。

线程指程序执行过程中一个线程实体。JVM允许一个应用并发执行多个线程。HotSpot JVM中的java线程与原生操作系统线程有直接的映射关系。当线程本地存储、缓存区分配、同步对象、栈程序计数器等准备好以后,就会创建一个操作系统原生线程。java线程结束,原生线程随之被回收。操作系统负责调度所有线程,并把它们分配到任何可用的CPU上。当原生线程初始化完毕,就会调用java线程的run()方法。当线程结束时,会释放原生线程和Java线程的所有资源。

JVM 中运行着许多线程,有一部分是应用程序创建来执行代码逻辑的应用线程,剩下的就是 JVM 创建来执行一些后台任务的系统线程。主要的系统线程有:

  • Compile Threads:运行时将字节码编译为本地代码所使用的线程
  • GC Threads:包含所有和 GC 有关操作
  • Periodic Task Thread:JVM 周期性任务调度的线程,主要包含 JVM 内部的采样分析
  • Singal Dispatcher Thread:处理 OS 发来的信号
  • VM Thread:某些操作需要等待 JVM 到达 安全点(Safe Point) ,即堆区没有变化。比如:GC 操作、线程 Dump、线程挂起 这些操作都在 VM Thread 中进行。

按照线程类型来分,在 JVM 内部有两种线程:

  • 守护线程:通常是由虚拟机自己使用,比如 GC 线程。但是,Java程序也可以把它自己创建的任何线程标记为守护线程(public final void setDaemon(boolean on)来设置,但必须在start()方法之前调用)。
  • 非守护线程:main方法执行的线程,我们通常也称为用户线程。

只要有任何的非守护线程在运行,Java程序也会继续运行。当该程序中所有的非守护线程都终止时,虚拟机实例将自动退出(守护线程随 JVM 一同结束工作)。

守护线程中不适合进行IO、计算等操作,因为守护线程是在所有的非守护线程退出后结束,这样并不能判断守护线程是否完成了相应的操作,如果非守护线程退出后,还有大量的数据没来得及读写,这将造成很严重的后果。

内联逃逸分析是对 Java 很重要的优化算法。

内联(Inlining)

内联优化是 Java JIT 编译器非常重要的一种优化策略。简单地说,内联就是把被调用的方法的方法体,在调用的地方展开。这样做最大的好处,就是省去了函数调用的开销。对于频繁调用的函数,内联优化能大大提高程序的性能。执行内联优化是有一定条件的。第一,被内联的方法要是热点方法;第二,被内联的方法不能太大,否则编译后的目标代码量就会膨胀得比较厉害。

执行引擎&JIT&逃逸分析

如何理解Java是半编译半解释型语言?

1. javac编译,java执行;2. 字节码解释器解释执行,模板解释器编译执行。

  • 字节码解释器:将Java字节码解释称C++代码(java代码->java字节码->C++代码),再编译成底层的硬编码给CPU执行。性能较低,早期JDK只有字节码解释器。(解释执行,跟即时编译器无关

字节码解释器的通用结构:在循环中先取每个字节码指令的code,switch case判断,解释成对应的C++代码执行。

  • 模板解释器:(触发即时编译)将Java字节码直接编译成硬编码。是JIT的一部分。

模板解释器下次执行时不会再做字节码解释器的解释过程,直接执行(即时编译器编译好的)硬编码。(所以运行效率比字节码解释器高

模板解释器的底层实现:

1) 申请一块(可读可写可执行的)内存;//JIT在Mac上无法运行,因为Mac出于安全性禁止了申请可执行的内存块的API。

2) (参照字节码解释器)将处理该部分(new方法)字节码的硬编码拿过来

3) 将硬编码写入申请的内存

4) 申请一个函数指针,指向这块内存

5) 调用时,直接通过函数指针调用

运行模式

根据底层使用不同的解释器,JVM有3种运行模式:解释模式、编译模式、混合模式。

1)-Xint纯字节码解释器;2)-Xcomp纯模板解释器;3)-Xmixed字节码解释器+模板解释器(JVM默认模式

解释模式:-Xint 纯字节码解释器

①不经过JIT直接由解释器Interpreter解释执行所有字节码。

②特点:启动快(不需要编译),执行慢。

③可通过-Xint参数指定为纯解释模式。

编译模式:-Xcomp 纯模板解释器

①不加筛选的将全部代码编译成机器码,不考虑其执行的频率是否有编译的价值。

②特点:启动慢(编译过程较慢),执行快。

③可通过-Xcomp参数指定为纯编译模式。

混合模式:-Xmixed 字节码解释器+模板解释器

为什么JVM默认采用-Xmixed模式而不是-Xcomp?

  • Xcomp会先将程序全部编译生成硬编码再运行。如果程序比较大,初次运行时会需要很长时间编译;
  • Xmixed会随着程序运行收集数据来做更深层次的优化。

性能比较: -Xmixed ≈ -Xcomp > -Xint

-Xmixed和-Xcomp的性能比较与具体程序大小有关。因为-Xcomp是将全部代码编译以后才执行。

①如果程序很小,-Xcomp性能较高,因为初期编译时间短;

②如果程序较大,-Xmixed性能较高,因为可以马上执行程序,运行一段时间以后触发即时编译后再将热点代码缓存起来。

JIT

JIT(Just In Time),当代码执行次数超过一定的阈值时,会将java字节码转换为本地代码,如主要的热点代码会被转换为本地代码,这样有利于大幅度提高java应用的性能。即时编译器生成热点代码供模板解释器运行。

JIT编译器称为后端编译器,与javac编译器不同。 因为javac编译的是java文件,JIT编译的是字节码文件。

现在有4种即时编译器:C1、C2、混合编译、GraalVM(jdk14后)。

1) C1编译器: client模式下的即时编译器。

可通过命令java -version查看当前jvm处于client还是server模式。64位机只有server模式。32位机可通过java -client -version指定为client模式。

①触发条件相对C2较宽松--需要收集的数据较少;

②编译优化较浅(e.g. 基本运算在编译时运算掉;e.g. String+final String的编译优化);

③生成的代码执行效率较C2低;

2) C2编译器: server模式下的即时编译器。

①触发条件较严格 -- 一般来说程序运行了一段时间后才会触发;

②优化比较深(e.g. 会分析程序中有无堆栈操作,将不需要的堆栈操作优化掉);

③生成的代码执行效率较C1高。

C2编译优化e.g. 删去多余堆栈操作编码,程序也能正常运行。

3) 混合编译: 程序运行初期触发C1编译器,运行一段时间后触发C2编译器。

即时编译的最小单位是代码块(e.g. for/while循环),最大单位是方法。即时编译的触发条件:底层判断循环/方法执行次数达到N次就会触发即时编译;Client模式下,N默认值为1500;Server模式下,N默认值为10000。

java -XX:+PrintFlagsFinal -version | grep CompileThreshold 可查看触发条件CompileThreshold的N值。

热度衰减:已执行过若干次数过后如果有一段时间没有执行,已执行次数会倍速减少,未来要达到即时编译条件需要更多执行次数。E.g. 某方法已被调用7000次,本应再调用3001次就会触发即时编译,但没调用后次数会两倍速,减少到3500次时要达到触发条件需要再执行6501次。

e.g. 阿里早年的一个故障:热机切冷机故障

因为业务增加需要在集群里加节点(用于均衡负载),涉及到热机切换冷机。出现问题:将流量/压力切换到冷机上时冷机马上挂掉。冷机:刚运行程序不久; 热机:运行程序有一段时间。

热点代码:编译好的硬编码。(从机器的角度叫硬编码,在JVM中称为热点代码)

原因:1) 热机上有热点代码缓存,扛的并发更大。马上切换到冷机上时,冷机上没有热点代码缓存,并发达不到;2) 冷机上程序一边运行一边触发即时编译,CPU扛不住。

解决方案:先切少量流量到冷机上,等冷机上程序运行一段时间触发即时编译,再逐渐切换流量。

热点代码缓存区Codecache,存放即时编译生成的热点代码,位于方法区(所以基本不会出现OOM)。

源码

/openjdk/hotspot/src/share/vm/code/codeCache.hpp

命令 java -XX:+PrintFlagsFinal -version | grep CodeCache 查看热点代码缓存区大小。

initialCodeCacheSize: 初始大小;ReservedCodeCacheSize: 最大大小,这两个值也是需要调优的地方。(调优参数:initialCodeCacheSize和ReservedCodeCacheSize,调优时一般调为一样大。)

Server编译器模式下热点代码缓存大小起始于2496KB,Client编译器模式下热点代码缓存大小起始于160KB。对于较长时间没被调用的热点代码,Codecache会按照LRU自动清理

即时编译的线程调优,可通过参数-XX:CICompilerCount=N调优。

java -XX:+PrintFlagsFinal -version | grep CICompilerCount查看CICompilerCount为即时编译线程数量。

即时编译器运行类似队列,JVM中很多系统性的操作(e.g. GC,即时编译)都是通过VM_THREAD(JVM的系统线程)触发的。

当某个代码块执行N次达到触发即时编译的条件时会经历以下步骤:(System.gc的调用同理)

①执行引擎将这个即时编译任务写入队列QUEUE;

②VM_THREAD从这个队列中读取任务execution并运行;(所以即时编译是异步的)

AOT(ahead of time):是提前把代码编译成机器码的一种编译技术。这样直接颠覆了Java正常的编译过程,而是首次编译时即把Java代码编译成机器码,跳过了字节码这个中间环节,可想而知,当程序运行时,直接运行机器码性能要提高很多,但这样的做法直接跳过了字节码,显然是丢失了一些通用性。静态编译非常慢,启动非常快,在静态编译期间将源代码转换成和具体平台相关的本地代码。

JIT(just in time):编译技术是在通常的编译过程之上做了增强,JVM会根据运行过程中代码执行的热点情况,把一些热点代码提前编译成机器码,等下次执行这些热点代码的时候,就不用实时编译成机器码了,而是直接运行机器码即可,这样就提高了Java的运行速度。静态编译一般很快,静态编译和JIT没多大关系,启动慢,热点代码在运行期间动态转换成和具体平台相关的本地代码。(大多数Java应用程序越跑越快)

逃逸分析

对象一定分配在堆中吗? 不一定的。

随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。其实,在编译期间,JIT 会对代码做很多优化。其中有一部分优化的目的就是减少内存堆分配压力,其中一种重要的技术叫做逃逸分析。

逃逸分析是指分析指针动态范围的方法,它同编译器优化原理的指针分析和外形分析相关联。当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。

通俗点讲,当一个对象被 new 出来之后,它可能被外部所调用,如果是作为参数传递到外部了,就称之为方法逃逸。除此之外,如果对象还有可能被外部线程访问到,例如赋值给可以在其它线程中访问的实例变量,这种就被称为线程逃逸

逃逸是一种现象,分析是一种技术手段。逃逸:如果对象的作用域是局部的(仅创建线程可见)则不是逃逸,其它(外部线程可见)都是逃逸。E.g. 共享变量、返回值、参数、static变量。可理解为逃逸到方法外/线程外。

public class EscapeAnalysis {  public static Object globalVariableObject;   public Object instanceObject;  public void globalVariableEscape() {     globalVariableObject = new Object();   } // 静态变量,逃逸  public void instanceObjectEscape() {     instanceObject = new Object();   } // 共享变量,逃逸  public Object returnObjectEscape() {     return new Object();   } // 返回值,逃逸  public void noEscape1() {    synchronized(new Object()) { //不是逃逸        System.out.println(“hello”);    }  }  public void noEscape2() {     Object noEscape = new Object();   } //局部变量,不是逃逸  public static void main(String[] args) {    EscapeAnalysis analysis = new EscapeAnalysis();      …  }}

基于逃逸分析JVM开发了三种优化技术:标量替换、锁消除、栈上分配。

开发优化技术原因:如果对象发生了逃逸,情况就会变得非常复杂(外部可能对该对象进行改变、重新赋值等),优化无法实施。所以这些优化措施都是在逃逸分析的基础(确定对象没有发生逃逸)上进行的。

从JDK1.7开始默认是开启逃逸分析的。 调节参数:-XX:+/-DoEscapeAnalysis;+开启,-关闭。

标量替换

标量:不可再分;e.g. java中的基本数据类型;聚合量:可再分;e.g. 对象

/* Position中的xyz为标量,在外部不会被修改。position对象没有发生逃逸。  JVM逃逸分析后会将test()中的标量position.x, position.y, position.z直接替换为1、2、3,提高程序效率。 */public class ScalarReplace {  …  public static void test() {    Position position = new Position(1, 2, 3);    System.out.println(position.x); //标量替换后:System.out.println(1);    System.out.println(position.y); //标量替换后:System.out.println(2);    System.out.println(position.z); //标量替换后:System.out.println(3);  }  class Position {    int x;    int y;    int z;    public Position(int x, int y, int z) {      this.x = x;      this.y = y;      this.z = z;    }  }}

锁消除: 逃逸分析发现上锁的对象为局部变量,将锁消除来优化。

public void noEscape1() {  synchronized (new Object()) {       System.out.println(“hello”);  }}//----优化后----public void noEscape1() {  System.out.println(“hello”);}

栈上分配: 逃逸分析如果是开启的,有些对象会在虚拟机栈上分配(相对传统观念中对象在堆区分配)

如何证明栈上分配存在?(在不发生GC的前提下)生成一个对象100w次,然后看堆区是否有100w个该对象,如果没有则说明存在栈上分配。

public class StackAlloc {  public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {    long start = System.currentTimeMillis();    for (int i = 0; i < 1000000; i++)      alloc();    long end = System.currentTimeMillis();    System.out.println((end-start)+” ms”);    while (true);  }  public static void alloc() { StackAlloc obj = new StackAlloc(); }}

类装载子系统

类加载器分为三个过程,Loading,Linking,Initializing。

Loading

载入(loading):通过IO读取字节码文件(class文件)至JVM虚拟机方法区,同时在堆中创建class对象。

所有Java虚拟机实现必须在每个类或接口首次主动使用时初始化。以下六种情况符合主动使用的要求:

  • 当创建某个类的新实例时(new、反射、克隆、序列化)
  • 调用某个类的静态方法
  • 使用某个类或接口的静态字段,或对该字段赋值(用final修饰的静态字段除外,它被初始化为一个编译时常量表达式)
  • 当调用Java API的某些反射方法时。
  • 初始化某个类的子类时。
  • 当虚拟机启动时被标明为启动类的类。

除以上六种情况,所有其他使用Java类型的方式都是被动的,它们不会导致Java类型的初始化。

对于接口来说,只有在某个接口声明的非常量字段被使用时,该接口才会初始化,而不会因为事先这个接口的子接口或类要初始化而被初始化。

父类需要在子类初始化之前被初始化。当实现了接口的类被初始化的时候,不需要初始化父接口。然而,当实现了父接口的子类(或者是扩展了父接口的子接口)被装载时,父接口也要被装载。(只是被装载,没有初始化)。

类加载器:

  • 启动类加载器(Bootstrap ClassLoader):加载JVM 运行时核心类【$JAVA_HOME/jre/lib/rt.jar】比如 java.util.、java.io.、java.nio.、java.lang。由 C++ 代码实现,称之为「根加载器」。
  • 扩展类加载器(Extension ClassLoader):加载JVM 扩展类【$JAVA_HOME/jre/lib/ext/*.jar或-Djava.ext.dirs指定目录下的jar】比如 swing 系列、内置的 js 引擎、xml 解析器等。
  • 应用程序类加载器(Application ClassLoader):【加载classpath上指定的jar包及-Djava.class.path所指定目录下的类和jar包】可以由 ClassLoader 类提供的静态方法 getSystemClassLoader 得到。
  • URLClassLoader:加载远程类库,还可以加载本地路径的类库,ExtensionClassLoader 和 AppClassLoader 都是 URLClassLoader 的子类,它们都是从本地文件系统里加载类库。

JVM至少有 3 种类加载器,类加载器通过全盘负责双亲委托机制来协调类加载器。

  • 全盘负责:指当一个 ClassLoader 装载一个类的时,除非显式地使用另一个 ClassLoader ,该类所依赖及引用的类也由这个 ClassLoader 载入。
  • 双亲委托机制:指先委托父装载器寻找目标类,只有在找不到的情况下才从自己的类路径中查找并装载目标类。

全盘负责双亲委托机制只是 Java 推荐的机制,并不是强制的机制。实现自己的类加载器时,如果想保持双亲委派模型,就应该重写 findClass(name) 方法;如果想破坏双亲委派模型:1.重写ClassLoad类的 loadClass(name) 方法。2.手动调用系统类加载器Thread.currentThread().getContextClassLoader()。3.重写findClass。

双亲委派指 AppClassLoader 在加载一个未知的类名时,它并不是立即去搜寻 Classpath,它会首先将这个类名称交给 ExtensionClassLoader 来加载 ExtensionClassLoader 在加载一个未知的类名时,它也并不是立即搜寻 ext 路径,它会首先将类名称交给 BootstrapClassLoader 来加载,每个 ClassLoader 对象内部都会有一个 parent 属性指向它的父加载器,BootstrapClassLoader 的 parent 的值是 ,当 parent 字段是 时就表示它的父加载器是「根加载器」。即除了顶层的启动类加载器以外,其余的类加载器,在加载之前,都会委派给它的父加载器进行加载。这样一层层向上传递,直到祖先们都无法胜任,它才会真正的加载。

优点:

  • 避免类的重复加载。相同的类被不同的类加载器加载会产生不同的类,双亲委派保证了 java 程序的稳定运行,防止程序混乱。
  • 保证核心 API 不被修改。

双亲委派机制在历史上主要有三次破坏:

虽然双亲委派有这些好处,但是在一些场景下是无法加载我们所需要的类。例如:

1、JDBC,JNDI 等实现了服务提供者接口(SPI 【Service Provider Interface】)提供的接口,这些接口属于 Java 核心库,一般存在rt.jar包中,由Bootstrap ClassLoader类加载器加载。而Bootstrap ClassLoader类加载器无法直接加载SPI的实现类,所以需要反向委派给其他类加载器进行加载。

2、Tomcat 服务器中,为了保证每个web项目互相独立,所以不能都由Application ClassLoader加载,所以自定义了类加载器WebappClassLoader,WebappClassLoader继承自URLClassLoader,重写了findClass和loadClass,并且WebappClassLoader的父类加载器设置为Application ClassLoader。



WebappClassLoader.loadClass中会先在缓存中查看类是否加载过,没有加载,就交给ExtClassLoader,ExtClassLoader再交给BootstrapClassLoader加载;都加载不了,才自己加载;自己也加载不了,就遵循原始的双亲委派,交由Application ClassLoader递归加载,所以需要打破双亲委派模型,加载多个同名类,才能部署多个web项目。

Tomcat 破坏了双亲委派原则,提供隔离的机制,为每个 web 容器单独提供一个 WebAppClassLoader 加载器。每一个 WebAppClassLoader 负责加载本身的目录下的 class 文件,加载不到时再交 CommonClassLoader 加载,这和双亲委派刚好相反。

通过Tomcat打破双亲委派模型,我们看到,WebappClassLoader继承自URLClassLoader,重写了findClass和loadClass。因此我们可以继承URLClassLoader也可以继承ClassLoader自定义类加载器如下:

import java.io.ByteArrayOutputStream;import java.io.File;import java.io.FileInputStream;import java.io.FileNotFoundException;import java.io.IOException;/** * 自定义类加载器 */public class MyClassLoader extends ClassLoader{  public MyClassLoader(ClassLoader parent) {    super(parent);  }  @Override  protected Class<?> findClass(String name) throws ClassNotFoundException {    // 1、获取class文件二进制字节数组    byte[] data = null;    try {      String classFile = "Test.class";      ByteArrayOutputStream baos = new ByteArrayOutputStream();      FileInputStream fis = new FileInputStream(new File(classFile));      byte[] bytes = new byte[1024];      int len = 0;      while ((len = fis.read(bytes)) != -1) {        baos.write(bytes, 0, len);      }      data = baos.toByteArray();    } catch (FileNotFoundException e) {      e.printStackTrace();    } catch (IOException e) {      e.printStackTrace();    }    // 2、字节码加载到 JVM 的方法区,    // 并在 JVM 的堆区建立一个java.lang.Class对象的实例    // 用来封装 Java 类相关的数据和方法    return this.defineClass(name, data, 0, data.length);  }  @Override  public Class<?> loadClass(String name) throws ClassNotFoundException{    Class<?> clazz = null;    // 直接自己加载,打破双亲委派    clazz = this.findClass(name);    if (clazz != null) {      return clazz;    }    // 自己加载不了,再调用父类loadClass,保持双亲委托模式    return super.loadClass(name);  }}

看上面的自定义类加载器,我们可以:

1、保护框架: 开发一个框架,用自定义类加载器,利用双亲委派模型,保护自己框架需要加载的类不被应用程序覆盖。

2、加密: 把自己的代码进行加密以防止反编译,可以先将编译后的代码用某种加密算法加密,类加密后就不能再用Java的ClassLoader去加载类了,这时就需要自定义ClassLoader在加载类的时候先解密类,然后再加载。

3、加载其他来源的类: 字节码是放在数据库、OSS、云服务等,就可以自定义类加载器,从指定的来源加载类。

Linking

Linking又分为Verification验证、Preparation准备、Resolution解析三个过程。

验证就是确保Class文件字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身安全。包括文件格式验证、元数据验证、字节码验证和符号引用验证。

准备负责为类的类变量(被static修饰的变量)分配内存,并设置默认初始化值。

解析动态地将运行时常量池中的符号引用转变为直接引用。

Initializing

这个阶段主要是对类变量初始化,是执行类构造器的过程,到此才是真正开始执行Java代码。在准备阶段,已经为类变量分配内存,并赋值了默认值。在初始阶段,则可以根据需要来赋值了。可以说,初始化阶段是执行类构造器 < clinit > 方法的过程。换句话说,只对static修饰的变量或语句进行初始化。

如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。

如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。

说明:类构造器 < clinit > 方法实例构造器 < init > 方法有什么区别。< clinit > 方法是在类加载的初始化阶段执行,是对静态变量、静态代码块进行的初始化。而< init > 方法是new一个对象,即调用类的 constructor方法时才会执行,是对非静态变量进行的初始化。

类构造器方法有如下特点:

第一、保证父类的 < clinit > 方法执行完毕,再执行子类的 < clinit > 方法。所以父类的静态代码块也优于子类执行。

第二、类中没有静态代码块,也没有为变量赋值,则可以不生成 < clinit > 方法。并非所有的类都需要在它们的class文件中拥有<clinit>()方法,若类没有声明任何类变量,也没有静态初始化语句,则不会有<clinit>()方法。若类声明了类变量,但没有明确的使用类变量初始化语句或者静态代码块来初始化它们,也不会有<clinit>()方法。如果类仅包含静态final常量的类变量初始化语句,而且这些类变量初始化语句采用编译时常量表达式,类也不会有<clinit>()方法。只有那些需要执行Java代码来赋值的类才会有<clinit>()。

第三、执行接口的 < clinit > 方法时,不需要先执行父接口的 < clinit > 方法。只有父接口中定义的变量使用时,父接口才会初始化。

第四、接口的实现类在初始化时也不执行接口的 < clinit > 方法。虚拟机会保证在多线程环境下 < clinit > 方法能被正确的加锁、同步。如果有多个线程同时请求加载一个类,那么只会有一个线程去执行这个类的 < clinit > 方法,其他线程都会阻塞,直到方法执行完毕。同时,其他线程也不会再去执行 < clinit > 方法了。这就保证了同一个类加载器下,一个类只会初始化一次。

第五、类的初始化时机:只有对类主动使用的时候才会触发初始化,主动使用的场景如下:使用new关键词创建对象时,访问某个类的静态变量或给静态变量赋值时,调用类的静态方法时。反射调用时,会触发类的初始化(如Class.forName())初始化一个类的时候,如其父类未初始化,则会先触发父类的初始化。虚拟机启动时,会先初始化主类(即包含main方法的类)。

有些场景并不会触发类的初始化:通过子类调用父类的静态变量,只会触发父类的初始化,而不会触发子类的初始化(因为,对于静态变量,只有直接定义这个变量的类才会初始化)。通过数组来创建对象不会触发此类的初始化。(如定义一个自定义的Person[] 数组,不会触发Person类的初始化)通过调用静态常量(即static final修饰的变量),并不会触发此类的初始化。因为,在编译阶段,就已经把final修饰的变量放到常量池中了,本质上并没有直接引用到定义常量的类,因此不会触发类的初始化。

所有的类变量(即静态量)初始化语句和类型的静态初始化器都被Java编译器收集在一起,放到一个特殊的方法中。对于类来说,这个方法被称作类初始化方法;对于接口来说,它被称为接口初始化方法。在类和接口的 class 文件中,这个方法被称为<clinit>。

1、如果存在直接父类,且直接父类没有被初始化,先初始化直接父类。

2、如果类存在一个类初始化方法,执行此方法。

这个步骤是递归执行的,即第一个初始化的类一定是Object。

Java虚拟机必须确保初始化过程被正确地同步。如果多个线程需要初始化一个类,仅仅允许一个线程来进行初始化,其他线程需等待。

内存区域

简单的说分别是堆区(Java Heap)、虚拟机栈(Virtual Machine Stacks)、本地方法栈(Native Method Stacks)、方法区(Method Area)或元空间(Meta Spaces)、程序计数器(Program Counter Register)。

JVM内存区域主要分为线程私有区域:【虚拟机栈、本地方法栈和程序计数器】;线程共享区域:【堆区和方法区】、直接内存(Direct Memory);JDK 1.8 之前叫方法区(永久代),之后用元空间替代

线程私有区域生命周期与线程相同,依赖用户线程的启动/结束而创建/销毁(HotSpot JVM内);线程共享区域随虚拟机的启动/关闭而创建/销毁。

程序计数器(私有)

①占用的JVM内存空间较小;②每个线程生命周期内独享自己的程序计数器(内部存放的是字节码指令的地址的引用);③唯一一个无OOM区域。

如果线程正在执行的是 Java 方法,这个计数器记录的是正在执行虚拟机字节码指令的地址;如果正在执行的是 Native 方法,计数器的值则为空(undefined)。

程序计数器主要有两个作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

虚拟机栈(私有)

①是线程私有的内存空间,和Java线程的生命周期相同。当创建一个线程时,会在虚拟机栈中申请一个线程栈,用于存储局部变量表,操作数栈,动态链接,方法返回地址等信息,并参与方法的调用和返回。其内部结构是栈帧,每个方法在执行的时候都会创建一个栈帧(Stack Frame)。

②每个方法的调用和返回对应栈帧的入栈和出栈操作,方法入栈的顺序和方法的调用顺序是一致的。某方法在调用另一个方法是通过动态链接在常量池中查询方法的引用,进而完成方法调用。

是一个先进后出(FILO-First In Last Out)的有序列表。在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧。正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。栈由一个个栈帧组成,和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。

局部变量表(Loacl Variables)是存放方法参数和局部变量的区域。类属性变量一共要经历两个阶段,分为准备阶段初始化阶段,而局部变量没有准备阶段,必须显式初始化。如果是非静态方法,则在index[0]位置上存储的是方法所属对象的实例引用,随后存储的是参数局部变量。字节码指令中的STORE指令就是将操作栈中计算完成的局部变量写回局部变量表的存储空间内 全局变量是放在堆的,有两次赋值的阶段,一次在类加载的准备阶段,赋予系统初始值;另外一次在类加载的初始化阶段,赋予代码定义的初始值。

局部变量表的容量以变量槽为最小单位,每个变量槽都可以存储32位长度的内存空间,例如boolean、byte、char、short、int、float、reference。对于64位长度的数据类型(long,double),虚拟机会以高位对齐方式为其分配两个连续的Slot空间,即相当于把一次long和double数据类型读写分割成为两次32位读写。

操作数栈(Operand Stack)是个初始状态为空的桶式结构栈(先入后出)。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。JVM的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈。字节码指令集的定义都是基于栈类型的。一个long和double类型的数据会占用两个单位的栈深度,其他数据类型则占用一个。

指令集的架构模型分为基于栈的指令集架构基于寄存器的指令集架构两种,JVM 中的指令集属于前者,也就是说任何操作都是用栈来管理,基于栈指令可以更好地实现跨平台,栈都是是在内存中分配的,而寄存器往往和硬件挂钩,不同的硬件架构是不一样的,不利于跨平台,当然基于栈的指令集架构缺点也很明显,基于栈的实现需要更多指令才能完成(因为栈只是一个FILO结构,需要频繁压栈出栈,它的大小在编译时确定),而寄存器是在CPU的高速缓存区,相较而言,基于栈的速度要慢不少指令=操作码+操作数。

动态链接:每个栈帧都包含一个指向运行时常量池中对当前方法的引用,目的是支持方法调用过程的动态连接。当前方法中如果需要调用其他方法的时候,能够从运行时常量池中找到对应的符号引用,然后将符号引用转换为直接引用,然后就能直接调用对应方法。

不是所有方法调用都需要动态链接的,有一部分符号引用会在类加载解析阶段将符号引用转换为直接引用,这部分操作称之为: 静态解析,就是编译期间就能确定调用的版本,包括: 调用静态方法,调用实例的私有构造器,私有方法,父类方法。

方法返回地址:方法执行时有两种退出情况:1、正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN 等;2、异常退出。无论正常或异常都算方法结束。

无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,而退出可能有三种方式:返回值压入上层调用栈帧。异常信息抛给能够处理的栈帧。PC 计数器指向方法调用后的下一条指令。

对于 JVM 中虚拟机栈参数的设置:-Xss :用于设置栈的大小,栈的大小决定了方法调用的深度。

# 设置线程栈大小为 512k(以字节为单位)-Xss512k(默认1M)

异常:1、若栈内存大小不允许动态扩展,当线程请求的栈深度大于JVM所允许的深度会出现 StackOverflowException 栈溢出异常。2、若Java虚拟机栈容量可以动态扩展,当动态扩展栈时无法申请到足够内存空间就会OOM。HotSpot虚拟机的栈容量是不可以动态扩展的

本地方法栈(私有)

①和虚拟机栈类似内部结构是栈帧,每个 Native 方法执行时创建一个栈帧;②该部分没有规定内存大小;异常情况和虚拟机栈一样。Java虚拟机栈是为Java方法服务,本地方法栈是为Native服务,可以认为是通过 JNI (Java Native Interface) 直接调用本地 C/C++ 库,不受 JVM 控制。 对于内存不足的情况,本地方法栈还是会抛出 native heap OutOfMemory

堆-运行时数据区(共享)

该内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析若某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存

堆和栈的区别:

堆的存取类型为管道类型,先进先出。在运行期动态分配,内存大小不固定,一般远大于栈,物理地址分配是不连续的,存取速度慢,在分配和销毁时都要占用时间,因此堆的效率低。String、Integer、Byte、Short、Long、Character、Boolean六大包装类型和对象的实例、数组存放于堆中。

栈的存取类型类似于水杯,先进后出。物理地址分配是连续的,存取速度比堆快,存在栈中的数据大小是在编译时就确定的。基础数据类型和自定义对象的引用存放在栈中。

队列和栈都是被用来预存数据的。队列的插入称入队,删除称出队。栈的插入称进栈,删除称出栈。队列是在队尾入队,队头出队即两边都可操作。而栈的进出栈都在栈顶进行,无法对栈底直接进行操作。队列是先进先出(FIFO),即队列的修改是依据先进先出原则进行的。新来的成员总是加入队尾(不能从中间插入),每次离开的成员总是在队列头上(不允许中途离队)。而栈是后进先出(LIFO),即每次删除的总是当前栈中最新的元素,即最后入栈的元素。最先插入的被放在栈底,要到最后才能删。

①存放 Java 对象和数组;②虚拟机中存储空间比较大的区域;③可能出现 OOM 异常区域

④该区域是 GC 的主要区域,由年轻代和老年代组成,年轻代又分为 Eden 区、S0区(from survivor)、S1 区(to survivor);新生代对应 Minor GC(Young GC),老年代对应 Full GC(Old GC)。

对象刚创建的时候,会被创建在新生代,到一定阶段之后会移送至老年代 ,如果创建了一个新生代无法容纳的新对象,那么这个新对象也可以创建到老年代。大部分的对象会在Eden区中生成,当Eden区没有足够的空间容纳新对象时,会触发Young Garbage Collection,即YGC。在Eden区进行垃圾清除时,它的策略是会把没有引用的对象直接给回收掉,还有引用的对象会被移送到Survivor区

每次进行YGC的时候,会将存活的对象复制到未使用的那块内存空间,然后将当前正在使用的空间完全清除掉,再交换两个空间的使用状况。如果YGC要移送的对象Survivor区无法容纳,那么就会将该对象直接移交给老年代。

每一个对象都有一个计数器,当每次进行YGC的时候,都会 +1。通过-XX:MAXTenuringThrehold参数可以配置当计数器的值到达某个阈值时,对象就会从新生代移送至老年代。该参数的默认值为15,也就是说对象在Survivor区中的S0和S1内存空间交换的次数累加到15次之后,就会移送至老年代。如果参数配置为1,那么创建的对象就会直接移送至老年代。

如果Survivor区无法放下,或者创建了一个超大新对象,Eden和Old区都无法存放,就会触发Full Garbage Collection,即FGG,便再尝试放在Old区,如果还是容纳不了,就会抛出OOM异常。在不同的JVM实现及不同的回收机制中,堆内存的划分方式是不一样的。

对于 JVM 中堆区参数的设置:

堆的内存空间是可以自定义大小的,同时也支持在运行时动态修改,通过 -Xms 、-Xmx 这两参数去改变堆的初始值和最大值。-X指的是JVM运行参数,ms 是memory start的简称,代表的是最小堆容量,mx是memory max的简称,代表的是最大堆容量;

由于堆的内存空间是可以动态调整的,所以在服务器运行的时候,请求流量的不确定性可能会导致我们堆的内存空间不断调整,会增加服务器的压力,所以生产环境Xms和Xmx设置的值必须一样,防止抖动,同样也为了避免在GC(垃圾回收)之后调整堆大小时带来的额外压力。 如果Xms小于Xmx,堆的大小不会直接扩展到上限,而是留着一部分等待内存需求不断增长时,再分配给新生代。Vritual空间便是这部分保留的内存区域。

# 设置堆区的初始大小-Xms1024m(默认情况下堆中可用内存小于40%,堆内存开始增加,直到-Xmx的大小)# 设置堆区的存储空间最大值,一般与堆区的初始大小相等-Xmx1024m(默认值是总内存/64(且小于1G)默认情况下堆中可用内存大于70%,堆内存开始减少,直到-Xms的大小)# 设置年轻代堆的大小-Xmn512m(Eden和Survivor总和)# 设置如下参数,在出现OOM时进行堆转储-XX:+HeapDumpOnOutOfMemoryError# 设置以上设置时,需配置以下参数,堆转储文件输出的位置-XX:HeapDumpPath=/usr/log/java_dump.hprof

方法区(永久代)(共享)

①方法区被所有线程共享。采用永久代或元空间的方式实现方法区。

②jdk 8 之前存在永久代(Perm区),jdk 8 及之后移除了永久代,用元空间代替,类的元信息被存储在元空间中,元空间没有使用堆内存,而是与堆不相连的本地内存区域。

方法区在不同 JDK 版本的变化:

方法区和元空间的区别:(永久代和堆使用的物理内存是连续的,如图

1.方法区概念

方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。在不同的虚拟机实现上,方法区的实现是不同的。当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

2.方法区和永久代以及元空间的关系

方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现便成为元空间。元空间使用的是直接内存

3.方法去常用参数

JDK 1.8 之前

-XX:PermSize=512M #方法区 (永久代) 初始大小

-XX:MaxPermSize=512m #方法区 (永久代) 最大值,超过此值将会抛出OOM异常

:java.lang.OutOfMemoryError: PermGen

JDK 1.8及之后

-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小),未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小,默认值为unlimited,它只受系统内存的限制。

4.永久代替换为元空间的原因

  • 整个永久代有一个 JVM 本身设置的固定大小上限(64M),无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。java.lang.OutOfMemoryError: MetaSpace
  • 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
  • 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)符号引用(Symbolic Reference)的 常量池表(Constant Pool Table) 。每个Class文件的头四个字节称为Magic Number,它的作用是确定这是否是一个可以被虚拟机接受的文件;接着的四个字节存储的是Class文件的版本号。紧挨着版本号之后的,就是常量池入口了。

常量池用于存放编译期生成的各种字面量和符号引用,它会在类加载后存放到运行时常量池中。

常量池的理解:

1、class 文件常量池也称为静态常量池,位于class文件中,而class文件又是位于磁盘上。

2、方法区运行时常量池(Class 文件中的常量池(编译器生成的字面量和符号引用)会在类加载后被放入这个区域),除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern()。)

3、字符串常量池(jdk6 及之前在方法区,jdk7 及之后移到堆)指的是字符串在创建时,先去“常量池”查找是否有此“字符串”,若有则不会开辟新空间创建字符串,直接把常量池中的引用返回给此对象。在字符串常量池移到堆后无需复制字符串副本,只会把首次遇到的字符串的引用添加到常量池中。此时只会判断常量池中是否已经有此字符串,若有就返回常量池中的字符串引用。

public static void main(String[] args) {  String s = new String("1");  s.intern();  String s2 = "1";  System.out.println(s == s2);   String s3 = new String("1") + new String("1");  s3.intern();  String s4 = "11";  System.out.println(s3 == s4);  // JDK6: false false;JDK7: false true}public static void main(String[] args) {  String s = new String("1");  String s2 = "1";  s.intern();  System.out.println(s == s2);  String s3 = new String("1") + new String("1");  String s4 = "11";  s3.intern();  System.out.println(s3 == s4);  // JDK6: false false;JDK7: false false}

jdk7 版本对 intern 操作和常量池都做了一定的修改。主要包括2点:

  • 将 String 常量池 从 Perm 区移动到了 Java Heap 区。
  • String#intern 方法时,如果存在堆中的对象,会直接保存对象的引用,而不会重新创建对象。

补充:

a.HotSpot 虚拟机中字符串常量池的实现是

src/hotspot/share/classfile/stringTable.cpp,StringTable 本质上就是一个HashSet<String>,容量为 StringTableSize(jdk6中StringTable是固定的,就是1009的长度,所以如果常量池中字符串过多就会导致效率下降很快。jdk7中其长度默认值是60013,可以通过 -XX:StringTableSize 参数来设置。jdk8中1009是可设置的最小值)。

StringTable 中保存的是字符串对象的引用,字符串对象的引用指向堆中的字符串对象。 b.字符串常量池本质是一个哈希表;c.字符串常量池被整个JVM共享;d.字符串常量池是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

JDK1.7 之前,运行时常量池包含的字符串常量池和静态变量存放在方法区,,此时 HotSpot 虚拟机对方法区的实现为永久代。

JDK1.7 字符串常量池和静态变量被从方法区拿到了堆中,这里没有提到运行时常量池,即字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区。(因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。)

JDK1.8 HotSpot 移除了永久代用元空间(Metaspace)取而代之, 此时字符串常量池和静态变量还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)。

运行时常量池、方法区、字符串常量池这些都是不随虚拟机实现而改变的逻辑概念,是公共且抽象的,Metaspace、Heap 是与具体某种虚拟机实现相关的物理概念,是私有且具体的。

直接内存(共享)

它并不是JVM运行时数据区的一部分,不受JVM GC管理,不受Java堆大小限制,但是受机器的物理内存限制,当各个内存区域大于机器物理内存的时候,会出现OutOfMemoryError。

JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

内存模型

内存模型(Java Memory Model,简称 JMM ):是一种抽象的模型,定义了线程和主内存之间的抽象关系,即 JMM 定义了 JVM 在计算机内存(RAM)中的工作方式。线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。

内存模型是为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。

内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。

JMM控制 Java 线程之间的通信,决定一个线程对共享变量的写入何时对另一个线程可见。

Java内存模型抽象图:

本地内存是JMM的 一个抽象概念,并不真实存在。它其实涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

图里面的是一个双核 CPU 系统架构 ,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辅运算。每个核都有自己的一级缓存,在有些架构里面还有一个所有 CPU 共享的二级缓存。 那么 Java 内存模型里面的工作内存,就对应这里的 Ll 缓存或者 L2 缓存或者 CPU 寄存器。

JMM八大同步规范

1、lock(锁定):作用于 主内存的变量,把一个变量标记为一条线程独占状态。

2、unlock(解锁):作用于 主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

3、read(读取):作用于 主内存的变量,把一个变量值从主内存传输到线程的 工作内存中,以便随后的load动作使用。

4、load(载入):作用于 工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

5、use(使用):作用于 工作内存的变量,把工作内存中的一个变量值传递给执行引擎。

6、assign(赋值):作用于 工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量。

7、store(存储):作用于 工作内存的变量,把工作内存中的一个变量的值传送到 主内存中,以便随后的write的操作。

8、write(写入):作用于 工作内存的变量,它把store操作从工作内存中的一个变量的值传送到 主内存的变量中。

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

1、不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write。

2、不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存。

3、不允许一个线程将没有assign的数据从工作内存同步回主内存。

4、一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过assign和load操作。

5、一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁。

6、如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值。

7、如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量。

8、对一个变量进行unlock操作之前,必须把此变量同步回主内存。

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

JVM 主内存与工作内存

主内存:存放我们共享变量的数据。

工作内存:每个CPU对共享变量(主内存)的副本。堆+方法区。

Java 内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

这里的工作内存是 JMM 的一个抽象概念,其存储了该线程以读 / 写共享变量的副本。

重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序:由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

happens-before

Java 内存模型提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

Happen-Before的规则有以下几条

程序次序规则(Program Order Rule):在一个线程内,程序的执行规则跟程序的书写规则是一致的,从上往下执行。

管程锁定规则(Monitor Lock Rule):一个Unlock的操作肯定先于下一次Lock的操作。这里必须是同一个锁。同理我们可以认为在synchronized同步同一个锁的时候,锁内先行执行的代码,对后续同步该锁的线程来说是完全可见的。

volatile变量规则(volatile Variable Rule):对同一个volatile的变量,先行发生的写操作,肯定早于后续发生的读操作。

线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。

线程中止规则(Thread Termination Rule):Thread对象的中止检测(如:Thread.join(),Thread.isAlive()等)操作,必须晚于线程中所有操作。

线程中断规则(Thread Interruption Rule):对线程的interruption()调用,先于被调用的线程检测中断事件(Thread.interrupted())的发生。

对象中止规则(Finalizer Rule):一个对象的初始化方法先于一个方法执行Finalizer()方法。

传递性(Transitivity):如果操作A先于操作B、操作B先于操作C,则操作A先于操作C。

Java内存模型的实现

在Java中提供了一系列和并发处理相关的关键字,比如 volatile、synchronized、final、JUC 包等。其实这些就是 Java 内存模型封装了底层的实现后提供给程序员使用的一些关键字。

原子性

为了保证原子性,提供了两个高级的字节码指令 monitorenter 和 monitorexit,这两个字节码,在Java中对应的关键字就是 synchronized。

可见性

Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

Java 中的 volatile 关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用 volatile 来保证多线程操作时变量的可见性。

除了 volatile,Java中的 synchronized 和 final 两个关键字也可以实现可见性。只不过实现方式不同。

有序性

在 Java 中,可以使用 synchronized 和 volatile 来保证多线程之间操作的有序性。实现方式有所区别:

  • volatile:关键字会禁止指令重排。
  • synchronized:关键字保证同一时刻只允许一条线程操作。

Java创建对象过程

ObjectA a = new ObjectA();

对象的创建

先在虚拟机栈创建栈帧,栈帧内创建对象的引用,在方法区进行类的加载,然后去 Java 堆区进行分配内存并内存初始化, 再回到栈帧中初始化对象的数据,完成对象的创建。见下图:

堆内存分配

根据Java堆是否规整,内存分配有两种方式:指针碰撞(Bump The Pointer)和空闲列表(Free List)。而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

指针碰撞法

支持压缩整理功能的垃圾回收器 Serial、ParNew 等(Compact 过程),使得已使用的内存和未使用的内存分开,两者之间存在一个指针作为分界点指示器。分配内存只需移动指针,分界点指示器向未使用的内存一侧移动一段与对象大小相等的空间,这种分配内存的方法叫做指针碰撞法。如下图所示:

空闲列表法

基于标记清除(Mark-Sweep)算法的 CMS 垃圾回收器,其内存划分成网格区(Region),内存分配不规整,即已使用的和未使用的内存随机分布,JVM 会维护一个记录表,用于记录那些内存可用于分配,当需要给对象分配内存区域时,寻找一块足够大的内存空间分配给对象,并更新记录表,这种分配内存的方法叫做空闲列表法。如下图所示:

JVM 中内存分配纷繁复杂,为了防止内存分配混乱,需要解决并发问题(JVM 里 new 对象时,堆会发生抢占),解决并发问题有两种方式:同步处理方式和 TLAB 方式(Thread Local Allocation Buffer)。

  • 同步处理:内存分配的动作采用同步机制,JVM 为了增加效率采用了 CAS 方式。

计算机科学中,比较和交换(Conmpare And Swap)是用于实现多线程同步的原子指令。它将内存位置的内容与给定值进行比较,只有在相同的情况下,将该内存位置的内容修改为新的给定值。这是作为单个原子操作完成的。

  • TLAB 方式:这个 TLAB 和 Java 中的 ThreadLocal 类有点像,每个线程独享线程本地变量。哪个线程需要分配内存先去各自的 TLAB 中分配,但是这个缓冲区比较小,是为了加速对象的分配。只有在线程的 TLAB 用完才会去堆中进行内存分配,此时才需要同步机制。通过 -XX:+/-UserTLAB 参数来设定虚拟机是否使用 TLAB。如下图所示:

对象的访问定位

Java 程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有句柄和直接指针两种方式。

指针:指向对象,代表一个对象在内存中的起始地址。 句柄:可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。

  • 句柄访问,见下图所示:

注:句柄池是 Java 堆分配用于存放对象指针的内存空间。

优点:引用中存储的是稳定的句柄地址,在对象被移动(垃圾回收时移动对象是非常普遍的行为)时只需改变句柄中指向对象实例数据的指针即可(不用修改 reference)。

  • 直接访问,见下图所示:

使用直接指针访问,引用中存储的直接就是对象地址

优点:相对于句柄访问定位的方式,减少了一次指针定位的开销(也减少了句柄池的存储空间),速度更快,HotSpot JVM 实现采用的是直接访问的方式进行对象访问定位。

对象的内存布局

HotSpot 中对象在堆内存中的存储布局可以分为三部分:对象头(Object Header)、实例数据(Instance Data)、对齐填充(Padding)。

数组类型对象普通对象的区别仅在于 4 字节数组长度的存储区间。Mark Word32位占4B,64位8B,Class Pointer和reference究竟是 4 字节还是 8 字节要看是否开启指针压缩(上图中64位这两值都写的8字节应该是关闭压缩指针的情况)。JDK 1.6开始在 64 位系统上会默认开启压缩指针,可对OOP(Ordinary Object Pointer,普通对象指针)进行压缩。如果要强行关闭指针压缩使用 -XX:-UseCompressedOops,强行启用指针压缩使用:-XX:+UseCompressedOops。

对象的存活

引用计数算法(reference counting)和可达性分析算法。

  • 引用计数算法

引用计数器的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

  • 可达性分析算法

目前 Java 虚拟机的主流垃圾回收器采取的是可达性分析算法。这个算法的实质在于将一系列 GC Roots 作为初始的存活对象合集(Gc Root Set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。

可以作为 GC Roots 的主要有四种对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI 引用的对象

内存溢出和内存泄漏

内存溢出就是申请的内存超过了可用内存,内存不够了。内存泄漏就是申请的内存空间没有被正确释放,导致内存被白白占用。两者关系:内存泄露可能会导致内存溢出。

  • Java 堆溢出
/** * VM参数: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError */public class HeapOOM {  static class OOMObject {  }  public static void main(String[] args) {    List<OOMObject> list = new ArrayList<OOMObject>();    while (true) {      list.add(new OOMObject());    }  }}
  • 虚拟机栈.OutOfMemoryError

JDK 使用的 HotSpot 虚拟机的栈内存大小是固定的,可以把栈的内存设大一点,然后不断地去创建线程,因为操作系统给每个进程分配的内存是有限的,所以到最后,也会发生 OutOfMemoryError 异常。

/** * vm参数:-Xss2M */public class JavaVMStackOOM {  private void dontStop() {    while (true) {    }  }  public void stackLeakByThread() {    while (true) {      Thread thread = new Thread(new Runnable() {        public void run() {          dontStop();        }      });      thread.start();    }  }  public static void main(String[] args) throws Throwable {    JavaVMStackOOM oom = new JavaVMStackOOM();    oom.stackLeakByThread();  }}

内存泄露

静态集合类引起内存泄漏

静态集合的生命周期和 JVM 一致,所以静态集合引用的对象不能被释放。

public class OOM {  static List list = new ArrayList();  public void oomTests(){    Object obj = new Object();    list.add(obj);  }}

单例模式

和上面的例子原理类似,单例对象在初始化后会以静态变量的方式在 JVM 的整个生命周期中存在。如果单例对象持有外部的引用,那么这个外部对象将不能被 GC 回收,导致内存泄漏。

数据连接、IO、Socket 等连接

创建的连接不使用时,需调用close方法关闭连接,只有连接被关闭后,GC才会回收对应的对象(Connection、Statement、ResultSet、Session)。忘记关闭这些资源会导致持续占有内存无法被GC回收。

变量不合理的作用域

一个变量的定义作用域大于其使用范围,很可能存在内存泄漏;或不再使用对象没有及时将对象设置为 null,很可能导致内存泄漏的发生。

public class Simple {  Object object;  public void method1(){    object = new Object();    //...其他代码    //由于作用域原因,method1执行完成之后,object 对象所分配的内存不会马上释放    object = null;  }}

hash 值发生变化

对象 Hash 值改变,使用 HashMap、HashSet 等容器中时候,由于对象修改之后的 Hah 值和存储进容器时的 Hash 值不同,所以无法找到存入的对象,自然也无法单独删除了,这也会造成内存泄漏。说句题外话,这也是为什么 String 类型被设置成了不可变类型。

ThreadLocal 使用不当

ThreadLocal 的弱引用导致内存泄漏也是个老生常谈的话题了,使用完 ThreadLocal 一定要记得使用 remove 方法来进行清除。

垃圾收集

概念

部分收集(Partial GC):指目标不是完整收集整个 Java 堆的垃圾收集,其中又分为:

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS 收集器会有单独收集老年代的行为。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有 G1 收集器会有这种行为。

整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾收集。

新创建的对象优先在新生代 Eden 区进行分配,如果 Eden 区没有足够的空间时,就会触发 Young GC 来清理新生代。

  • Young GC 之前检查老年代:在要进行 Young GC 的时候,发现老年代可用的连续内存空间 < 新生代历次Young GC后升入老年代的对象总和的平均大小,说明本次 Young GC 后可能升入老年代的对象大小,可能超过了老年代当前可用内存空间,那就会触发 Full GC。
  • Young GC 之后老年代空间不足:执行 Young GC 之后有一批对象需要放入老年代,此时老年代就是没有足够的内存空间存放这些对象了,此时必须立即触发一次 Full GC
  • 老年代空间不足,老年代内存使用率过高,达到一定比例,也会触发 Full GC。
  • 空间分配担保失败( Promotion Failure),新生代的 To 区放不下从 Eden 和 From 拷贝过来对象,或者新生代对象 GC 年龄到达阈值需要晋升这两种情况,老年代如果放不下的话都会触发 Full GC。
  • 方法区内存空间不足:如果方法区由永久代实现,永久代空间不足 Full GC。
  • System.gc()等命令触发:System.gc()、jmap -dump 等命令会触发 full gc。

长期存活的对象将进入老年代

在对象的对象头信息中存储着对象的迭代年龄,迭代年龄会在每次 YoungGC 之后对象的移区操作中增加,每一次移区年龄加一.当这个年龄达到 15(默认)之后,这个对象将会被移入老年代。可以通过这个参数设置这个年龄值。

-XX:MaxTenuringThreshold

大对象直接进入老年代

有一些占用大量连续内存空间的对象在被加载就会直接进入老年代,这样的大对象一般是一些数组,长字符串之类的对。HotSpot 虚拟机提供了这个参数来设置。

-XX:PretenureSizeThreshold

动态对象年龄判定

为了能更好地适应不同程序的内存状况,HotSpot 虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保

假如在 Young GC 之后,新生代仍然有大量对象存活,就需要老年代进行分配担保,把 Survivor 无法容纳的对象直接送入老年代。

GC 针对的 JVM 区域 从上面对 JVM 内存布局的介绍,发生 GC 主要是针对 Java Heap 区 和 元空间(或方法区)。其他区域都是线程私有的,即随着线程的创建而创建,随着线程的销毁而销毁。对于 JVM 中 GC 参数的设置:

# 在控制台输出GC情况-verbose:gc # GC日志输出-XX:+PrintGC# 打印堆的GC日志-XX:+PrintHeapAtGC # GC日志详细输出-XX:+PrintGCDetails# GC输出时间戳-XX:+PrintGCDateStamps# GC日志输出指定文件中-Xloggc:/log/gc.log

Java 语言的内存自动回收称为垃圾回收(Garbage Collection)机制,简称 GC。垃圾回收机制是指 JVM 用于释放那些不再使用的对象所占用的内存。

在 Java 的 Object 类中还提供了一个 protected 类型的 finalize() (JDK18废弃)方法,因此任何 Java 类都可以覆盖这个方法,在这个方法中进行释放对象所占有的相关资源的操作。

调用 System.gc() 或者 Runtime.gc() 方法也不能保证回收操作一定执行,它只是提高了 Java 垃圾回收器尽快回收垃圾的可能性。

Java 中的引用有四种,分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。

强引用:默认情况下,对象采用的均为强引用(这个对象的实例没有其它对象引用,GC 时才会被回收)。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。

软引用:是 Java 中提供的一种比较适合于缓存场景的应用(只有在内存不够的情况下才会被 GC)

弱引用:在 GC 时一定会被 GC 回收

虚引用:只是用来得知对象是否被 GC


被回收时间

用途

生存时间

强引用

从来不会

对象的一般状态

JVM停止运行时终止

软引用

内存不足

对象缓存

内存不足时终止

弱引用

垃圾回收

对象缓存

gc运行后终止

虚引用

unknown

unknown

unknown

算法

垃圾收集算法主要有三种:

1、标记-清除算法

见名知义,标记-清除(Mark-Sweep)算法分为两个阶段:

  • 标记 : 标记出所有需要回收的对象
  • 清除:回收所有被标记的对象

标记-清除算法比较基础,但是主要存在两个缺点:

  • 执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
  • 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

2、标记-复制算法

标记-复制算法解决了标记-清除算法面对大量可回收对象时执行效率低的问题。

过程也比较简单:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这种算法存在一个明显的缺点:一部分空间没有使用,存在空间的浪费。

新生代垃圾收集主要采用这种算法,因为新生代的存活对象比较少,每次复制的只是少量的存活对象。当然,实际新生代的收集不是按照这个比例。

3、标记-整理算法

为了降低内存的消耗,引入一种针对性的算法:标记-整理(Mark-Compact)算法。

其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

标记-整理算法主要用于老年代,移动存活对象是个极为负重的操作,而且这种操作需要 Stop The World 才能进行,只是从整体的吞吐量来考量,老年代使用标记-整理算法更加合适。

收集器

  • Serial 收集器

Serial 收集器是最基础、历史最悠久的收集器。

如同它的名字(串行),它是一个单线程工作的收集器,使用一个处理器或一条收集线程去完成垃圾收集工作。并且进行垃圾收集时,必须暂停其他所有工作线程,直到垃圾收集结束——这就是所谓的“Stop The World”。简称STW。

在 HotSpot 中,有个数据结构(映射表)称为OopMap。一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,记录到 OopMap。在即时编译过程中,也会在特定的位置生成 OopMap,记录下栈上和寄存器里哪些位置是引用。

这些特定的位置主要在:

  • 循环的末尾(非 counted 循环)
  • 方法临返回前 / 调用方法的 call 指令后
  • 可能抛异常的位置

这些位置就叫作安全点(safepoint)。 用户程序执行时并非在代码指令流的任意位置都能够在停顿下来开始垃圾收集,而是必须是执行到安全点才能够暂停。

Serial/Serial Old 收集器的运行过程如图:

  • ParNew

ParNew 收集器实质上是 Serial 收集器的多线程并行版本,使用多条线程进行垃圾收集。

ParNew/Serial Old 收集器运行示意图如下:

  • Parallel Scavenge

Parallel Scavenge 收集器是一款新生代收集器,基于标记-复制算法实现,也能够并行收集。和 ParNew 有些类似,但 Parallel Scavenge 主要关注的是垃圾收集的吞吐量——所谓吞吐量,就是 CPU 用于运行用户代码的时间和总消耗时间的比值,比值越大,说明垃圾收集的占比越小。特点:高吞吐量。

  • Serial Old

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

  • Parallel Old

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

  • CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,同样是老年代的收集器,采用标记-清除算法。特点:降低停顿时间。

CMS 收集齐的垃圾收集分为四步:

  • 初始标记(CMS initial mark):单线程运行,需要 Stop The World,标记 GC Roots 能直达的对象。
  • 并发标记((CMS concurrent mark):无停顿,和用户线程同时运行,从 GC Roots 直达对象开始遍历整个对象图。
  • 重新标记(CMS remark):多线程运行,需要 Stop The World,标记并发标记阶段产生对象。
  • 并发清除(CMS concurrent sweep):无停顿,和用户线程同时运行,清理掉标记阶段标记的死亡的对象。

Concurrent Mark Sweep 收集器运行示意图如下:

  • Garbage First 收集器

Garbage First(简称 G1)收集器是垃圾收集器的一个颠覆性的产物,它开创了局部收集的设计思路和基于 Region 的内存布局形式。

虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异。以前的收集器分代是划分新生代、老年代、持久代等。

G1 把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理。

这样就避免了收集整个堆,而是按照若干个 Region 集进行收集,同时维护一个优先级列表,跟踪各个 Region 回收的价值,优先收集价值高的 Region。

G1 收集器的运行过程大致可划分为以下四个步骤:

  • 初始标记(initial mark),标记了从 GC Root 开始直接关联可达的对象。STW(Stop the World)执行。
  • 并发标记(concurrent marking),和用户线程并发执行,从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象、
  • 最终标记(Remark),STW,标记再并发标记过程中产生的垃圾。
  • 筛选回收(Live Data Counting And Evacuation),制定回收计划,选择多个 Region 构成回收集,把回收集中 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。需要 STW。

优点:CMS 最主要的优点在名字上已经体现出来——并发收集、低停顿。

缺点:CMS 同样有三个明显的缺点。

  • Mark Sweep 算法会导致内存碎片比较多
  • CMS 的并发能力比较依赖于 CPU 资源,并发回收时垃圾收集线程可能会抢占用户线程的资源,导致用户程序性能下降。
  • 并发清除阶段,用户线程依然在运行,会产生所谓的理“浮动垃圾”(Floating Garbage),本次垃圾收集无法处理浮动垃圾,必须到下一次垃圾收集才能处理。如果浮动垃圾太多,会触发新的垃圾回收,导致性能降低。

G1 主要解决了内存碎片过多的问题。

java -XX:+PrintCommandLineFlags -version命令查看使用什么收集器。UseParallelGC = Parallel Scavenge + Parallel Old,表示的是新生代用的Parallel Scavenge收集器,老年代用的是Parallel Old 收集器。

收集器的适用场景:

  • Serial :如果应用程序有一个很小的内存空间(大约 100 MB)亦或它在没有停顿时间要求的单线程处理器上运行。
  • Parallel:如果优先考虑应用程序的峰值性能,并且没有时间要求要求,或者可以接受 1 秒或更长的停顿时间。
  • CMS/G1:如果响应时间比吞吐量优先级高,或者垃圾收集暂停必须保持在大约 1 秒以内。
  • ZGC:如果响应时间是高优先级的,或者堆空间比较大

Java分派机制

在Java中,符合“编译时可知,运行时不可变”这个要求的方法主要是静态方法和私有方法。这两种方法都不能通过继承或别的方法重写,因此它们适合在类加载时进行解析。

Java虚拟机中有四种方法调用指令:

  • invokestatic:调用静态方法。
  • invokespecial:调用实例构造器方法,私有方法和super。
  • invokeinterface:调用接口方法。
  • invokevirtual:调用以上指令不能调用的方法(虚方法)。

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有:静态方法、私有方法、实例构造器、父类方法,他们在类加载的时候就会把符号引用解析为改方法的直接引用。这些方法被称为非虚方法,反之其他方法称为虚方法(final方法除外)。

虽然final方法是使用invokevirtual 指令来调用的,但是由于它无法被覆盖,多态的选择是唯一的,所以是一种非虚方法。

静态分派

对于类字段的访问也是采用静态分派 People man = new Man()

静态分派主要针对重载,方法调用时如何选择。代码中,People被称为变量的引用类型,Man被称为变量的实际类型。静态类型是在编译时可知的,而动态类型是在运行时可知的,编译器不能知道一个变量的实际类型是什么。

编译器在重载时候通过参数的静态类型而不是实际类型作为判断依据。并且静态类型在编译时是可知的,所以编译器根据重载的参数的静态类型进行方法选择。

在某些情况下有多个重载,那编译器如何选择呢? 编译器会选择"最合适"的函数版本,那么怎么判断"最合适“呢?越接近传入参数的类型,越容易被调用。

动态分派

动态分派主要针对重写,使用invokevirtual指令调用。invokevirtual指令多态查找过程:

  • 找到操作数栈顶的第一个元素所指向的对象的实际类型,记为C。
  • 如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果权限校验不通过,返回java.lang.IllegalAccessError异常。
  • 否则,按照继承关系从下往上一次对C的各个父类进行第2步的搜索和验证过程。
  • 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError异常。

虚拟机动态分派的实现

由于动态分派是非常繁琐的动作,而且动态分派的方法版本选择需要考虑运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实现中基于性能的考虑,在方法区中建立一个虚方法表(invokeinterface 有接口方法表),来提高性能。

虚方法表中存放各个方法的实际入口地址。如果某个方法在子类没有重写,那么子类的虚方法表里的入口和父类入口一致,如果子类重写了这个方法那么子类方法表中的地址会被替换为子类实现版本的入口地址。

JVM调优

CPU达到100%定位:1、ps aux |grep xxx 或者jps 命令找Java进程号 PID;2、top -Hp PID 命令(-H 表示以线程的维度显示,默认以进程维度展示。)查看其阻塞的线程序号;3、ps -mp PID -o THREAD,tid,time | sort -rn命令,查询该进程的线程情况; 4、printf "%x\n" PID,将10进制线程号转为16进制TID,0x结果;5、jstack -l PID | grep TID -A 100 > xx.txt 跟踪此Java进程的堆栈信息,jinfo PID查询启动参数;jstat -gcutil PID 1000(1000表示GC状态的更新频率,单位毫秒)命令查看进程的堆情况,jmap -dump:live,format=b,file=dump-pid.hprof pid命令导出堆文件,只导出 live 存活的对象。打印heap概要信息:jmap -heap $(jcmd | grep "jar" | awk '{print }') 。

调优层次

性能调优包含多个层次,比如:架构调优、代码调优、JVM调优、数据库调优、操作系统调优等。 架构调优和代码调优是JVM调优的基础,其中架构调优是对系统影响最大的。

调优指标

  • 吞吐量:运行用户代码的时间占总运行时间的行例 (总运行时间=程序的运行时间+内存回收的时间);
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间;
  • 内存占用:java堆区所占的内存大小;

这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。

简单来说,主要抓住两点:

  • 吞吐量 吞吐量优先,意味着在单位时间内,STW的时间最短
  • 暂停时间 暂停时间优先,意味这尽可能让单次STW的时间最短

在设计(或使用)GC算法时,必须确定我们的目标:一个GC算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找一个二者的折衷。现在标准,在最大吞吐量优先的情况下,降低停顿时间。

JVM调优原则

优先原则

优先架构调优和代码调优,JVM优化是不得已的手段,大多数的Java应用不需要进行JVM优化

堆设置

参数-Xms和-Xmx,通常设置为相同的值,避免运行时要不断扩展JVM内存,建议扩大至3-4倍FullGC后的老年代空间占用。

年轻代设置

参数-Xmn,1-1.5倍FullGC之后的老年代空间占用。

避免新生代设置过小,当新生代设置过小时,会带来两个问题:一是minor GC次数频繁,二是可能导致 minor GC对象直接进老年代。当老年代内存不足时,会触发Full GC。 避免新生代设置过大,当新生代设置过大时,会带来两个问题:一是老年代变小,可能导致Full GC频繁执行;二是 minor GC 执行回收的时间大幅度增加。

老年代设置

  • 注重低延迟的应用
  • 老年代使用并发收集器,所以其大小需要小心设置,一般要考虑并发会话率和会话持续时间等一些参数如果堆设置偏小,可能会造成内存碎片、高回收频率以及应用暂停如果堆设置偏大,则需要较长的收集时间
  • 吞吐量优先的应用 一般吞吐量优先的应用都有一个较大的年轻代和一个较小的老年代。原因是,这样可以尽可能回收掉大部分短期对象,减少中期的对象,而老年代尽可能存放长期存活对象

方法区设置

基于jdk1.7版本,永久代:参数-XX:PermSize和-XX:MaxPermSize; 基于jdk1.8版本,元空间:参数 -XX:MetaspaceSize和-XX:MaxMetaspaceSize; 通常设置为相同的值,避免运行时要不断扩展,建议扩大至1.2-1.5倍FullGc后的永久带空间占用。

JVM启动时增加两个参数:

# 出现OOME时生成堆dump:-XX:+HeapDumpOnOutOfMemoryError# 生成堆文件地址:-XX:HeapDumpPath=/home/hadoop/dump/

常用工具命令

命令行工具:

  • 操作系统工具
    • top:显示系统整体资源使用情况
    • vmstat:监控内存和 CPU
    • iostat:监控 IO 使用
    • netstat:监控网络使用
  • JDK 性能监控工具
    • jps:虚拟机进程查看
    • jstat:虚拟机运行时信息查看
    • jinfo:虚拟机配置查看
    • jmap:内存映像(导出)
    • jhat:堆转储快照分析
    • jstack:Java 堆栈跟踪
    • jcmd:实现上面除了 jstat 外所有命令的功能

可视化工具:

JDK 自带的可视化性能监控和故障处理工具:

  • JConsole
  • VisualVM
  • Java Mission Control

第三方的工具:

  • MAT:Java 堆内存分析工具。
  • GChisto:GC 日志分析工具。
  • GCViewer:GC 日志分析工具。
  • JProfiler:商用的性能分析利器。
  • arthas:阿里开源诊断工具。
  • async-profiler:Java 应用性能分析工具,开源、火焰图、跨平台。


jps(JVM Process Status Tool) 查看Java进程,相当于Linux下的ps命令,只不过它只列出Java进程。

jps:列出Java程序进程ID和Main函数名称jps -q:只输出进程IDjps -m:输出传递给Java进程(主函数)的参数jps -l:输出主函数的完整路径jps -v:显示传递给Java虚拟的参数

jstat(JVM Statistics Monitoring Tool)查看Java程序运行时相关信息,可以通过它查看堆信息的相关情况

jstat -<options> [-t] [-h<lines>] <vmid> [<interval> [<count>]]#options可选值-class:显示ClassLoader的相关信息-compiler:显示JIT编译的相关信息-gc:显示与GC相关信息-gccapacity:显示各个代的容量和使用情况-gccause:显示垃圾收集相关信息(同-gcutil),同时显示最后一次或当前正在发生的垃圾收集的诱发原因-gcnew:显示新生代信息-gcnewcapacity:显示新生代大小和使用情况-gcold:显示老年代信息-gcoldcapacity:显示老年代大小-gcpermcapacity:显示永久代大小-gcutil:显示垃圾收集信息-printcompilation:输出JIT编译的方法信息-t:在输出信息前加上一个Timestamp列,显示程序的运行时间-h:可以在周期性数据输出后,输出多少行数据后,跟着一个表头信息interval:用于指定输出统计数据的周期,单位为毫秒count:用于指定一个输出多少次数据# 示例:显示GC相关信息jstat -gc 7063 500 47063 是进程ID ,采样时间间隔为500ms,采样数为4# 示例:显示垃圾收集相关信息jstat -gcutil 7737 5s 5

S0C:年轻代中第一个survivor(幸存区)的容量 (字节)S1C:年轻代中第二个survivor(幸存区)的容量 (字节)S0U:年轻代中第一个survivor(幸存区)目前已使用空间 (字节)S1U:年轻代中第二个survivor(幸存区)目前已使用空间 (字节)EC :年轻代中Eden(伊甸园)的容量 (字节)EU :年轻代中Eden(伊甸园)前已使空间 (字节)OCOld代的容量 (字节)OUOld代目前已使用空间 (字节)MCmetaspace(元空间)的容量 (字节)MUmetaspace(元空间)目前已使用空间 (字节)CCSC:压缩类空间大小CCSU:压缩类空间使用大小YGC :从应用程序启动到采样时年轻代中gc次数YGCT :从应用程序启动到采样时年轻代中gc所用时间(s)FGC :从应用程序启动到采样时old代(全gc)gc次数FGCT :从应用程序启动到采样时old代(全gc)gc所用时间(s)GCT:从应用程序启动到采样时gc用的总时间(s)S0:年轻代中第一个survivor(幸存区)已使用的占当前容量百分比S1:年轻代中第二个survivor(幸存区)已使用的占当前容量百分比E:年轻代中Eden(伊甸园)已使用的占当前容量百分比Oold代已使用的占当前容量百分比Mmetaspace已使用的占当前容量百分比CCS:压缩使用比例

jinfo(Java Configuration Info)查看正在运行的java程序的扩展参数,甚至支持运行时修改部分参数。

jinfo [option] <pid>#option可选值-flag <name> to print the value of the named VM flag-flag [+|-]<name> to enable or disable the named VM flag-flag <name>=<value> to set the named VM flag to the given value-flags to print VM flags-sysprops to print Java system properties<no option> to print both of the above-h | -help to print this help message# 示例 查看堆的最大值jinfo -flag MaxHeapSize 8384-XX:MaxHeapSize=10485760# 查看所有参数jinfo -flags 8384Attaching to process ID 8384, please wait...Debugger attached successfully.Server compiler detected.JVM version is 25.121-b13Non-default VM flags: -XX:CICompilerCount=4 -XX:InitialHeapSize=10485760 -XX:MaxHeapSize=10485760 -XX:MaxNewSize=3145728 -XX:MinHeapDeltaBytes=524288 -XX:NewSize=3145728 -XX:OldSize=7340032 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseFastUnorderedTimeStamps -XX:+UseParallelGCCommand line: -Xms10m -Xmx10m -Dfile.encoding=UTF-8# 查看使用的垃圾回收器jinfo -flag UseParallelGC 8384-XX:+UseParallelGC~ jinfo -flag UseConcMarkSweepGC 8384-XX:-UseConcMarkSweepGC# 设置日志打印jinfo -flag PrintGCDetails 8384-XX:-PrintGCDetails~ jinfo -flag +PrintGCDetails 8384~ jinfo -flag PrintGCDetails 8384-XX:+PrintGCDetails~ jinfo -flag -PrintGCDetails 8384~ jinfo -flag PrintGCDetails 8384-XX:-PrintGCDetails

jmap(Memory Map)查看堆内存使用状况,一般结合jhat使用。

jmap [option] <pid> (to connect to running process) jmap [option] <executable <core> (to connect to a core file) jmap [option] [server_id@]<remote server IP or hostname> (to connect to remote debug server)#option:选项参数。 pid:需要打印配置信息的进程ID。 executable:产生核心dump的Java可执行文件。 core:需要打印配置信息的核心文件。 server-id:可选的唯一id,如果相同的远程主机上运行了多台调试服务器,用此选项参数标识服务器。 remote server IP or hostname:远程调试服务器的IP地址或主机名。<none> to print same info as Solaris pmap-heap to print java heap summary-histo[:live] to print histogram of java object heap; if the "live" suboption is specified, only count live objects-clstats to print class loader statistics-finalizerinfo to print information on objects awaiting finalization-dump:<dump-options> to dump java heap in hprof binary format-F force. Use with -dump:<dump-options> <pid> or -histo to force a heap dump or histogram when <pid> does not respond. The "live" suboption is not supported in this mode.-h | -help to print this help message-J<flag> to pass <flag> directly to the runtime systemno option: 查看进程的内存映像信息,类似 Solaris pmap 命令。 heap: 显示Java堆详细信息 histo[:live]: 显示堆中对象的统计信息 clstats:打印类加载器信息 finalizerinfo: 显示在F-Queue队列等待Finalizer线程执行finalizer方法的对象 dump:生成堆转储快照 F:当-dump没有响应时,使用-dump或者-histo参数。在这个模式下,live子参数无效 help:打印帮助信息 J:指定传递给运行jmap的JVM的参数#示例显示Java堆详细信息jmap -heap pid#显示堆中对象的统计信息jmap -histo:live pid#打印类加载器信息jmap -clstats pid#打印正等候回收的对象的信息jmap -finalizerinfo pid# 发现程序异常前通过执行指令,直接生成当前JVM的dump文件,方式一jmap -dump:file=文件名.dump [pid]#生成堆转储快照dump文件 方式二jmap -dump:format=b,file=heapdump.dump pid#第一种方式是一种事后方式,需要等待当前JVM出现问题后才能生成dump文件,实时性不高; #第二种方式在执行时,JVM是暂停服务的,所以对线上的运行会产生影响。所以建议第一种方式。

jhat(Java Heap Analysis Tool)解析Java堆转储文件并启动一个 web server,然后用浏览器来查看、浏览 dump 出来的 heap。支持预先设计的查询,比如显示某个类的所有实例。 还支持对象查询语言(OQL, Object Query Language)。 OQL有点类似SQL,专门用来查询堆转储。

jhat [ options ] heap-dump-file#options可选值-stack false|true关闭对象分配调用栈跟踪(tracking object allocation call stack)。 如果分配位置信息在堆转储中不可用,则必须将此标志设置为 false。 默认值为 true 。-refs false|true关闭对象引用跟踪(tracking of references to objects)。 默认值为 true 。 默认情况下, 返回的指针是指向其他特定对象的对象,如反向链接或输入引用(referrers or incoming references), 会统计、计算堆中的所有对象。-port port-number设置 jhat HTTP server 的端口号。 默认值 7000 。-exclude exclude-file指定对象查询时需要排除的数据成员列表文件(a file that lists data members that should be excluded from the reachable objects query)。 例如, 如果文件列列出了 java.lang.String.value , 那么当从某个特定对象 Object o 计算可达的对象列表时, 引用路径涉及 java.lang.String.value 的都会被排除。-baseline exclude-file指定一个基准堆转储(baseline heap dump)。 在两个 heap dumps 中有相同 object ID 的对象会被标记为不是新的(marked as not being new),其他对象被标记为新的(new)。 在比较两个不同的堆转储时很有用。-debug int设置 debug 级别。 0表示不输出调试信息。 值越大则表示输出更详细的 debug 信息。-version启动后只显示版本信息就退出-h显示帮助信息并退出。 同 -help-help显示帮助信息并退出。 同 -h-J< flag >因为jhat命令实际上会启动一个JVM来执行, 通过 -J 可以在启动JVM时传入一些启动参数。 例如, -J-Xmx512m 则指定运行jhat的Java虚拟机使用的最大堆内存为 512 MB。 如果需要使用多个JVM启动参数,则传多多个 -Jxxxxxx。

jstack(Java Stack Trace)java虚拟机自带的一种堆栈跟踪工具。

jstack [ option ] pid 查看当前时间点,指定进程的dump堆栈信息。jstack [ option ] pid > 文件 将当前时间点的指定进程的dump堆栈信息,写入到指定文件中。注:若该文件不存在,则会自动生成;若该文件存在,则会覆盖源文件。jstack [ option ] executable core 查看当前时间点,core文件的dump堆栈信息。jstack [ option ] [server_id@]<remote server IP or hostname> 查看当前时间点,远程机器的dump堆栈信息。option可选值-F 强制jstack。当进程挂起了,此时'jstack [-l] pid'是没有响应的,这时候可使用此参数来强制打印堆栈信息,一般情况不需要使用。-m 打印java和native c/c++框架的所有栈信息。可以打印JVM的堆栈,以及Native的栈帧,一般应用排查不需要使用。-l 长列表。打印关于锁的附加信息。例如属于java.util.concurrent的ownable synchronizers列表,会使得JVM停顿得久得多(可能会差很多倍,如普通的jstack可能毫秒和次GC没区别,加了-l 就是近一秒的时间),-l 建议不要用。一般情况不需要使用。-h or -hel 打印帮助信息

Jconsole(Java Monitoring and Management Console)Java 5引入,一个内置 Java 性能分析器,可以从命令行或在 GUI shell 中运行。您可以轻松地使用 JConsole来监控 Java 应用程序性能和跟踪Java 中的代码。

JVM参数

JVM参数主要分为以下三种:标准参数、非标准参数、不稳定参数。

标准参数:顾名思义,标准参数中包括功能以及输出的结果都是很稳定的,基本上不会随着JVM版本的变化而变化。标准参数以-开头,如java -version、java -jar、java-help等。

非标准参数:是标准参数的扩展,以-X开头。表示在将来的JVM版本中可能会发生改变,但是这类以-X开始的参数变化的比较小。可以通过 Java -X 命令来检索所有-X 参数。

常用的非标准参数有:

  • -Xmn新生代内存的大小,包括Eden区和两个Survivor区的总和,写法如:-Xmn1024,-Xmn1024k,-Xmn1024m,-Xmn1g 。Sun官方推荐配置为整个堆的3/8
  • -Xms堆内存的最小值,默认值是总内存/64(且小于1G)。默认情况下,当堆中可用内存小于40%(这个值可以用-XX: MinHeapFreeRatio 调整,如-X:MinHeapFreeRatio=30)时,堆内存会开始增加,直增加到-Xmx的大小。
  • -Xmx堆内存的最大值,默认值是总内存/4(且小于1G)。默认情况下,当堆中可用内存大于70%(这个值可以用-XX: MaxHeapFreeRatio调整,如-X:MaxHeapFreeRatio =80)时,堆内存会开始减少,一直减小到-Xms的大小。 * 如果Xms和Xmx都不设置,则两者大小会相同*
  • -Xss每个线程的栈内存,默认1M(JDK5后,之前为256K),般来说是不需要改的。
  • -Xrs减少JVM对操作系统信号的使用。
  • -Xprof跟踪正运行的程序,并将跟踪数据在标准输出输出。适合于开发环境调试。
  • -Xnoclassgc关闭针对class的gc功能。因为其阻至内存回收,所以可能会导致OutOfMemoryError错误,慎用。
  • -Xincgc开启增量gc(默认为关闭)。这有助于减少长时间GC时应用程序出现的停顿,但由于可能和应用程序并发执行,所以会降低CPU对应用的处理能力。
  • -Xloggc:file与-verbose:gc功能类似,只是将每次GC事件的相关情况记录到一个文件中,文件的位置最好在本地,以避免网络的潜在问题。

不稳定参数:也是非标准化参数,随着JVM版本的变化可能会发生变化,主要用于JVM调优和debug。以-XX 开头,此类参数的设置很容易引起JVM 性能上的差异,使JVM存在极大的不稳定性。如果此类参数设置合理将大大提高JVM的性能及稳定性。

不稳定参数分为三类:

  • 性能参数:用于JVM的性能调优和内存分配控制,如内存大小的设置
  • 行为参数:用于改变JVM的基础行为,如GC的方式和算法的选择
  • 调试参数:用于监控、打印、输出jvm的信息

不稳定参数语法规则:

  • 布尔类型参数值:
  • -XX:+-XX:- 示例:-XX:+UseG1GC,表示启用G1垃圾收集器
  • 数字类型参数值: -XX: 示例:-XX:MaxGCPauseMillis=500 ,表示设置GC的最大停顿时间是500ms
  • 字符串类型参数值: -XX: 示例:-XX:HeapDumpPath=./dump.core

常用参数设置

–Xms4g -Xmx4g –Xmn1200m –Xss512k -XX:NewRatio=4 -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:PermSize=100m -XX:MaxPermSize=256m -XX:MaxDirectMemorySize=1G -XX:+DisableExplicitGC
  • -XX:NewRatio=4:设置年轻代(包括Eden和两个Survivor区)与老年代的比值(除去持久代)。设置为4,则年轻代与老年代所占比值为1:4,年轻代占整个堆栈的1/5
  • -XX:SurvivorRatio=8:设置年轻代中Eden区与Survivor区的大小比值。设置为8,则两个Survivor区与个Eden区的比值为2:8,个Survivor区占整个年轻代的1/10
  • -XX:PermSize=100m:初始化永久代大小为100MB。
  • -XX:MaxPermSize=256m:设置持久代大小为256MB。
  • -XX:MaxTenuringThreshold=15:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入老年代。对于老年代比较多的应用,可以提高效率。如果将此值设置为个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象在年轻代的存活时间,增加在年轻代即被回收的概率。
  • -XX:MaxDirectMemorySize=1G:直接内存。报java.lang.OutOfMemoryError: Direct buffermemory异常可以上调这个值。
  • -XX:+DisableExplicitGC:禁止运行期显式地调用System.gc()来触发fulll GC。 注意: Java RMI的定时GC触发机制可通过配置-Dsun.rmi.dgc.server.gcInterval=86400来控制触发的时间。
  • -XX:CMSInitiatingOccupancyFraction=60:老年代内存回收阈值,默认值为68。
  • -XX:ConcGCThreads=4:CMS垃圾回收器并行线程线,推荐值为CPU核心数。
  • -XX:ParallelGCThreads=8:新生代并行收集器的线程数。
  • -XX:CMSMaxAbortablePrecleanTime=500:当abortable-preclean预清理阶段执行达到此时间就会结束。

其它参数

  • -XX:+UseSerialGC:设置串行收集器
  • -XX:+UseParallelGC:设置并行收集器
  • -XX:+UseParalledlOldGC:设置并行年老代收集器
  • -XX:+UseConcMarkSweepGC:设置并发收集器
  • -XX:ParallelGCThreads=n:设置并行收集器收集时使用的 CPU 数。并行收集线程数
  • -XX:MaxGCPauseMillis=n:设置并行收集最大的暂停时间(如果到这个时间了,垃圾回收器依然没有回收完,也会停止回收)
  • -XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比。公式为:1/(1+n)
  • -XX:+CMSIncrementalMode:设置为增量模式。适用于单 CPU 情况

-XX:+PrintFlagsInitial、-XX:+PrintFlagsFinal:Java 6(update 21oder 21之后)版本, HotSpot JVM 提供给了两个新的参数,在JVM启动后,在命令行中可以输出所有XX参数和值。

-XX:+PrintCommandLineFlags: 这个参数让JVM打印出那些已经被用户或者JVM设置过的详细的XX参数的名称和值。它列举出 -XX:+PrintFlagsFinal的结果中第三列有":="的参数,快捷查看修改过的参数。

-XX:+

PrintGCApplicationStoppedTime 打印GC造成应用暂停的时间。

-XX:+PrintTenuringDistribution 在每次新生代 young GC时,输出幸存区中对象的年龄分布。

-XX:InitialCodeCacheSize 和 -XX:ReservedCodeCacheSize:自定义代码缓存的大小。若代码缓存被占满,JVM会打印出一条警告消息并切换到interpreted-only 模式(JIT编译器被停用,字节码将不再会被编译成机器码)。应用程序会继续运行,但运行速度会降低一个数量级。可使用-XX:+UseCodeCacheFlushing 这个参数,避免因代码缓存被填满时JVM切换到interpreted-only 模式。

JVM GC格式日志的主要参数包括如下8个:

-XX:+PrintGC 输出简要GC日志

-XX:+PrintGCDetails 输出详细GC日志,默认是关闭,推荐使用。

-Xloggc:gc.log 输出GC日志到文件

-XX:+PrintGCTimeStamps 输出GC的时间戳(以JVM启动到当期的总时长的时间戳形式)

-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2020-04-26T21:53:59.234+0800)

-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息

-verbose:gc : 在JDK 8中,-verbose:gc是-XX:+PrintGC一个别称,日志格式等价于:-XX:+PrintGC。不过在JDK 9中 -XX:+PrintGC被标记为deprecated。 -verbose:gc是一个标准的选项,-XX:+PrintGC是一个实验的选项,建议使用-verbose:gc 替代-XX:+PrintGC

-XX:+PrintReferenceGC 打印年轻代各个引用的数量以及时长

使用日志循环(Log rotation)标志可以限制保存在GC日志中的数据量;对于需要长时间运行的服务器而言,这是一个非常有用的标志,否则累积几个月的数据很可能会耗尽服务器的磁盘。

开启日志滚动输出通过-XX:+UseGCLogfileRotation -XX:NumberOfGCLogfiles=N -XX:GCLogfileSize=N标志可以控制日志文件的循环。 默认情况下,UseGCLogfileRotation标志是关闭的。它负责打开或关闭GC日志滚动记录功能的。要求必须设置 -Xloggc参数开启UseGCLogfileRotation标志后,默认的文件数目是0(意味着不作任何限制),默认的日志文件大小是0(同样也是不作任何限制)。因此,为了让日志循环功能真正生效,我们必须为所有这些标志设定值。

需要注意的是:

  • 设置滚动日志文件的大小必须大于8k。当前写日志文件大小超过该参数值时,日志将写入下一个文件
  • 设置滚动日志文件的个数,必须大于等于1
  • 必须设置 -Xloggc 参数
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/home/hadoop/gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=512k

零拷贝常用的非标准参数有:

  • -Xmn新生代内存的大小,包括Eden区和两个Survivor区的总和,写法如:-Xmn1024,-Xmn1024k,-Xmn1024m,-Xmn1g 。Sun官方推荐配置为整个堆的3/8
  • -Xms堆内存的最小值,默认值是总内存/64(且小于1G)。默认情况下,当堆中可用内存小于40%(这个值可以用-XX: MinHeapFreeRatio 调整,如-X:MinHeapFreeRatio=30)时,堆内存会开始增加,直增加到-Xmx的大小。
  • -Xmx堆内存的最大值,默认值是总内存/4(且小于1G)。默认情况下,当堆中可用内存大于70%(这个值可以用-XX: MaxHeapFreeRatio调整,如-X:MaxHeapFreeRatio =80)时,堆内存会开始减少,一直减小到-Xms的大小。 * 如果Xms和Xmx都不设置,则两者大小会相同*
  • -Xss每个线程的栈内存,默认1M(JDK5后,之前为256K),般来说是不需要改的。
  • -Xrs减少JVM对操作系统信号的使用。
  • -Xprof跟踪正运行的程序,并将跟踪数据在标准输出输出。适合于开发环境调试。
  • -Xnoclassgc关闭针对class的gc功能。因为其阻至内存回收,所以可能会导致OutOfMemoryError错误,慎用。
  • -Xincgc开启增量gc(默认为关闭)。这有助于减少长时间GC时应用程序出现的停顿,但由于可能和应用程序并发执行,所以会降低CPU对应用的处理能力。
  • -Xloggc:file与-verbose:gc功能类似,只是将每次GC事件的相关情况记录到一个文件中,文件的位置最好在本地,以避免网络的潜在问题。

不稳定参数:也是非标准化参数,随着JVM版本的变化可能会发生变化,主要用于JVM调优和debug。以-XX 开头,此类参数的设置很容易引起JVM 性能上的差异,使JVM存在极大的不稳定性。如果此类参数设置合理将大大提高JVM的性能及稳定性。

不稳定参数分为三类:

  • 性能参数:用于JVM的性能调优和内存分配控制,如内存大小的设置
  • 行为参数:用于改变JVM的基础行为,如GC的方式和算法的选择
  • 调试参数:用于监控、打印、输出jvm的信息

不稳定参数语法规则:

  • 布尔类型参数值:
  • -XX:+-XX:- 示例:-XX:+UseG1GC,表示启用G1垃圾收集器
  • 数字类型参数值: -XX: 示例:-XX:MaxGCPauseMillis=500 ,表示设置GC的最大停顿时间是500ms
  • 字符串类型参数值: -XX: 示例:-XX:HeapDumpPath=./dump.core

常用参数设置

–Xms4g -Xmx4g –Xmn1200m –Xss512k -XX:NewRatio=4 -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:PermSize=100m -XX:MaxPermSize=256m -XX:MaxDirectMemorySize=1G -XX:+DisableExplicitGC
  • -XX:NewRatio=4:设置年轻代(包括Eden和两个Survivor区)与老年代的比值(除去持久代)。设置为4,则年轻代与老年代所占比值为1:4,年轻代占整个堆栈的1/5
  • -XX:SurvivorRatio=8:设置年轻代中Eden区与Survivor区的大小比值。设置为8,则两个Survivor区与个Eden区的比值为2:8,个Survivor区占整个年轻代的1/10
  • -XX:PermSize=100m:初始化永久代大小为100MB。
  • -XX:MaxPermSize=256m:设置持久代大小为256MB。
  • -XX:MaxTenuringThreshold=15:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入老年代。对于老年代比较多的应用,可以提高效率。如果将此值设置为个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象在年轻代的存活时间,增加在年轻代即被回收的概率。
  • -XX:MaxDirectMemorySize=1G:直接内存。报java.lang.OutOfMemoryError: Direct buffermemory异常可以上调这个值。
  • -XX:+DisableExplicitGC:禁止运行期显式地调用System.gc()来触发fulll GC。 注意: Java RMI的定时GC触发机制可通过配置-Dsun.rmi.dgc.server.gcInterval=86400来控制触发的时间。
  • -XX:CMSInitiatingOccupancyFraction=60:老年代内存回收阈值,默认值为68。
  • -XX:ConcGCThreads=4:CMS垃圾回收器并行线程线,推荐值为CPU核心数。
  • -XX:ParallelGCThreads=8:新生代并行收集器的线程数。
  • -XX:CMSMaxAbortablePrecleanTime=500:当abortable-preclean预清理阶段执行达到此时间就会结束。

其它参数

  • -XX:+UseSerialGC:设置串行收集器
  • -XX:+UseParallelGC:设置并行收集器
  • -XX:+UseParalledlOldGC:设置并行年老代收集器
  • -XX:+UseConcMarkSweepGC:设置并发收集器
  • -XX:ParallelGCThreads=n:设置并行收集器收集时使用的 CPU 数。并行收集线程数
  • -XX:MaxGCPauseMillis=n:设置并行收集最大的暂停时间(如果到这个时间了,垃圾回收器依然没有回收完,也会停止回收)
  • -XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比。公式为:1/(1+n)
  • -XX:+CMSIncrementalMode:设置为增量模式。适用于单 CPU 情况

-XX:+PrintFlagsInitial、-XX:+PrintFlagsFinal:Java 6(update 21oder 21之后)版本, HotSpot JVM 提供给了两个新的参数,在JVM启动后,在命令行中可以输出所有XX参数和值。

-XX:+PrintCommandLineFlags: 这个参数让JVM打印出那些已经被用户或者JVM设置过的详细的XX参数的名称和值。它列举出 -XX:+PrintFlagsFinal的结果中第三列有":="的参数,快捷查看修改过的参数。

-XX:+

PrintGCApplicationStoppedTime 打印GC造成应用暂停的时间。

-XX:+PrintTenuringDistribution 在每次新生代 young GC时,输出幸存区中对象的年龄分布。

-XX:InitialCodeCacheSize 和 -XX:ReservedCodeCacheSize:自定义代码缓存的大小。若代码缓存被占满,JVM会打印出一条警告消息并切换到interpreted-only 模式(JIT编译器被停用,字节码将不再会被编译成机器码)。应用程序会继续运行,但运行速度会降低一个数量级。可使用-XX:+UseCodeCacheFlushing 这个参数,避免因代码缓存被填满时JVM切换到interpreted-only 模式。

JVM GC格式日志的主要参数包括如下8个:

-XX:+PrintGC 输出简要GC日志

-XX:+PrintGCDetails 输出详细GC日志,默认是关闭,推荐使用。

-Xloggc:gc.log 输出GC日志到文件

-XX:+PrintGCTimeStamps 输出GC的时间戳(以JVM启动到当期的总时长的时间戳形式)

-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2020-04-26T21:53:59.234+0800)

-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息

-verbose:gc : 在JDK 8中,-verbose:gc是-XX:+PrintGC一个别称,日志格式等价于:-XX:+PrintGC。不过在JDK 9中 -XX:+PrintGC被标记为deprecated。 -verbose:gc是一个标准的选项,-XX:+PrintGC是一个实验的选项,建议使用-verbose:gc 替代-XX:+PrintGC

-XX:+PrintReferenceGC 打印年轻代各个引用的数量以及时长

使用日志循环(Log rotation)标志可以限制保存在GC日志中的数据量;对于需要长时间运行的服务器而言,这是一个非常有用的标志,否则累积几个月的数据很可能会耗尽服务器的磁盘。

开启日志滚动输出通过-XX:+UseGCLogfileRotation -XX:NumberOfGCLogfiles=N -XX:GCLogfileSize=N标志可以控制日志文件的循环。 默认情况下,UseGCLogfileRotation标志是关闭的。它负责打开或关闭GC日志滚动记录功能的。要求必须设置 -Xloggc参数开启UseGCLogfileRotation标志后,默认的文件数目是0(意味着不作任何限制),默认的日志文件大小是0(同样也是不作任何限制)。因此,为了让日志循环功能真正生效,我们必须为所有这些标志设定值。

需要注意的是:

  • 设置滚动日志文件的大小必须大于8k。当前写日志文件大小超过该参数值时,日志将写入下一个文件
  • 设置滚动日志文件的个数,必须大于等于1
  • 必须设置 -Xloggc 参数
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/home/hadoop/gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=512k