JVM之内存与垃圾回收

154 阅读47分钟

java虚拟机就是二进制字节码的运行环境。

特点:

  • 一次编译到处运行
  • 自动内存管理
  • 自动垃圾回收功能

JVM所处的位置

JVM整体结构

类加载器子系统把字节码文件加载到内存中,生成一个大的Class对象

多线程共享堆和方法区。

程序计数器、虚拟机栈、本地方法栈 每个线程独有一份。

反编译字节码文件的命令

Javap -v 字节码文件名

JVM生命周期

虚拟机的启动

通过引导类加载器 创建一个初始类来完成的,这个类是由虚拟机栈的具体实现指定的。

虚拟机的运行

虚拟机的退出

JVM发展历程

最早的是sun公司的classvm虚拟机,只有解释器没有jit编译器,如果想要使用编译器就要使用外挂,一旦使用外挂就只能 解释器编译器二选一。jdk1.4之后被淘汰

jit:就是把热点代码缓存到本地。下次执行的时候不需要--。如果只使用jit的话,卡顿时间会变长。

现在主流的是hotsopt虚拟机。二者结合使用。

类加载子系统

3个阶段:加载阶段、链接阶段、初始化阶段

特点:

  • 负责从文件系统或网络中加载Class文件,Class文件开头有特定的标识。
  • ClassLoader只负责Class文件的加载,至于他能否运行,ExecutionEngine决定。
  • 加载的类信息存放于一块方法区的内存空间。除了类的信息,方法区还会存放运行时常量池的信息。

加载:

1、通过一个类的全限定名获取定义此类型的二进制字节流

2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

3、在内存中生成一个代表这个类的Java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

链接:

验证 :确保Class文件额字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性。

准备: 为变量分配内存并且设置默认初始值

不包含用final修饰的static,因为final在编译的时候就分配了,准备阶段会显示初始化。

解析:将常量池内的符号引用转为直接引用的过程。

初始化;

就是执行类构造器方法 clinit()的过程

类加载器分类

引导类加载器 Bootstrap ClassLoader

通过C/C++语言实现,嵌套在JVM内部,用来加载Java核心库(JAVA_HOME/jre/lib/rt.jar 、resource.jar 或 sun.boot.class.path路径下的内容),用于提供JVM自身需要的类

并不继承java.lang.ClassLoader,没有父加载器

加载扩展类和应用程序类加载器,并指定为他们的父类加载器

出于安全的考虑,Bootstrap启动类加载器只加载包名为java,javax,sun等开头的类

扩展类加载器 Extension ClassLoader

Java语言编写,由sum.misc.Launcher$ExtClassLoader实现。

继承ClassLoader类,父类加载器是启动类加载器

从java.ext.dirs系统属性所指定的目录中加载类库或从JDK的安装目录jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的Jar放在此目录也会自动由扩展类加载器进行加载

应用程序类加载器 (系统类加载器) AppClassLoader

Java语言编写,由sum.misc.Launcher$AppClassLoader实现

继承ClassLoader类,父类加载器为扩展类加载器

负责加载环境变量classpath或系统属性 java.class.path 指定路径下的类库

程序中默认的类加载器,一般来说,Java应用的类都是由它完成加载

通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器

获取类加载器的方式

 //获取系统类加载器
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader); // sun.misc.Launcher$AppClassLoader@18b4aac2

        //获取扩展类加载器
        ClassLoader extClassLoader = systemClassLoader.getParent();
        System.out.println(extClassLoader); // sun.misc.Launcher$ExtClassLoader@312b1dae

        //试图获取引导类加载器
        ClassLoader bootStrapClassLoader = extClassLoader.getParent();
        System.out.println(bootStrapClassLoader); //null

        //获取用户定义的类对象的类加载器
        ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
        System.out.println(classLoader); // sun.misc.Launcher$AppClassLoader@18b4aac2

        //String类的类加载器  - 通过引导类加载器加载的  Java的核心类库都是通过引导类加载进行加载的
        ClassLoader classLoader1 = String.class.getClassLoader();
        System.out.println(classLoader1); //null

