Java、JVM、内存模型、垃圾回收
JVM介绍
JDK:JDK全称为Java Development Kit,顾名思义是java开发工具包,是程序员使用java语言编写java程序所需的开发工具包。
JRE:JRE全称为Java Runtime Environment,顾名思义是java运行时的环境,包含了java虚拟机,java基础类库,是使用java语言编写的程序运行所需要的软件环境。
JVM:JVM是JavaVirtualMachine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java语言最重要的特点就是跨平台运行。使用JVM就是为了支持与操作系统无关,实现跨平台。
常见的JVM 虚拟机 Hotspot 、 JRockit(Oracle)、J9(IBM)。
组成部分
JVM 主要组成部分
1、类加载器(ClassLoader)
2、运行时数据区(Runtime Data Area)
3、执行引擎(Execution Engine)
4、本地库接口(Native Interface)
首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。
类加载器
1、作用
JDK中javac.exe编译器将编写好的.Java文件编译成.class文件,然后类加载器将.class文件转换成IO流加载到JVM内存中。
2、种类
- 启动类加载器
- 扩展类加载器
- 应用程序类加载器
启动类加载器:启动类加载器不是ClassLoader的子类,由C++编写,因此在java中看不到他,负责装载JRE的核心类库,即<JAVA_HOME>/lib路径下的核心类库或-Xbootclasspath参数指定的路径下的jar包加载到内存中,如JRE目录下的rt.jar,charsets.jar等,出于安全考虑,Bootstrap启动类加载器其实只加载包名为java、javax、sun等开头的类。
扩展类加载器:ExtClassLoader是ClassLoder的子类,加载<JAVA_HOME>/lib/ext目录下或者由系统变量-Djava.ext.dir指定路径中的类库,即JRE扩展目录ext下的jar类包;
应用程序类加载器:加载系统类路径java -classpath或-D java.class.path 指定路径下的类库,也就是我们经常用到的classpath路径。通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器。
3、类加载过程
加载=》验证=》准备=》解析=》初始化=》使用=》卸载
1、加载:
加载指的是把class字节码文件从各个来源通过类加载器装载入内存中。
实际上java的每个类被编译成.class文件的时候,java虚拟机(jvm)会自动为这个类生成一个类对象,这个对象保存了这个类的所有信息(成员变量,方法,构造器等),以后这个类要想实例化(也就是创建类的实例或创建类的对象)那么都要以这个class对象为蓝图(或模版)来创建这个类的实例。
补充:
Class.forName与ClassLoader加载类的区别:Class.forName加载会对类进行初始化(类加载总共有以下几个过程:加载->验证->准备->解析->初始化,详情可自行百度),而ClassLoader不会。
public class Student {
private static String USERNAME = demo();
static {
System.out.println("静态代码块");
}
public static String demo(){
System.out.println("静态方法");
return "zhangsan";
}
}
public static void main(String[] args) throws ClassNotFoundException {
Class<?> cls1 = Class.forName("com.demo.myspringboot.demo.test.Student");
System.out.println("------------------");
Class<?> cls2 = ClassLoader.getSystemClassLoader().loadClass("com.demo.myspringboot.demo.test.Student");
}
结果:
2、验证:
①文件格式验证
四个验证过程中,只有格式验证是建立在二进制字节流的基础上的。格式验证就是对文件是否是0xCAFEBABE开头、class文件版本等信息进行验证,确保其符合JVM虚拟机规范。
这一阶段具体可能包括下面这些验证点:
- 是否以0xCAFEBABE开头。
- 主、次版本号是否在当前虚拟机处理范围之内。
- 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
- 指向常量的各种索引值是否有指向不存在的常量或不符合类型的常量。
- CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据。
- Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
实际上,第一阶段的验证点还远不止这些,上面这些只是从HotSpot虚拟机源码中摘抄的一小部分内容,该验证阶段的主要目的是保证输入的字节流能正确的解析并存储于方法区之内,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面3个验证阶段全部是基于方法区的存储结果进行的,不会再直接操作字节流。
②元数据验证
元数据验证是对源码语义分析的过程,验证的是子类继承的父类是否是final类;如果这个类的父类是抽象类,是否实现了其父类或接口中要求实现的所有方法;子父类中的字段、方法是否产生冲突等,这个过程把类、字段和方法看做组成类的一个个元数据,然后根据JVM规范,对这些元数据之间的关系进行验证。所以,元数据验证阶段并未深入到方法体内。
这一阶段具体可能包括下面这些验证点:
- 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)
- 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
③字节码验证
既然元数据验证并未深入到方法体内部,那么到了字节码验证过程,这一步就不可避免了。字节码主要是对方法体内部的代码的前后逻辑、关系的校验,例如:字节码是否执行到了方法体以外、类型转换是否合理等。
这一阶段具体可能包括下面这些验证点:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况
- 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
当然,这很复杂。所以,即使是到了jdk1.8,也还是无法完全保证字节码验证准确无遗漏的。而且,如果在字节码验证浪费了大量的资源,似乎也有些得不偿失。
④符号引用验证
符号引用的验证其实是发生在符号引用向直接引用转化的过程中,而这一过程发生在解析阶段。
这一阶段具体可能包括下面这些验证点:
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段
- 符号引用中的类、字段、方法的可访问性(private、protected、public、)是否可被当前类访问。
简单来说就是验证是否是正确的字节流、是否符合JVM加载的规范。
3、准备:
为 static 变量分配内存空间,设置默认值:
- static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于 _java_mirror 末尾
- static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
- 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
- 如果 static 变量是 final 的,但属于引用类型,即 new 对象,那么赋值也会在初始化阶段完成
4、解析:
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。
1、符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
2、直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
符号引用与虚拟机实现的内存布局无关,直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。
如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。
5、初始化:
初始化,则是为标记为常量值的字段赋值的过程。换句话说,只对static修饰的变量或语句块进行初始化。
如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。
如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。
会导致 类初始化 的情况:
- main 方法所在的类,总会被首先初始化
- 首次访问这个类的 静态变量 或 静态方法 时
- 子类初始化,如果父类还未初始化,会引发
- 子类访问父类的静态变量,只会触发父类的初始
- Class.forName
- new 会导致初始化
不会导致 类初始化 的情况:
- 访问 类的 static final 静态变量(基本类型和字符型)不会触发初始化
- 类对象.class 不会触发初始化
- 创建该类的数组不会触发初始化
- 类加载的 loadClass 方法
- Class.forName 的参数2 为 false 时
6、使用:即对象在JVM内存去的程序运行
7、卸载:程序结束,JVM关闭
4、类加载器双亲委托原则
1、原理:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
2、好处:
- 可以避免类的重复加载,当父类加载器已经加载了该类时,就没有必要子ClassLoader再加载一次。
- 考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Object的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Object,而直接返回已加载过的Object.class,这样便可以防止核心API库被随意篡改。
3、如何打破双亲委托(自定义类加载器)
双亲委派机制,可以避免类被重复加载,相同包路径下的同名类不会被重复加载,但在某些场景下我们需要有这样的重复加载,所以我们可以用自定义类加载器去打破这种关系机制。
类加载器的关键类是:ClassLoader,所以我们自定义类加载器需要继承ClassLoader,而且看它的主要方法是loadClass()和findClass()。因此需要重写这两个方法然后指定自定义的文件路径即可。
内存结构
1、堆Heap
Java堆也是被所有线程共享的一块内存区域,是JVM所管理的内存中最大的一块,它在虚拟机启动时创建。此区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
2、栈Stack
与方法区、Java堆不同,Java虚拟机栈是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行是同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
在Java 虚拟机规范中,对这个区域规定了两种异常状况:
- StackOverflowError: 异常线程请求的栈深度大于虚拟机所允许的深度时抛出;
- OutOfMemoryError 异常: 虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存时会抛出。
3、方法区MethodArea
方法区是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
4、本地方法栈Native Method Stack
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其
区别不过是虚拟机栈为虚拟机执行Java 方法(也就是字节码)服务,而本地方法栈则
是为虚拟机使用到的Native 方法服务。(简单地讲,一个Native Method就是一个java调用非java代码的接口。一个Native Method是这样一个java的方法:该方法的实现由非java语言实现,比如C。)
与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError 和OutOfMemoryError异常。
5、程序计数器Program counter register
程序计数器是一块较小的内存区域,它可以看做是当前线程执行的字节码的行号指示器。字节码解释器工作时,就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。由于Java是多线程的,在线程切换回来后,它需要知道原先的执行位置在哪里。就需要用程序计数器来记录这个执行位置的,保证线程间的计数器相互不影响,这个内存区域是线程私有的。
JVM参数
1、堆(Heap):
老年代空间大小=堆空间大小-年轻代大空间大小
-Xms:初始堆大小
物理内存的1/64(<1GB)默认(MinHeapFreeRatio参数可以调整)空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制.
-Xmx:最大堆大小
物理内存的1/4(<1GB)默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制
-Xmn:年轻代大小(1.4or lator)
-XX:NewRatio设置新生代与老年代在堆空间的大小
默认1:2
-XX:PermSize:设置持久代(perm gen)初始值
物理内存的1/64
-XX:MaxPermSize:设置持久代最大值
物理内存的1/4
-XX:SurvivorRatio:Eden区与Survivor区的大小比值
设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10
XX:+DisableExplicitGC:关闭System.gc() (这个参数需要严格的测试)
2、方法区
-XX:PermSize 设置永久代最小空间大小。
-XX:MaxPermSize 设置永久代最大空间大小。
3、栈/本地方法栈
-Xss 设置每个线程的堆栈大小。
年轻代、老年代、永久代、元空间
JVM堆内存 在物理上 分为三个区:
- 伊甸园 eden:最初对象都分配到这里,与幸存区合称新生代
- 幸存区 survivor:当伊甸园内存不足,回收后的幸存对象到这里,分成 from 和 to,采用复制算法。默认情况下,年轻代与老年代比例为1:2。
- 老年代 old:当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存区内存不足或大对象会导致提前晋升)
年轻代:
新生代主要用来存放新生的对象。一般占据堆空间的1/3。
在新生代中,保存着大量的刚刚创建的对象,但是大部分的对象都是朝生夕死,所以在新生代中会频繁的进行MinorGC,进行垃圾回收。新生代又细分为三个区:Eden区、From、To区,三个区的默认比例为:8:1:1。
Eden区:Java新创建的对象绝大部分会分配在Eden区(如果对象太大,则直接分配到老年代)。当Eden区内存不够的时候,就会触发MinorGC(新生代采用的是复制算法),对新生代进行一次垃圾回收。
Survivor区:包括 From区和To区
在GC开始的时候,对象只会存在于Eden区和名为From的Survivor区(第一次minorGC时from区也是空的),To区是空的,一次MinorGc过后,Eden区和Survivor的From区存活的对象会移动到Survivor的To区中,然后会清空Eden区和Survivor的From区,并对存活的对象的年龄+1,如果对象的年龄达到15,则直接分配到老年代。
补充:大对象就是需要大量连续内存空间的对象(比如:字符串、数组),JVM参数-XX:PretenureSizeThreshold可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代。因为新生代一般都用复制算法,为了避免为大对象分配内存时的复制操作而降低效率。
老年代:
老年代存放比较稳定存活的对象(存活15次以上)或者大对象对于老年代有Major GC的垃圾回收机制。当老年代也满了装不下的时候,就会抛出OOM(Out of Memory)异常。
有两个触发条件:
- 一个是当新生代发生minor GC之后,仍然不够位置存放新生对象时,借用老年代空间不足时,会发生major GC。
- 另一个是当申请一个大的连续空间(如大数组)给较大对象时,也会触发Major GC进行垃圾回收。
方法区、永久代、元空间:
- 方法区 是 JVM 的规范,所有虚拟机 必须遵守的。
- 永久代(PermGen)是 JDK7及之前, HotSpot 虚拟机 对 方法区 的一个落地实现。
- JDK8 中, Hotspot 已经没有永久代(PermGen),取而代之是元空间(Metaspace)。
1、为什么取消永久代
- 在原来的永久代划分中,永久代需要存放类的元数据、静态变量和常量等。它的大小不容易确定,因为这其中有很多影响因素,比如类的总数,常量池的大小和方法数量等,-XX:MaxPermSize 指定太小很容易造成永久代内存溢出。
- 移除永久代是为融合HotSpot VM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代。
- 永久代会为GC带来不必要的复杂度,并且回收效率偏低。
2、元空间的好处
- 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。不会遇到永久代存在时的内存溢出错误。
- 将运行时常量池从PermGen分离出来,与类的元数据分开,提升类元数据的独立性。
- 将元数据从PermGen剥离出来到Metaspace,可以提升对元数据的管理同时提升GC效率。
3、元空间参数
- -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
- -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。如果没有使用该参数来设置类的元数据的大小,其最大可利用空间是整个系统内存的可用空间。JVM也可以增加本地内存空间来满足类元数据信息的存储。但是如果没有设置最大值,则可能存在bug导致Metaspace的空间在不停的扩展,会导致机器的内存不足;进而可能出现swap内存被耗尽;最终导致进程直接被系统直接kill掉。如果设置了该参数,当Metaspace剩余空间不足,会抛出java.lang.OutOfMemoryError: Metaspace space
- -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集。
- -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集。
GC垃圾回收
GC (Garbage Collection:即垃圾回收)的基本原理:将内存中不再被使用的对象进行回收,GC中用于回收的方法称为收集器,由于GC需要消耗一些资源和时间,Java在对对象的生命周期特征进行分析后,按照新生代、老年代的方式来对对象进行收集,以尽可能的缩短GC对应用造成的暂停。
● 对新生代的对象的收集称为minor GC
● 对老年代的对象的收集称为major GC
● 对整个java堆和方法区的垃圾收集称为Full GC
触发条件
Minor GC:
Eden区满了以后,会检查进入老年代的大小是否大于老年代的剩余空间大小
大于:直接触发一次Full GC;
不大于:查看是否设置了-XX:+HandlePromotionFailure(允许担保失败)
允许:只会进行MinorGC,此时可以容忍内存分配失败;
不允许:进行Full GC.
(如果设置-XX:+Handle PromotionFailure不允许担保失败,则触发MinorGC就会同时触发Full GC,哪怕老年代还有很多内存,所以,最好不要这样做)
STW(Stop-The-World:是在垃圾回收算法执行过程当中,将JVM内存冻结、应用程序停顿的一种状态。除了GC所需的线程外,其他线程都将停止工作,中断了的线程直到GC任务结束才继续它们的任务)。
Major GC:
当老年代满时会触发MajorGC,只有CMS收集器会有单独收集老年代的行为,其他收集器均无此行为。而针对新生代的MinorGC,各个收集器均支持。总之,单独发生收集行为的只有新生代,除了CMS收集器,都不支持单独回收老年代。
Concurrent Mark Sweep (CMS) 收集器是hotspot虚拟机中一款低延迟的并发型垃圾收集器。
Full GC:
- System.gc()方法的调用:
在代码中调用System.gc()方法会建议JVM进行Full GC,但是注意这只是建议,JVM执行不执行是另外一回事儿,不过在大多数情况下会增加Full GC的次数,导致系统性能下降,一般建议不要手动进行此方法的调用,可以通过-XX:+ DisableExplicitGC来禁止RMI调用System.gc。
补充:
System.gc();//底层是调用Runtime.getRuntime().gc(),提醒jvm的垃圾回收器执行gc,但是不确定是否马上执行gc
System.runFinalization();//强制调用 失去引用的对象的finalize()方法
- 老年代(Tenured Gen)空间不足:
在Survivor区域的对象满足晋升到老年代的条件时,晋升进入老年代的对象大小大于老年代的可用内存,这个时候会触发Full GC。
- Metaspace区内存达到阈值:
从JDK8开始,永久代(PermGen)的概念被废弃掉了,取而代之的是一个称为Metaspace的存储空间。Metaspace使用的是本地内存,而不是堆内存,也就是说在默认情况下Metaspace的大小只与本地内存大小有关。-XX:MetaspaceSize=21810376B(约为20.8MB)超过这个值就会引发Full GC,这个值不是固定的,是会随着JVM的运行进行动态调整的,与此相关的参数还有多个,详细情况请参考这篇文章jdk8 Metaspace 调优
- 统计得到的Minor GC晋升到旧生代的平均大小大于老年代的剩余空间:
Survivor区域对象晋升到老年代有两种情况:
一种是给每个对象定义一个对象计数器,如果对象在Eden区域出生,并且经过了第一次GC,那么就将他的年龄设置为1,在Survivor区域的对象每熬过一次GC,年龄计数器加一,等到到达默认值15时,就会被移动到老年代中,默认值可以通过-XX:MaxTenuringThreshold来设置。
另外一种情况是如果JVM发现Survivor区域中的相同年龄的对象占到所有对象的一半以上时,就会将大于这个年龄的对象移动到老年代,在这批对象在统计后发现可以晋升到老年代,但是发现老年代没有足够的空间来放置这些对象,这就会引起Full GC。
- 堆中产生大对象超过阈值:
这个参数可以通过-XX:PretenureSizeThreshold进行设定,大对象或者长期存活的对象进入老年代,典型的大对象就是很长的字符串或者数组,它们在被创建后会直接进入老年代,虽然可能新生代中的Eden区域可以放置这个对象,在要放置的时候JVM如果发现老年代的空间不足时,会触发GC。
- 老年代连续空间不足:
JVM如果判断老年代没有做足够的连续空间来放置大对象,那么就会引起Full GC,例如老年代可用空间大小为200K,但不是连续的,连续内存只要100K,而晋升到老年代的对象大小为120K,由于120>100的连续空间,所以就会触发Full GC。
- CMS GC时出现promotion failed和concurrent mode failure
提升失败(promotion failed),在 Minor GC 过程中,Survivor Unused 可能不足以容纳 Eden 和另一个 Survivor 中的存活对象, 那么多余的将被移到老年代, 称为过早提升(Premature Promotion)。 这会导致老年代中短期存活对象的增长, 可能会引发严重的性能问题。 再进一步, 如果老年代满了, Minor GC 后会进行 Full GC, 这将导致遍历整个堆, 称为提升失败(Promotion Failure)。
如何确认被定义为垃圾的对象
1、引用计数法
引用计数法:给对象添加一个引用计数器,每当有一个地方引用它,计数器值就加一;相反的,当引用失效的时候,计数器值就减一;任何时刻计数器为0的对象就是不可能再被使用的。
优点:
无需等到内存不够的时候,才开始回收,运行时根据对象的计数器是否为0,就可以直接回收;更新对象的计数器时,只是影响到该对象,不会扫描全部对象。
缺点:
无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。
2、可达性分析法
可达性算法是目前主流的虚拟机都采用的算法,程序把所有的引用关系看作一张图,从一个节点GC Roots开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。
在Java语言中,可作为GC Roots的对象包括下面几种:
● 虚拟机栈中引用的对象(栈帧中的本地变量表);
● 方法区中类静态属性引用的对象;
● 方法区中常量引用的对象;
● 本地方法栈中JNI(Native方法)引用的对象。
对于可达性分析算法而言,若要判断一个对象死亡,需要经历两次标记阶段。
第一次标记:对象在进行可达性分析后发现没有与GCRoots相连的引用链,则该对象被第一次标记并进行一次筛选,筛选条件为是否有必要执行该对象的finalize方法:
1、若对象没有覆盖finalize方法或者该finalize方法是否已经被虚拟机执行过了,则均视作不必要执行该对象的finalize方法,即该对象将会被回收;
2、若对象覆盖了finalize方法并且该finalize方法并没有被执行过,这个对象会被放置在一个叫F-Queue的队列中,之后会由虚拟机自动建立的、优先级低的Finalizer线程去执行。
第二次标记:对F-Queue中对象进行第二次标记
1、如果对象在finalize方法中拯救了自己,即关联上了GCRoots引用链,那么在第二次标记的时候该对象将从“即将回收”的集合中移除;
2、如果对象还是没有拯救自己,那就会被回收。
对象引用的类型
1、强引用
代码中普遍存在的类似"Object obj = new Object()"这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
2、软引用
描述有些还有用但并非必需的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。Java中的类SoftReference表示软引用。
3、弱引用
描述非必需对象。被弱引用关联的对象只能生存到下一次垃圾回收之前,垃圾收集器工作之后,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。Java中的类WeakReference表示弱引用。
4、虚引用
这个引用存在的唯一目的就是在这个对象被收集器回收时收到一个系统通知,被虚引用关联的对象,和其生存时间完全没关系。Java中的类PhantomReference表示虚引用。
垃圾回收算法
1、标记-清除(Mark-Sweep)算法
首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。
缺点:
- 从效率的角度讲,标记和清除两个过程的效率都不高;
- 从空间的角度讲,标记清除后会产生大量不连续的内存碎片。(内存碎片太多可能会导致以后程序运行过程中在需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作)
2、复制(Copying)算法
将可用的内存分为两块,每次只用其中一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉。
优点:这样每次只需要对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等复杂情况,只需要移动指针,按照顺序分配即可。
缺点:对象存活率较高的场景下要进行大量的复制操作,效率很低。万一对象100%存活,那么需要有额外的空间进行分配担保。(因此不适合老年代)
3、标记-整理(Mark-Compact)算法
过程与标记-清除算法一样,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉没有用到的内存。
总结:
大批对象死去、少量对象存活的(新生代),使用复制算法,复制成本低;对象存活率高、没有额外空间进行分配担保的(老年代),采用标记-清理算法或者标记-整理算法。
垃圾回收器
垃圾回收器的种类
1、根据线程分:
- 串行回收器:Serial、Serial old
- 并行回收器:ParNew、Parallel Scavenge、Parallel old
- 并发回收器:CMS、G1
2、根据区块分:
- 新生代收集器:Serial、ParNew、Parallel Scavenge;
- 老年代收集器:Serial old、Parallel old、CMS;
- 整堆收集器:G1;
垃圾回收器的性能指标
- 吞吐量:运行用户代码的时间栈总运行时间的比例。(总运行时间:程序的运行时间+内存回收时间)
- 垃圾收集开销:垃圾收集所用时间与总运行时间的比例。
- 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
- 收集频率:相对于应用程序的执行,收集操作发生的频率。
- 内存占用:Java堆区所占的内存大小。
- 快速:一个对象从诞生到被回收所经历的时间。
七种主流的垃圾回收器介绍:
1、Serial(单线程,JDK1.3)
新生代收集器,使用停止复制算法,使用一个线程进行GC,串行,其它工作线程暂停。
单线程串行:进行垃圾收集时必须暂停其他线程的所有工作,直到它收集结束为止。
在用户不可见的情况下要把用户正常工作的线程全部停掉(Stop The World)。
不过实际上到目前为止,Serial收集器依然是虚拟机运行在Client模式下的默认新生代收集器,因为它简单而高效。用户桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代停顿时间在几十毫秒最多一百毫秒,只要不是频繁发生,这点停顿是完全可以接受的。
使用-XX:+UseSerialGC可以使用Serial+Serial Old模式运行进行内存回收(这也是虚拟机在Client模式下运行的默认值)
2、ParNew(响应时间)
新生代收集器,使用停止复制算法,用多个线程进行GC(Serial收集器的多线程版),并行,其它工作线程暂停,关注缩短垃圾收集时用户线程的停顿时间。
Server模式下的虚拟机首选的新生代收集器,因为除了Serial收集器外,目前只有它能与CMS收集器配合工作。
使用-XX:+UseParNewGC开关来控制使用ParNew+Serial Old收集器组合收集内存;使用-XX:ParallelGCThreads来设置执行内存回收的线程数。
3、Parallel Scavenge(吞吐量)
新生代收集器,使用停止复制算法,多线程,并行,关注CPU吞吐量。
吞吐量=运行用户代码的时间/总时间,比如:JVM运行100分钟,其中运行用户代码99分钟,垃圾收集1分钟,则吞吐量是99%。反映CPU使用效率。
CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是打到一个可控制的吞吐量。
停顿时间短适合需要与用户交互的程序,良好的响应速度能提升用户体验;高吞吐量则可以高效率利用CPU时间,尽快完成运算任务,主要适合在后台运算而不需要太多交互的任务。
使用-XX:+UseParallelGC开关控制使用Parallel Scavenge+Serial Old收集器组合回收垃圾(这也是在Server模式下的默认值);使用-XX:GCTimeRatio来设置用户执行时间占总时间的比例,默认99,即1%的时间用来进行垃圾回收。使用-XX:MaxGCPauseMillis设置GC的最大停顿时间(这个参数只对Parallel Scavenge有效)
用开关参数-XX:+UseAdaptiveSizePolicy可以进行动态控制,如自动调整Eden/Survivor比例,老年代对象年龄,新生代大小等,这个参数在ParNew下没有。
4、Serial Old(单线程)
Serial的老年代版本,采用的是标记-整理法
5、Parallel Old(吞吐量)
Parallel Scavenge的老年代版本,采用标记-整理法
6、CMS(响应时间)
老年代收集器,使用标记清除算法,多线程,优点是并发收集(用户线程可以和GC线程同时工作),停顿小。以获取最短回收停顿时间为目标
使用-XX:+UseConcMarkSweepGC进行ParNew+CMS+Serial Old进行内存回收,优先使用ParNew+CMS,当用户线程内存不足时,采用备用方案Serial Old收集(悲观full gc)。
过程:
(1)初始标记,标记GCRoots能直接关联到的对象,stop the world,时间很短。
(2)并发标记,标记GCRoots可达的对象,和应用线程并发执行,不需要用户停顿,时间很长。
(3)重新标记,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,stop the world,时间较初始标记阶段长。
(4)并发清除,回收内存空间,和应用线程并发执行,时间很长。
缺点:
(1)需要消耗额外的CPU和内存资源,在CPU和内存资源紧张,CPU较少时,会加重系统负担(CMS默认启动线程数为(CPU数量+3)/4)。
(2)在并发收集过程中,用户线程仍然在运行,所以可能产生“浮动垃圾”,本次无法清理,只能下一次Full GC才清理。
因此在GC期间,需要预留足够的内存给用户线程使用。所以使用CMS的收集器并不是老年代满了才触发Full GC,而是在使用了一大半的时候就要进行Full GC。
(默认68%,即2/3,使用-XX:CMSInitiatingOccupancyFraction来设置)
如果预留的用户线程内存不够,则会触发Concurrent Mode Failure,此时将触发备用方案:使用Serial Old 收集器进行收集,但这样停顿时间就长了。
如果用户线程消耗内存不是特别大,可以适当调高-XX:CMSInitiatingOccupancyFraction以降低GC次数,提高性能。
(3)CMS采用的是标记清除算法,会导致内存碎片的产生
可以使用-XX:+UseCMSCompactAtFullCollection来设置是否在Full GC之后进行碎片整理
用-XX:CMSFullGCsBeforeCompaction来设置在执行多少次不压缩的Full GC之后,来一次带压缩的Full GC
7、G1回收器
G1是目前技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。与其他GC收集器相比,G1收集器有以下特点:
(1)并行+并发。使用多个CPU来缩短Stop The World停顿时间,与用户线程并发执行。
(2)分代收集。独立管理整个堆,但是能够采用不同的方式去处理新创建对象和已经存活了一段时间、熬过多次GC的旧对象,以获取更好的收集效果。
(3)空间整合。基于标记 - 整理算法,无内存碎片产生。
(4)可预测的停顿。能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
在G1之前的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分(可以不连续)Region的集合。
适用场景:要求尽可能可控GC停顿时间;内存占用较大的应用。可以用 -XX:+UseG1GC 使用 G1 收集器(jdk9 默认使用 G1 收集器。)
补充
三色标记法:
黑色:表示该对象已经被标记过了,且该对象下的属性也全部都被标记过了,例如:GCRoots对象
灰色:对象已经被垃圾收集器扫描过了,但是对象中还存在没有扫描的引用(GC需要从此对象中去寻找垃圾)
白色:表示该对象没有被垃圾收集器访问过,即表示不可达。
三色表记法过程:
- 初始时,全部对象都是白色的
- GC Roots直接引用的对象变为灰色
- 从灰色集合中获取元素;将本对象直接引用的对象标记为灰色;然后将当前的对象标记为黑色。
- 重复步骤3,直到灰色的对象集合全部变为空
- 结束后,仍然被标记为白色的对象就是不可达对象,就视为垃圾对象。
当Stop The Word时,对象间的引用是不会发生变化的,因为此时用户线程是中断的,可以轻松完成标记。但是在并发标记的时候,标记期间用户线程还在跑,对象间的引用可能发生变化,多标和漏标的情况就可能会发生。
内存屏障:
内存屏障,也称内存栅栏,内存栅障,屏障指令等, 是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。
1、产生的原因:
程序在运行时内存实际的访问顺序和程序代码编写的访问顺序不一定一致,这就是内存乱序访问。内存乱序访问行为出现的理由是为了提升程序运行时的性能。内存乱序访问主要发生在两个阶段:
- 编译时,编译器优化导致内存乱序访问(指令重排)
- 运行时,多 CPU 间交互引起内存乱序访问
2、作用:
内存屏障存在的意义就是为了解决程序在运行过程中出现的内存乱序访问问题,内存乱序访问行为出现的理由是为了提高程序运行时的性能,Memory Bariier能够让CPU或编译器在内存访问上有序。
吞吐量和响应时间:
- 吞吐量:用户代码执行时间/(用户代码执行时间+GC执行时间)。
- 响应时间:整个接口的响应时间(用户代码执行时间+GC执行时间),stw时间越短,响应时间越短。
JVM调优
内存溢出和内存泄露
内存溢出:
是指程序在申请内存时,没有足够的内存空间供其使用,出现 out of memory 。比如新增对象需要10MB 空间, 但是当前内存只有5MB,那就是内存溢出。
javadoc 对 OutOfMemoryError 的解释是, 没有空闲内存,垃圾收集器也无未能提供更多的内存空间 。
内存泄漏:
是指程序运行结束后,没有释放已所占用的内存空间。
1)单例模式
单例的生命周期和应用程序是一样长的,所以单例程序中如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄露的产生。
2)一些提供close的资源未闭导致内存泄漏
数据库连接(dataSource.getConnection() ),网络连接(socket)和 IO流的连接必须在finally中 close,否则不能被回收的。
频繁Full GC怎么办
清楚从程序角度,有哪些原因导致Full G
- 大对象:系统一次性加载了过多数据到内存中(比如SQL查询未做分页),导致大对象进入了老年代。
- 内存泄漏:频繁创建了大量对象,但是无法被回收(比如IO对象使用完后未调用close方法释放资源),先引发FGC,最后导致OOM。
- 程序BUG
- 代码中显示调用了System.gc(),包括自己的代码和依赖jar包中的代码。
- JVM参数设置问题:包括总内存大小、新生代和老年代大小、Eden区、S0区、S1区、元空间大小、垃圾回收算法等等。
清楚排查问题时使用哪些工具
- 公司自己的的监控系统: 大部分公司都会用,可全方位监控JVM的各项指标。
- JDK自带的工具,包括jmap、jstat等常用命令。
# 查看堆内存各区域的使用率以及GC情况:jstat -gcutil -h20 pid 1000
# 查看堆内存中存活对象,并按空间排序:jmap -histo pid | head -n20
# dump堆内存文:jmap -dump:format=b,file=heap pid
- 可视化的堆内存分析工具:VisualVM、MAT等。
排查注意事项
- 查看监控,以了解出现问题的时间点以及当前FGC的频率(对比正常频率)
- 了解该时间点之前有没有程序上线、基础组件升级等情况。
- 了解JVM的参数设置,包括:堆空间各个区域的大小设置,新生代和老年代分别采用了哪些垃圾收集器,然后分析JVM参数设置是否合理。
- 再对步骤1中列出的可能原因做排除法,其中元空间被打满、内存泄漏、代码显式调用gc方法比较容易排查。
- 针对大对象或者长生命周期对象导致的FGC,可通过 jmap -histo 命令并结合dump堆内存文件作进一步分析,需要先定位到可疑对象。
- 通过可疑对象定位到具体代码再次分析,这时候要结合GC原理和JVM参数设置,弄清楚可疑对象是否满足了进入到老年代的条件才能下结论。
JVM调优六大步骤
1.监控GC的状态
使用各种JVM工具,查看当前日志,分析当前JVM参数设置,并且分析当前堆内存快照和gc日志,根据实际的各区域内存划分和GC执行时间,觉得是否进行优化。
系统崩溃前的一些现象:
- 每次垃圾回收的时间越来越长,由之前的10ms延长到50ms左右;Full GC的时间从之前的0.5s延长到4、5s。
- Full GC的次数越来越多,最频繁时隔不到1分钟就进行一次Full GC。
- 年老代的内存越来越大并且每次Full GC后年老代没有内存被释放之后系统会无法响应新的请求,逐渐到达OutOfMemoryError的临界值,这个时候就需要分析JVM内存快照dump。
2.生成堆的dump文件
通过JMX的MBean生成当前的Heap信息,大小为一个3G(整个堆的大小)的hprof文件,如果没有启动JMX可以通过Java的jmap命令来生成该文件。
3.分析dump文件
打开这个3G的堆信息文件,显然一般的Window系统没有这么大的内存,必须借助高配置的Linux,几种工具打开该文件:
- Visual VM
- IBM HeapAnalyzer
- JDK 自带的Hprof工具
- Mat (Eclipse专门的静态内存分析工具)
4.分析结果,判断是否需要优化
如果各项参数设置合理,系统没有超时日志出现,GC频率不高,GC耗时不高,那么没有必要进行GC优化,如果GC时间超过1-3秒,或者频繁GC,则必须优化。
5.调整GC类型和内存分配
如果内存分配过大或过小,或者采用的GC收集器比较慢,则应该优先调整这些参数,并且先找1台或几台机器进行beta,然后比较优化过的机器和没有优化的机器的性能对比,并有针对性的做出最后选择。
6.不断的分析和调整
通过根据自己项目进行不断的试验和试错,分析并找到最合适的参数,如果找到了最合适的参数,则将这些参数应用到所有服务器。