双亲委派机制

Java虚拟机对Class文件采用的是按需加载的方式。需要该类时,才会把他的class文件加载到内存生成class对象。加载某个类的class文件时,采用的是双亲委派模式,就是将请求交给父类处理,是一种任务委派模式。

工作原理

1、如果一个类加载器收到了加载类的请求,他并不会自己先去加载,而是把这个请求委托给父类的加载器去执行。

2、如果父类加载器还存在父类加载器,就继续向上委托。最终达到顶层的启动类加载器。

3、如果父类加载器成功完成加载任务就成功返回。如果父类不能完成加载,子类加载器才会尝试自己去加载。这就是双亲委派模式。

优势:

1、避免类的重复加载

2、保护程序安全,保护核心api被恶意篡改

沙箱安全机制

自定义String类,但是在加载类自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中 java\lang\String.class),报错信息说没有main方法,就是因为加载的是rt.jar包中的String类。这样可以保证对java核心源代码的保护,这就是沙箱安全机制。

虚拟机栈

栈是运行时的单位(程序如何执行。如何处理数据),堆是存储的单位(数据怎么放,放在哪)。

每个线程在创建的时候都会创建一个虚拟机栈,内部保存一个个的栈帧,对应着一次次的java方法引用,是线程私有的。

生命周期和线程一致

作用:

主管java程序的运行,保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。

特点:

快速有效的分配存储方式、访问速度仅次于程序计数器

不存在垃圾回收问题

jvm直接对java栈的操作只有两个:每个方法执行,伴随着进栈(入栈、压栈)。执行结束后的出栈工作

虚拟机栈的异常与调优

jvm虚拟机允许java栈的大小是动态的或者是固定不变的。

如果采用固定大小的java虚拟机栈,每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机会抛出一个stackoverflowerror异常。

如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请足够的内存,或者在创建新的线程时没有足够的内存区创建对应的虚拟机栈,Java虚拟机会抛出一个outofmemoryerror异常。

设置栈内存大小

使用参数 -xss 设置线程的最大栈空间。

默认情况下:

    private static  int count = 1;
    public static void main(String[] args) {
        System.out.println(count);
        count++;
        main(args);

    }

设置栈内存后:

虚拟机栈的存储单位

每个线程都有自己的栈,栈中的数据都是以栈帧的格式存在

在这个线程上正在执行的每个方法都各自对应一个栈帧

栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息

运行原理:

1、不同线程中包含的栈帧是不允许存在相互引用的,不可能在一个栈帧中引用另外一个线程的栈帧。

2、如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果,给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

3、Java方法有两种返回函数的方式,一种是正常函数的返回,使用return命令;另外一种是抛出异常。不管使用那种方式,都会导致栈帧被弹出。

栈帧的内部结构

每个栈帧中存储着:

1、局部变量表

2、操作数栈

3、动态链接

4、方法返回地址

5、一些附加信息

局部变量表:

1、定义为一个数字数组、主要用于存储方法参数和定义在方法体内的局部变量。包括各类基本数据类型、对象引用,以及returnaddress类型。

2、局部变量表建立在线程的栈上,是线程私有数据,所以不会有线程安全问题。

3、局部变量表所需的容量大小是在编译期确定下来的。保存在方法的code属性的maximum local variables数据项中。方法运行期间是不会改变局部变量表的大小的。

最基本存储单位是Slot(变量槽)

32位以内的数据占用一个slot,64位(long double)的类型占用两个slot

slot变量槽重复利用
public static void main(String[] args) {
      int a =0;
      //变量b出了{} 作用域被销毁了,数组的位置已经开辟了,数组不能动态扩容    
        {
            int b =0;
            b=a+1;
        }
    //变量c 使用的是之前已经销毁的变量b占据的slot的位置
        int c =a+1;
 }

静态变量和局部变量的区别
  • 成员变量和局部变量的定义

局部变量:如果一个变量的定义的位置是在方法的内部,此时该变量被称为局部变量。

局部变量在使用的前必须要赋值。

成员变量:如果一个变量定义的位置在方法的外部,在类的内部,此时该变量被称为成员变量。成员变量不是必须要赋值,成员变量是有默认值的,具体的值是根据数据类型来定。

静态变量:Java 中被 static 修饰的成员称为静态成员或类成员。它属于整个类所有,而不是某个对象所有,即被类的所有对象所共享。静态成员可以使用类名直接访问,也可以使用对象名进行访问。静态变量是成员变量的一种。

  • 变量的分类: 按照数据类型分 1、基本数据类型 2、引用数据类型

按照在类中声明的位置分 1、成员变量 在使用前,都经历过默认初始化赋值。

类变量:linking的prepare阶段 给类变量默认赋值 --->inital阶 段给类变量显示赋值及静态代码块赋值。

实例变量:随着对象的创建,会在堆空间中分配实例变量空间, 并进行默认赋值

2、局部变量 在使用前必须进行显示赋值,否则编译不通过。

操作数栈

主要用于保存计算过程中的中间结果。同时作为计算过程中变量临时的存储空间。

  • 每个独立的栈帧中除了包含局部变量表之外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称为表达式栈(Expression Stack)
  • 操作数栈,在方法执行过程中,根据字节码指令,往操作数栈中写入数据或提取数据,即入栈(push)、出栈(pop)
  • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如,执行复制、交换、求和等操作
  • 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
  • 操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,此时这个方法的操作数栈是空的
  • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 Code 属性的 max_stack 数据项中
  • 栈中的任何一个元素都可以是任意的 Java 数据类型
  • 32bit 的类型占用一个栈单位深度
  • 64bit 的类型占用两个栈单位深度
  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
  • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令
  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证
  • 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

栈顶缓存技术

基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。由于操作数是存储在内存中的,因此频繁的执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM 设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。


动态链接

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。比如invokedynamic指令

在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 Class 文件的常量池中。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用

方法的调用

方法调用阶段的唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时不涉及方法内部的具体运行过程。 Class 文件的编译过程中不包括传统编译器中的连接步骤,一切方法调用在 Class文件里面存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。也就是需要在类加载阶段,甚至到运行期才能确定目标方法的直接引用。

在 JVM 中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关

  • 静态链接:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
  • 动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接

对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次

  • 早期绑定:早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
  • 晚期绑定:如果被调用的方法在编译器无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式就被称为晚期绑定

虚方法和非虚方法

  • 如果方法在编译器就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法,比如静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法
  • 其他方法称为虚方法

虚拟机中提供了以下几条方法调用指令:

普通调用指令

invokestatic:调用静态方法,解析阶段确定唯一方法版本。

invokespecial:调用init 方法、私有及父类方法,解析阶段确定唯一方法版本

invokevirtual:调用所有虚方法

invokeinterface:调用接口方法

invokestatic指令和invokespecial指令调用的方法成为非虚方法。其余的(final修饰的除外)称为虚方法。

本地方法接口

什么是本地方法:一个Native Method 就是调用一个Java调用非Java代码的接口。

本地方法栈

Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用

  • 本地方法栈也是线程私有的
  • 允许线程固定或者可动态扩展的内存大小
  • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常
  • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么 Java虚拟机将会抛出一个OutofMemoryError异常
  • 本地方法是使用 C 语言实现的
  • 它的具体做法是 Native Method Stack 中登记 native 方法,在 Execution Engine 执行时加载本地方法库当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区,它甚至可以直接使用本地处理器中的寄存器,直接从本地内存的堆中分配任意数量的内存
  • 并不是所有 JVM 都支持本地方法。因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果 JVM 产品不打算支持 native 方法,也可以无需实现本地方法栈
  • 在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一

栈是运行时的单位,而堆是存储的单位

栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪。

Java 堆是 Java 虚拟机管理的内存中最大的一块,被所有线程共享。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数据都在这里分配内存。

一个jvm实例只存在一个堆内存,堆也是Java内存管理的核心区域

验证:

  • 第一步:写两份同样的代码

  • 第二步:设置堆空间大小,一个设置成10m 一个设置成20m。

  • 第三步:打开jdk目录的 bin下面的 这个软件

就可以看见:

参数说明:

-Xms 用来设置堆空间(年轻代+老年代)的初始内存大小

-X 是jvm的运行参数

ms 是memory start

mx 是memory max

默认堆空间大小:

初始内存大小:物理电脑内存大小/64

最大内存大小:物理电脑内存大小/4

查看堆内存配置

第一种:

  1. 在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小
  2. 默认情况下新生代和老年代的比例是 1:2,可以通过 –XX:NewRatio 来配置
    • 新生代中的 Eden:From Survivor:To Survivor 的比例是 8:1:1,可以通过 -XX:SurvivorRatio 来配置
  1. 若在 JDK 7 中开启了 -XX:+UseAdaptiveSizePolicy,JVM 会动态调整 JVM 堆中各个区域的大小以及进入老年代的年龄此时 –XX:NewRatio 和 -XX:SurvivorRatio 将会失效,而 JDK 8 是默认开启-XX:+UseAdaptiveSizePolicy在 JDK 8中,不要随意关闭-XX:+UseAdaptiveSizePolicy,除非对堆内存的划分有明确的规划

每次 GC 后都会重新计算 Eden、From Survivor、To Survivor 的大小

suv区和edu区的 真实默认配置是 6:1:1 。可以通过参数配置 成 8:1

对象的分配过程

  • new 的对象先放在伊甸园区,此区有大小限制
  • 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
  • 然后将伊甸园中的剩余对象移动到幸存者 0 区
  • 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区,如果没有回收,就会放到幸存者 1 区
  • 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区
  • 什么时候才会去养老区呢? 默认是 15 次回收标记
  • 在养老区,相对悠闲。当养老区内存不足时,再次触发 Major GC,进行养老区的内存清理
  • 若养老区执行了 Major GC 之后发现依然无法进行对象的保存,就会产生 OOM 异常

Minor GC、Major GC、Full GC

JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。

针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)

  • 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
      • 目前,只有 CMS GC 会有单独收集老年代的行为
      • 很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
      • 目前只有 G1 GC 会有这种行为
  • 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾

什么是TLAB

  • 从内存模型而不是垃圾回收的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内
  • 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略
  • OpenJDK 衍生出来的 JVM 大都提供了 TLAB 设计

为什么要有TLAB

  • 堆区是线程共享的,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。

在程序中,可以通过 -XX:UseTLAB 设置是否开启 TLAB 空间。

默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,我们可以通过 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小。

一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。

堆是分配对象存储的唯一选择吗

逃逸分析

逃逸分析(Escape Analysis) 是目前 Java 虚拟机中比较前沿的优化技术 。这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中,称为方法逃逸。
public static StringBuffer craeteStringBuffer(String s1, String s2) {
   StringBuffer sb = new StringBuffer();
   sb.append(s1);
   sb.append(s2);
   return sb;
}

StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个 StringBuffer 有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,但是其逃逸到了方法外部。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

上述代码如果想要 StringBuffer sb不逃出方法,可以这样写:

ublic static String createStringBuffer(String s1, String s2) {
   StringBuffer sb = new StringBuffer();
   sb.append(s1);
   sb.append(s2);
   return sb.toString();
}

不直接返回 StringBuffer,那么 StringBuffer 将不会逃逸出方法。

开发中使用局部变量,就不要在方法外定义。

使用逃逸分析,编译器可以对代码做优化:

  • 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
  • 同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
  • 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而存储在 CPU 寄存器
  • JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无需进行垃圾回收了。

常见栈上分配的场景:成员变量赋值、方法返回值、实例引用传递

代码优化之同步省略(消除)
  • 线程同步的代价是相当高的,同步的后果是降低并发性和性能
  • 在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这个代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫做同步省略,也叫锁消除
public void keep() {
  Object keeper = new Object();
  synchronized(keeper) {
    System.out.println(keeper);
  }
}

如上代码,代码中对 keeper 这个对象进行加锁,但是 keeper 对象的生命周期只在 keep()方法中,并不会被其他线程所访问到,所以在 JIT编译阶段就会被优化掉。优化成:

public void keep() {
  Object keeper = new Object();
  System.out.println(keeper);
}
代码优化之标量替换

标量(Scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。

相对的,那些的还可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为其还可以分解成其他聚合量和标量。

在 JIT 阶段,通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM 不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。这个过程就是标量替换

通过 -XX:+EliminateAllocations 可以开启标量替换,-XX:+PrintEliminateAllocations 查看标量替换情况。

public static void main(String[] args) {
   alloc();
}

private static void alloc() {
   Point point = new Point(1,2);
   System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
    private int x;
    private int y;
}

以上代码中,point 对象并没有逃逸出 alloc() 方法,并且 point 对象是可以拆解成标量的。那么,JIT 就不会直接创建 Point 对象,而是直接使用两个标量 int x ,int y 来替代 Point 对象。

private static void alloc() {
   int x = 1;
   int y = 2;
   System.out.println("point.x="+x+"; point.y="+y);
}
代码优化之栈上分配

通过 JVM 内存分配可以知道 JAVA 中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠 GC 进行回收内存,如果对象数量较多的时候,会给 GC 带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM 通过逃逸分析确定该对象不会被外部访问。那就通过标量替换将该对象分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

总结

关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。

其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

虽然这项技术并不十分成熟,但是他也是即时编译器优化技术中一个十分重要的手段。

方法区

  • 方法区(Method Area)与 Java 堆一样,是所有线程共享的内存区域。
  • 方法区的大小和堆空间一样,可以选择固定大小也可选择可扩展,方法区的大小决定了系统可以放多少个类,如果系统类太多,导致方法区溢出,虚拟机同样会抛出内存溢出错误
  • JVM 关闭后方法区即被释放

方法区(method area) 只是 JVM 规范 中定义的一个 概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen) Hotspot 虚拟机特有的概念, Java8 的时候又被 元空间取代了,永久代和元空间都可以理解为方法区的落地实现。

Java7 中我们通过-XX:PermSize 和 -xx:MaxPermSize 来设置永久代参数,Java8 之后,随着永久代的取消,这些参数也就随之失效了,改为通过-XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 用来设置元空间参数

方法区内部结构

对象的创建

对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头实例数据对齐填充

直接内存

执行引擎

Java代码编译和执行的过程

什么事是解释器,什么是jit编译器

解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容翻译成对应平台的本地机器指令执行。

jit编译器:虚拟机将源代码直接编译成和本地机器平台相关的机器语言。

StringTable

String 在jdk1.8到1.9的变化

(1)jdk1.8之前的版本

private final char value[];

(2)jdk1.9以及之后的版本

private final byte[] value;

JDK8的字符串存储在char类型的数组里面,在java中,一个char类型占两个字节。但是很多时候,一个字符只需要一个字节就可存储,比如各种字母什么的,两个字节存储势必会浪费空间,JDK9的一个优化就在这,内存的优化,所以JDK9之后字符串改成byte类型数组进行存储。

private final byte coder;

  在JDK9的String类中,新增了一个属性coder,它是一个编码格式的标识,使用LATIN1还是UTF16,这个是在String生成的时候自动确定的,如果字符串中都是能用LATIN1编码表示,那coder的值就是0,否则就是UTF16编码,coder的值就是1。

字符串常量池中是不会存储相同内容的字符串。

1、String的String Pool 是一个固定大小的Hashtable,默认值大小长度是1009.如果放进String Pool 的String非常多,就会造成Hash冲突,导致链表会很长,造成的影响就是调用String,intern的时候性能会下降。

2、使用-XX:StringTableSize 可以设置StringTable的长度

3、jdk6中StringTable长度固定就是1009长度,StringTableSize设置没有要求。

4、jdk7中,StringTable的长度默认是60013,StringTableSize设置没有要求。

5、jdk8开始,设置StringTable的长度的话,1009是可设置的最小值。

String的内存分配

Java6及以前,字符串常量池存放在永久代。

Java7开始,字符串常量池的位置调整到Java堆内。

所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样在进行调优应用时仅需要调整堆大小就可以了。

垃圾回收

什么是垃圾

垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾

如果不及时对内存中的垃圾进行清理,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用,甚至可能导致内存溢出

判断一个对象是否可被回收

引用计数算法

对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。

给对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。

两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。

优点:

实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性

缺点:

需要单独的字段存储计数器,增加了存储空间的开销

每次赋值都需要更新计数器,伴随着加法和减法操作,增加了时间开销

无法处理循环引用的情况,导致Java垃圾回收器中没有使用这个算法。

代码证明:

//这个成员属性唯一的作用就是占用一点内存
    private byte[] bigSize = new byte[5 * 1024 * 1024];//5MB

    Object reference = null;

    public static void main(String[] args) {
        RefCountGC obj1 = new RefCountGC();
        RefCountGC obj2 = new RefCountGC();

        obj1.reference = obj2;
        obj2.reference = obj1;

        obj1 = null;
        obj2 = null;
        //显式的执行垃圾回收行为
        //这里发生GC,obj1和obj2能否被回收?
        System.gc();    
 }

可达性分析算法(根搜索算法、追踪性垃圾收集)

通过 GC Roots 作为起始点进行搜索,能够到达到的对象都是存活的,不可达的对象可被回收。

gcroots根集合就是一组必须活跃的引用。

Java 虚拟机使用该算法来判断对象是否可被回收,在 Java 中 GC Roots 一般包含以下内容:

  • 虚拟机栈中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中的常量引用的对象

对象的finalization机制

垃圾回收之前,总会调用这个对象的finalize方法。

finalize方法允许在子类中被重写,用于在对象被回收时进行资源释放。

永远不要主动调用某个对象的finalize方法,应该交给垃圾回收机制调用,原因:

在finalize时可能会导致对象复活

finalize方法的执行时间没有保障,完全由gc线程决定,极端情况下如果不发生gc 则finalize方法没有执行机会

一个糟糕的finalize方法会严重影响gc性能。

由于finalize方法的存在,虚拟机中的对象一般处于三种可能得状态。

1、可触及的;从根节点开始,可以到达这个对象

2、可复活的;对象的所有引用都被释放,但是对象有可能在finalize中复活。

3、不可触及的:对象的finalize被调用,并且没有复活,那就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize方法只会被调用一次。

具体过程:

判读一个对象objA是否可回收,至少需要经历两次标记过程。

1、如果对象objA到GC Roots没有引用链,则进行一次标记。

2、进行筛选,判断此对象是否有必要执行finalize()方法

如果对象objA没有重写finalize方法类,或者finalize方法已经被虚拟机调用过,则虚拟机视为‘没必要执行’,objA被判定为不可触及的。

如果对象objA重写了finalize方法,且还未执行过,那么objA会被插入到F- Queue队列中,由一个虚拟机自动创建的、低优先级的finalizer线程触发其finalize方法执行。

finalize方法是对象逃脱死亡的最后机会,稍后GC会对F- Queue队列中的对象进行第二次标记,如果objA在finalize方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出‘即将回收’集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize方法不会被再次调用,对象直接变成不可触及的状态,也就是说,一个对象的finalize方法只会被调用一次。

垃圾回收算法

标记-清除算法(Mark-sweep算法)

执行过程:

当堆中有效内存空间被耗尽的时候,就会停止整个程序(stop the world),然后进行两项工作,第一项是标记,第二项是清除。

标记:Collector从引用根节点 开始遍历,标记所有被引用的对象。一般是在对象的header中记录为可达对象

清除;Collector 对堆内存从头到尾进行线性遍历,如果发现某个对象在其header中没有标记为可达对象,则将其回收。

缺点:

效率不算高

在进行gc的时候,需要停止整个应用程序。导致用户体验差

这种方式清理出来的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表。

复制算法(copying)

让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

缺点:只使用了内存的一半。

标记压缩算法(标记整理算法 Mark-compact)

标记整理算法和标记清除算法非常的类似,主要被应用于老年代中。可分为以下两步:

  • 标记:和标记清除算法一样,先进行对象的标记,通过GC Roots节点扫描存活对象进行标记
  • 整理:将所有存活对象往一端空闲空间移动,按照内存地址依次排序,并更新对应引用的指针,然后清理末端内存地址以外的全部内存空间

优点:

1、消除了标记-清除算法当中内存区域分散的缺点,需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。

2、消除了复制算法中,内存减半的高额代价。

缺点:

从效率上来说,标记整理算法要低于复制算法。

移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址

移动过程中,需要全程暂停用户应用程序。即:STW

对比三种算法

Mark-sweepMark-CompactCopying
速度中等最慢最快
空间开销少(但会堆积碎片)少(不堆积碎片)通常需要活对象的2倍大小(不堆积碎片)
移动对象

效率上来说,复制算法最优,但是浪费了太多内存。

为了尽量兼顾三个指标,标记-整理算法相对来说更平滑一点,但是效率低,比复制算法多了一个标记阶段,比标记-清除多了一个整理内存的阶段。

增量收集算法

上面的算法,在垃圾回收过程中, 应用软件将处于一种stw的状态(Stop the World)的状态。

在stw状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收你时间过长,应用程序会挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集算法的诞生。

基本思想

如果一次性将所有垃圾处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到程序线程。一次反复,直到垃圾收集完成。

总的来说:增量收集算法的基础仍是传统的标记-清除算法和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的完成标记、清理或复制工作。

缺点:

使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,由于线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。

内存分配与回收策略

Minor GC、Major GC、Full GC

JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。

针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)

  • 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
      • 目前,只有 CMS GC 会有单独收集老年代的行为
      • 很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
      • 目前只有 G1 GC 会有这种行为
  • 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾

内存分配策略

1. 对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。

2. 大对象直接进入老年代

大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。

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

为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

-XX:MaxTenuringThreshold 用来定义年龄的阈值。

4. 动态对象年龄判定

虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

5. 空间分配担保

在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。

如果不成立的话虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。

触发FuLL GC 的条件

System.GC

通过System.GC,或者Runtime.getRunTime.gc调用。会显示触发FullGC,同时对新生代和老年代进行回收。

但是他只是提醒虚拟机 ,希望进行一次垃圾回收。

只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。

老年代空间不足

老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。

为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。

空间分配担保失败

使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC

JDK1.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

内存溢出和内存泄露

内存溢出:是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

内存泄漏:是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于能提供的最大内存。

垃圾回收器

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

  • 单线程与多线程: 单线程指的是垃圾收集器只使用一个线程进行收集,而多线程使用多个线程;
  • 串行与并行: 串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并形指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。

1. Serial 收集器

它是单线程的收集器,只会使用一个线程进行垃圾收集工作

优点:

它的优点是简单高效,对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。

2. ParNew 收集器

他是Serial 收集器的多线程版本。

Server 模式下的虚拟机首选新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。

默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。

3.Parallel Scavenge 收集器

与 ParNew 一样是多线程收集器。

其它收集器关注点是尽可能缩短垃圾收集时用户线 程的停顿时间,而它的目标是达到一个可控制的吞吐量,它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户代码的时间占总时间的比值

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

缩短停顿时间是以牺牲吞吐量和新生代空间来换取的: 新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。

可以通过一个开关参数打开 GC 自适应的调节策略(GC Ergonomics),就不需要手动指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

4.Serial Old 收集器

是 Serial 收集器的老年代版本,也是给 Client 模式下的虚拟机使用。如果用在 Server 模式下,它有两大用途:

  • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
  • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

5.Parallel Old 收集器

是 Parallel Scavenge 收集器的老年代版本。

在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

6.CMS 收集器

CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。

分为以下四个流程:

  • 初始标记: 仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
  • 并发标记: 进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
  • 重新标记: 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  • 并发清除: 不需要停顿。

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

具有以下缺点:

  • 吞吐量低: 低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
  • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
  • 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

7.G1 收集器

G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。

堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。

G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。

GC日志格式解读