深入理解Java虚拟机(JVM)
2021-03-02 23:16:09 0 举报
AI智能生成
深入理解Java虚拟机(JVM),目前只做了运行数据区和回收算法模块
作者其他创作
大纲/内容
运行时数据区域
对象创建
线程私有
程序计数器
是一块较小的内存空间,可看作是当前线程所执行的字节码的行号指示器,存储的是下一条指令的地址
字节码解释器就是通过这个计数器的值来选取下一条需要执行的字节码指令
为了线程切换之后能回复到正确的执行位置,每条线程都需要有一条独立的程序计数器
互不影响
独立存储
Java的多线程是通过线程轮流切换、分配处理器时间方式实现的,对于一核处理器智慧执行一条线程指令
线程正在执行
Java方法,则记录的是正在执行的虚拟机字节码指令的地址
本地(Native)方法,则计数器值应为空
程序控制流的指示器,分支、循环、跳转异常处理、线程恢复等基础功能都需要依赖程序计数器完成
此内存区域是唯一一个在虚拟机规范中没有规定任何OutOfMemoryError情况的区域
常见问题
为什么使用PC寄存器记录当前线程的执行地址呢
记录线程的上下文环境。 当线程进行上下文切换(Context Switch)发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态。Java中对应的概念就是程序计数器,它的作用是记住下一条JVM指令的执行地址,是线程私有的。
PC寄存器为什么会被设定为线程私有
Java虚拟机的多线程实现是通过CPU分配执行时间来对线程轮流切换执行的,也就是说:在同一时刻,一个处理器只会执行一个线程中的指令
PC计数器存储当前线程指令的地址,为了保证能够准确地记录各个线程正在执行的当前字节码指令的地址,所以它是线程私有的,每个线程都会有自己的PC计数器。
Java虚拟机栈
Java虚拟机栈描述的是Java方法执行的线程内存模型
虚拟机栈的基本单元是栈帧(Stack Frame),每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧存储。
局部变量表(Local Variables)
存储空间单位:局部变量槽
局部变量表是一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量
字节码表示:LocalVariableTable
也称“本地变量表”
存放编译可知的各种Java虚拟机数据
基本数据类型
boolean
char
short
int
byte
float
long
double
引用类型(reference引用指针类型)
指向对象起始地址的引用指针
对象句柄或其他与对象相关位置地址
returnAddress(返回值类型)
指向一条字节码指令的地址
异常表
局部变量表所需的空间在编译期间完成分配,所以方法调用是局部变量表的空间是确定的,在运行期间不会改变局部变量表的大小(变量槽个数)
Java虚拟机使用局部变量表来完成参数传递
在非静态方法被调用
局部变量表自动添加一个“this”作为第0位索引--指代实例对象
在方法中未被初始化的局部变量,无法加入到局部变量表中(在常量池中也没有),则无法使用
而类变量,则只需有系统初始化值就可以,无任何歧义
在方法代码块中,若未被使用也会被优化,不存在常量池和局部变量表中
原因:虚拟机的编译器优化有直接关系
解释执行:通常与概念模型(自己写的代码)比较接近
即时编译:施加了各种编译优化措施,与概念模型相差非常大,只保证程序执行结果与概念一致
如:赋值null,被优化掉
如:代码块中变量未被使用,被优化掉
为什么方法参数传递的时候,参数过多的时候,需要建立对象包装
原因:每个参数都会占用一个变量槽,而对象也是占用一个,包装成对象的话就会变成一个
操作数栈(Operand Stack)
主要用来保存计算过程的中间结果,同时作为计算过程中临时变量的存储空间
后入先出(LIFO)栈
操作数栈对于数据的存储跟局部变量表是一样的,但是跟局部变量表不同的是,操作数栈对于数据的访问不是通过下标,而是通过标准的栈操作来进行的(压入与弹出)
数据的计算是由CPU完成,CPU在执行指令时每次会从操作数栈中弹出所需的操作数,经过计算后再压入到操作数栈顶
Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
由于操作数是存储在内存中的,因此频繁的执行内存读/写操作必然会影响速率,为了解决这个问题,HotSpotJVM的设计者门提出了栈顶缓存技术,将栈顶元素全部缓存在物理CPU寄存器中,以此降低堆内存的读写次数,提升执行引擎的执行执行效率。
操作过程
栈帧的局部优化
实际虚拟机实现时会做一些优化处理,让两个栈帧的「部分操作栈」、「部分局部变量表出现重叠」,这样可以节省一部分空间,方法调用时直接可以共享一部分数据,无须进行额外的参数复制
动态链接(Dynamic Linking)
动态链接的作用就是为了将符号引用转换为直接引用的
符号引用:就是常量池中的一个字符串
命令:javap -c -verbose ClassTest.class
verbose:查看详细信息
符号引用在编译期间可以被确定,会在类加载过程的解析阶段被JVM转化为直接引用,这个过程就被称为静态解析
位置:class文件中的常量池
时间:类加载时
直接引用:是指向方法的真正的入口
在编译期不能确定类型,会在运行的时候在运行时常量池中被JVM转化为直接引用,这个过程被称为 动态连接
位置:方法区的运行时常量池
时间:字节码解释执行时
方法出口(Return Address)
方法的结束,有两种方式
正常执行完成
方法调用者的PC寄存器的值作为返回地址
出现未处理的异常,非正常退出
返回地址通过异常表来确定,栈帧中不会存储这部分信息
附加信息
携带与Java虚拟机实现相关的一些附加信息
JVM指令手册:https://gourderwa.blog.csdn.net/article/details/103976523
JVM 栈配置
-Xss
-Xss1m 默认1M
异常
StackOverflowError
栈溢出
主要是线程请求的栈深度大于虚拟机所允许的深度
影响栈深度元素
方法不断调用,不断创建栈帧,如:递归
局部变量的个数大小
OutOfMemoryError
内存溢出
栈扩展时无法申请到足够的内存导致
本地方法栈
与虚拟机栈作用相似,本地方法栈用于管理本地方法的调用,不再受虚拟机限制
作用是融合不同的编程语言为Java所用
目前主要是的本地实现:C语言
权限
本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
可以直接使用本地处理器中的寄存器
直接从本地内存的堆中分配任意数量的内存
异常
StackOverflowError
OutOfMemoryError
线程共享
Java堆
是Java虚拟机中所管理的内存最大的一个区域
主要存放的数据
静态成员变量
字符串常量池
对象实例
数组对象
在方法结束后,堆中的对象不会立刻被移除,只有在执行引擎垃圾回收的时候才会被移除。
是被所有的线程共享的一块内存区域,虚拟机启动时自动创建
此区域唯一目的就是存放对象实例
所有的对象实例都在这里分配内存
HotSport虚拟机对象的探秘
对象的创建
创建对象的方式
new
最常见的创建方式
单例模式
工厂模式
Hello h = new Hello();
Reflect
反射的机制创建对象
Class类的newInstance方法
Class helloClass = Class.forName("com.xxx.Hello");
Hello h =(Hello) helloClass.newInstance();
Hello h =(Hello) helloClass.newInstance();
Constructor类的newInstance方法
//获取类对象
Class helloClass = Class.forName("com.xxx.Hello");
//获取构造器
Constructor constructor = helloClass.getConstructor();
Hello h =(Hello) constructor.newInstance();
Class helloClass = Class.forName("com.xxx.Hello");
//获取构造器
Constructor constructor = helloClass.getConstructor();
Hello h =(Hello) constructor.newInstance();
clone
clone时,需要已经有一个分配了内存的源对象,创建新对象时,首先应该分配一个和源对象一样大的内存空间。
要调用clone方法的类需要实现Cloneable接口,由于clone方法是protected的,所以修改Hello类
要调用clone方法的类需要实现Cloneable接口,由于clone方法是protected的,所以修改Hello类
public class Hello implements Cloneable
浅复制
Hello h1 = new Hello();
Hello h2 = (Hello)h1.clone();
Hello h2 = (Hello)h1.clone();
序列化机制
序列化
使用序列化时,要实现实现Serializable接口,将一个对象序列化到磁盘上或网络流传输的形式过程
反序列化
反序列化可以将网络或磁盘上的对象二进制流信息转化到内存中
Hello h = new Hello();
//准备一个文件用于存储该对象的信息
File f = new File("hello.obj");
FileOutputStream fos = new FileOutputStream(f);
ObjectOutputStream oos = new ObjectOutputStream(fos);
FileInputStream fis = new FileInputStream(f);
ObjectInputStream ois = new ObjectInputStream(fis)
//序列化对象,写入到磁盘中
oos.writeObject(h);
//反序列化对象
Hello newHello = (Hello)ois.readObject();
//准备一个文件用于存储该对象的信息
File f = new File("hello.obj");
FileOutputStream fos = new FileOutputStream(f);
ObjectOutputStream oos = new ObjectOutputStream(fos);
FileInputStream fis = new FileInputStream(f);
ObjectInputStream ois = new ObjectInputStream(fis)
//序列化对象,写入到磁盘中
oos.writeObject(h);
//反序列化对象
Hello newHello = (Hello)ois.readObject();
创建对象的过程
当虚拟机遇到一条字节码new指令时,会有以下操作过程:
判断对象对应的类是否加载、链接、初始化
给对象分配内存空间,堆上分配策略
如果内存规整
指针碰撞
假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着指针做为分界点,所分配的内存就是指针向空闲内存挪动对象大小相等的距离,这种分配方式称做”指针碰撞“
如果内存不规整
空闲列表
假设Java堆中内存不规整,已使用和空闲的交错一起,虚拟机需要维护一个可用的内存块列表,分配的时候找到一个足够大的列表给对象实例,并更新列表,这种分配方式称为“空闲列表”
分配内存空间时,会引发并发安全问题
问题:在修改一个指针所指向的位置,在并发情况下并不是线程安全的。如:正在给对象A分配内存,指针还未即时修改,对象B同事使用类原来的指针来分配内存的情况。
方案一:虚拟机采用CAS配上失败重试方式保证更新操作的原子性
方案二:采用本地线程分配缓冲(ThreadLocalAllocBuffer TLAB)
把内存分配的动作按照线程划分不同的空间进行,每个线程在Java堆中预先初始化分配小块内存,变成线程私有
先在缓冲区中分配内存,缓冲区不足去Java堆中分配新的缓冲区(需要同步锁定),再去从缓冲区分配内存(只能在本地线程缓冲区中获取)
虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定
-XX:UseTLAB
对象默认初始化赋值
虚拟机将分配到的内存空间都初始化为零值
保证对象实例字段在不赋初始值就直接使用,使程序能访问到这些数据类型所对应的零值
设置对象的对象头
执行<init>方法,初始化构造函数
一般来说,new指令之后会接着执行<init>()方法
对象的内存布局
32位对象头
自身运行时数据(32位 Mark Word)
对象哈希码(identity_hashcode)
25位的对象标识Hash码,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor中
GC分代年龄(age)
4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。
当对象达到设定的阈值时,将会晋升到老年代。
默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。
由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
当对象达到设定的阈值时,将会晋升到老年代。
默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。
由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
-XX:MaxTenuringThreshold
晋升老年代的年龄阈值
锁状态标志位(lock)
2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。
该标记的值不同,整个mark word表示的含义不同
该标记的值不同,整个mark word表示的含义不同
是否偏向锁(biased_lock)
对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
指向栈中锁记录的指针(ptr_to_lock_record)
指向重量级锁(ptr_to_heavyweight_monitor)
持有偏向锁的线程ID(thread)
偏向时间戳(epoch)
Marked for GC
代表被垃圾回收了
类型指针(Klass Point)
指向类型元数据指针,来确定该对象时那个类的实例
该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。
应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。为了节约内存可以使用选项+UseCompressedOops开启指针压缩
每个Class的属性指针(即静态变量)
每个对象的属性指针(即对象变量)
普通对象数组的每个元素指针
+UseCompressedOops
不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等
并不是所有的虚拟机实现都必须在对象数据上保留类型指针(通过句柄池访问)
若是对象时数组,还需要记录数组长度的数据块
如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,这部分数据的长度也随着JVM架构的不同而不同
实例数据(Instance Data)
是对象真正存储的有效信息程序代码
定义的各种类型字段内容:父类和子类,其存储顺序
会受到虚拟机分配策略参数影响
-XX:FiledsAllocationStyle
字段在程序代码中定义的顺序的影响
HotSpot默认分配策略
分配顺序:long/double、int、short、char、byte、boolean、oop
相同宽度的字段总是被分配到一起存放
父类中定义的变量出现在子类之前
为了节省空间可以配置HotSpot虚拟机参数,将子类中较窄的变量也也允许插入父类变量的空隙中
+XX:CompactFields
对齐填充(Padding)
JVM要求java的对象占的内存大小应该是8bit的倍数,所以后面有几个字节用于把对象的大小补齐至8bit的倍数
并不是必然存在现象,没有特殊意义,只起到占位符的作用
对象内存布局针对不同系统的操作位数不同结构内存不同
64位系统会让对象内存产生多余的未使用(unused)浪费,可进行压缩至与32位系统一致结构,jdk8版本是默认开启指针压缩
-XX:+UseCompressedOops
对象的访问定位
不同的虚拟机可以实现不同的定位方式
句柄池访问
堆中开辟一块内存作为句柄池,存储两个地址
对象实例数据(属性值结构体)的内存地址
指向堆中
访问类型数据的内存地址(类信息,方法类型信息)
指向方法区
优缺点
优点:reference存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要改变
缺点:增加了一次指针定位的时间开销
直接指针访问
指针访问方式指reference中直接储存对象在堆中的内存地址,但对应的类型数据访问地址需要在对象实例中存储。
优缺点
优点:节省了一次指针定位的开销
缺点:在对象被移动时(如进行GC后的内存重新排列),reference本身需要被修改
选择:由于对象访问在Java中非常频繁,HotSpot主要采用第二种方式进行对象访问(直接指针访问)
垃圾收集器执行的内存区域
在不同的虚拟机版本和场景下将其分为好多区域
新生代
Eden
From Survivor
TO Survivor
老年代
永久代
在JDK8废弃变为元空间
TLAB快速分配策略:Java堆中可以划分出多个线程私有的分配缓冲区(TLAB)
以提升对象分配的效率
以提升对象分配的效率
位置:Eden区域中划分
一旦对象在TLAB空间分配内存失败时(缓存内存不够),JVM就会尝试通过使用加锁机制来确保数据的原子性,直接在Eden空间中分配内存
堆配置
-Xms
最小值,默认初始大小为物理内存的1/64
-Xmx
最大值,默认最大堆为物理内存的1/4
异常
OutOfMemoryError
内存溢出
堆无法分配更多的内存
方法区
虽然方法区是堆的一个逻辑部分,但是它却不是堆里面的
方法区的大小决定了系统可以加载多少个类,过多会OOM
加载了大量的第三方jar包
Tomcat部署的工程过多
方法区结构
类信息和运行常量池分类
类信息
类型信息
每个加载的类型(类Class、接口 interface、枚举enum、注解 annotation),JM必须在方法区中存储以下类型信息:
这个类型的完整有效名称(全名 = 包名.类名)
这个类型直接父类的完整有效名(对于 interface或是java.lang. Object,都没有父类)
这个类型的修饰符( public, abstract,final的某个子集)
这个类型直接接口的一个有序列表
域信息
域信息,即为类的属性,成员变量
JVM必须在方法区中保存类所有的成员变量相关信息及声明顺序
域的相关信息包括:域名称、域类型、域修饰符(pυblic、private、protected、static、final、volatile、transient的某个子集)
方法信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
方法名称方法的返回类型(或void)
方法参数的数量和类型(按顺序)
方法的修饰符public、private、protected、static、final、synchronized、native,、abstract的一个子集
方法的字节码bytecodes、操作数栈、局部变量表及大小( abstract和native方法除外)
异常表( abstract和 native方法除外)。每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
运行时常量池
运行时常量池
来源:常量池
存放编译期间生成的各种字面量与符号引用
命令:javap -c -verbose ClassTest.class
在字节码文件中(.class)
常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
常量池表在运行时的表现形式
编译后的字节码文件中包含了类型信息、域信息、方法信息等。通过ClassLoader将字节码文件的常量池中的信息加载到内存中,存储在了方法区的运行时常量池中
JIT即时编译后的代码缓存数据
方法区和永久代并不是等价的,在HotSpot虚拟机设计垃圾回收机制的分代模式与这个是不一致的层次
方法区的演进过程
方法区中的字符串常量池和静态常量池还存在永久代中
逐渐去永久代,方法区中的字符串常量池和静态常量池都移到了堆中
去除永久代,将剩余的都移到了元空间本地内存中,不再依靠JVM的内存
元空间取代了永久代原因
使用永久代,更容易导致Java程序更容易OOM,因为永久代使用的是JVM虚拟内存
对永久代进行调优是很困难的
使用元空间,类的元数据存储在本地内存中,称为元空间,不易发生OOM
还有是收购了JRockit,其客户不需要配置永久生成,JRockit和Hotspot聚合工作时就去掉了永久代
参数配置
-XX:MetaspaceSize=21m
默认的-XX:MetaspaceSize值为21MB
这就是初始的高水位线,一旦元空间的大小触及这个高水位线,就会触发Full GC并会卸载没有用的类,然后高水位线的值将会被重置
建议将-XX:MetaspaceSize设置为较高的值
-XX:MaxMetaspaceSize=-1
默认的-XX:MaxMetaspaceSize值为-1无限制
一般-XX:MaxMetaspaceSize不进行设置
如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存
异常
OutOfMemoryError
垃圾收集器、内存分配和回收策略
垃圾收集需要完成的三件事
那些内存需要回收?
Java堆
方法区
什么时候回收?
如何回收?
判定对象存活与否?
标记阶段的算法
引用计数算法(Reference Counting)
普遍性的理解:
在对象中添加一个引用计数器,每当有一个地方引用时,计数器加一;
当引用失效时,计数器减一;
在任何时候计数器为零的对象没有被引用及使用,可以回收;
优点
实现简单,垃圾对象便于标识
判定效率高,回收没有延迟性
缺点
需要单独的字段存储计数器,增加内存空间的开销
需要更新计数器的值,增加时间开销
致命缺陷:无法解决循环引用的问题,导致垃圾无法回收。这也是导致Java的垃圾回收器没有采用此算法的原因
主流的Java虚拟机没有选用引用计数算法来管理内存
内存泄漏:对象已不再被使用,应该被回收,但是由于有引用的存在却无法被回收,最终导致内存占满而泄漏
可达性分析算法(Reachability Analysis)
基本思想:
通过一系列称为“GC Roots”的根对象作为起始节点集
Java技术体系中,可作为GC Roots的对象
在虚拟机栈(栈帧中的局部变量表)中引用的对象,譬如各个线程调用的方法堆栈中使用到的参数、局部变量、临时变量等
在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量 static,JDK8及之后存储在堆上
在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用
在本地方法栈中JNI(Native方法)引用的对象
Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExecption、OutOfMenoryError)等,还有系统类的加载器
所有被同步锁(synchronized关键字)持有的对象
反映Java虚拟机内部情况的JMXBean、JVMTI周末注册的回调、本地代码缓存等
根据用户所选的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”的加入“GC Roots” 集合中
如:对象被堆的不同区域引用问题,跨代引用
主要原因是垃圾回收是可以针对某一区域进行单独回收行动
从这些节点开始根据引用关系向下搜索,搜索过程的路径称为“引用链”(Reference Chain)
如果某个对象到GC Roots间没有任何引用链相连,则证明此对象不可能被使用,可以回收
当前主流的商用程序语言(Java、C#)的子系统,都是采用可达性分析算法来判定对象是否存活
对象的生存与死亡规则
可达性分析算法的两次标记过程:
第一次标记:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链
第二次标记:筛选条件是次对象是否有必要执行finalize方法
没有必要执行
对象没有覆盖finalize方法
finalize方法已经被虚拟机调用过
有必要执行
对象重写了finalize方法
在finalize方法中对象可以进行一次自救的机会
任何一个对象的finalize方法之后被系统自动调用一次,如果对象下次面临回收,它的finalize方法不会被再次执行
GC对于finalize的回收过程
对象判定为有必要执行finalize方法
将对象被放置在一个名为F-Queue的队列中
虚拟机自动建立、低调度优先级的Finalizer线程去执行对象的finalize方法
非阻塞线程
finalize的执行:意义是虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束
原因:如果某个对象的finalize()方法执行缓慢,或者更极端的发生死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。
finalize方法不建议使用,更不好作于对象的自救,可以做关闭资源清除,但是try-finally或者其他方式都可以做的更好及时。
Java中4种引用的级别和强度由高到低依次为:强引用 -> 软引用 -> 弱引用 -> 虚引用
强引用(StrongReference)
强引用是最普遍存在的引用
回收机制:
无论任何情况下,只要强引用关系还在,垃圾收集器就永远不会回收掉被引用的对象
当内存空间不足时,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会随意回收具有强引用的对象来解决内存不足的问题。
示例:
Object obj = new Object();
释放:
如果强引用对象不使用时,需要弱化从而使
GC
能够回收 obj = null;
显式地设置StrongReference对象为null,或让其超出对象的生命周期范围,则gc认为该对象不存在引用,这时就可以回收这个对象
使用场景:
正常创建使用
软引用(SoftReference)
如果一个对象只具有软引用,则内存空间充足时,垃圾回收器就不会回收它
回收机制:
如果一个对象只具有软引用,则内存空间充足时,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。
示例:
String str = new String("abc");
SoftReference<String> softReference = new SoftReference<String>(str);
SoftReference<String> softReference = new SoftReference<String>(str);
引用队列:ReferenceQueue
软引用可以和一个引用队列(ReferenceQueue)联合使用。如果软引用所引用对象被垃圾回收,JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中
释放:
如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。
使用场景:
软引用可用来实现内存敏感的高速缓存。
数据缓存等,如缓存页面信息等
弱引用(WeakReference)
弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期
回收机制:
在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。
示例:
Object obj = new Object();
WeakReference<Object> wr = new WeakReference<Object>(obj);
WeakReference<Object> wr = new WeakReference<Object>(obj);
释放:
当对象只有弱引用指向它时
使用场景:
操作或依附该对象,却不能影响其正常被回收时
如果一个对象是偶尔(很少)的使用,并且希望在使用时随时就能获取到,但又不想影响此对象的垃圾收集,那么你应该用Weak Reference来记住此对象。
虚引用(PhantomReference)
虚引用顾名思义,就是形同虚设。与其他几种引用都不同,虚引用并不会决定对象的生命周期,也无法通过虚引用来获取得到一个对象引用
回收机制:
如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
示例:
Object obj = new Object();
ReferenceQueue<Object> rq = new ReferenceQueue<Object>();
PhantomReference<Object> pr = new PhantomReference<Object>(obj, rq);
ReferenceQueue<Object> rq = new ReferenceQueue<Object>();
PhantomReference<Object> pr = new PhantomReference<Object>(obj, rq);
虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中
释放:
当对象只有虚引用指向它时
使用场景:
虚引用主要用来跟踪对象被垃圾回收器回收的活动。
结合ReferenceQueue追踪对象是否已经被回收
为一个对象设置虚引用关联的唯一目的是为了对象在被垃圾收集器回收的时候,收到一个系统通知
方法区的回收
方法区的回收主要是类的卸载问题
方法区垃圾收集的“性价比”通常比较低
在Java堆中新生代中,可以回收70%至99%的内存空间
方法区的垃圾收集主要回收两部分内容:
废弃的常量
不再使用的类型
方法区的回收的判断条件:
判定一个常量是否废弃相对简单
主要是常量在系统中无任何地方被引用,在发生内存回收机制时,垃圾收集器判断有必要,则会被系统清理出常量池
判定一个类型是否不再被使用比较苛刻
该类所有的实例都已经被回收
在Java堆中不存在该类及其任何派生子类实例
加载该类的类加载器已经被回收
除非是自定义的加载器,如OSGI、JSP的冲加载等,一般很难达成
该类对应的java.lang.Class对象没有任何地方被引用
无法在任何地方通过反射访问该类
注意点
大量的使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的厂家中,通常需要Java虚拟机具备类型的卸载能力,以保证不会对方法区造成过大的内存压力
大量的加载,也需要具备卸载能力
虚拟机对方法区参数设置:
-Xnoclassgc
是否对类型进行回收,卸载
-verbose:class
查看类信息
-XX:+TraceClassLoading
查看类加载信息
-XX:+TraceClassUnLoading
查看类卸载信息
FastDubug版的虚拟机支持
目前主流HotSpot虚拟机默认是有垃圾回收
JDK11时期的ZGC收集器不支持类的卸载
垃圾收集算法
垃圾收集算法在各个虚拟机平台操作内存的方法有差异,只介绍分代收集理论和算法思想及发展过程
从判定对象消亡的角度
垃圾收集算法划分
引用计数式垃圾收集(Reference Counting GC)
又称:直接垃圾收集
追踪式垃圾收集(Tracing GC)
又称:间接垃圾收集
主流Java虚拟机方式
分代收集理论
实质是一套符合大多数程序运行实际情况的经验法则
建立在两个分代假说之上:
弱分代假说
绝大多数对象都是朝生夕灭的
强分代假说
熬过越多次垃圾收集过程的对象就越难以消灭
跨代引用假说
跨代引用相对与同代引用来说仅占少数
根据这个假说,需要做的事:
不需要为少量的跨代引用去扫描整个老年代
只需要在新生代中建立一个全局数据结构,将老年代划分多个小块,标识出老年代中那块内存存在跨代引用
当发生Minor GC时,只需扫描老年代中这小块跨代引用内存就行
老年代的跨代引用区域,在引用变化时,进行维护正确性
垃圾清理阶段算法
标记-清除算法(Mark-Sweep)
概念
算法主要分为“标记”和“清除”两阶段
标记:首先标记出所有需要回收(或者存活)的对象
Collector从引用GC Roots开始递归遍历,标记所有 被引用的可达对象
清除:标记完成后,统一回收掉所有被标记的对象
Collector对堆内存从头到尾进行线性遍历,如果发现某个对象在Header中没有标记为可达,则将其回收
这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就覆盖存放。
类似C内存地址
应用场景
一般可以应用到老年代,在内存足够,暂时能容忍内存碎片的存在的情况
缺点
执行效率不稳定
如果Java堆中有大量对象,其中大部分需要被回收,这时必须进行大量的标记和清除操作,导致标记和清除操作执行效率随对象的数量增长而降低
内存空间碎片化问题
标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致大对象的内存分配找不到足够的连续内存,导致会触发一次垃圾收集动作
标记-复制算法(Copying)
概念
可以简称“复制算法”
1969年Fenichel提出一种称为“半区复制(Semispace)”的垃圾算法
将堆某个区域的可用内存按容量划分为大小相同的两块,每次只使用其中一块
当正在使用的那块内存用完了,触发GC,就将还存活的可达对象复制(深拷贝)到另一块上面(紧密排列),并且清理正在使用的内存块中的所有对象
交换两个内存块的角色,完成垃圾回收
应用场景
主要在年轻代的区域进行使用
优点
没有标记和清除的过程,实现简单,运行高效
复制后保证了连续的空间,不会出现“碎片问题”
分配内存只要移动堆顶指针,按顺序分配
指针碰撞
缺点
将可用内存缩小为原来的一半
如果系统中的存活对象多于垃圾对象时,复制算法反而更加低效
因为清理的垃圾很少,却需要消耗大量的时间和空间来拷贝存活的对象,还需要额外维护对象region与对象之间的映射关系,相当于在做无用功
Java对象引用的访问方式是直接访问,而不是句柄池方式访问。所以堆上的对象再移动了位置之后,变量的引用地址发生了改变,需要重新关联起来
演变
在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出一种更优化的半区复制分代策略,现在称为“Appel式回收”
HotSpot虚拟机的Serial、ParNew等新生代收集器均采用次策略设计新生代内存布局
新生代划分区域
Eden:8
Survivor From:1
Survivor To:1
过程
当Eden区满的时候,会触发第一次young gc,把还活着的对象拷贝到Survivor From区;
当Eden区再次触发young gc 的时候,会扫描Eden区和From区域,对两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到To区域,并将Eden和From区域清空。
会在From和To区域来回复制,如此交换15次,最终存活下来的就会存入老年代中
问题
当Survivor 区域空间不够容纳Minor GC 后存活的对象时
需要依赖其他的内存区域(大多数是老年代)进行分配担保机制(需要有特定条件)
标记-整理算法(Mark-Compact)
概念
在1974年Edward Lueders提出了针对老年代对象的存亡特征
标记过程与“标记-清除”算法一样,但后续是让所有存活的对象都向内存空间一端移动,按序排放,然后在清理掉边界以外的内存
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark- Sweep Compact)算法
主要过程
标记
清理
整理
标记-清除算法与标记整理算法的本质差异在于是否多一步移动
问题:移动存活的对象到内存空间的一边,若是这一边有垃圾对象怎么移动
应用场景
主要在老年代中使用
老年代对象存活周期长,存活对象多于垃圾对象,适合标记算法
老年代空间大,适合存放大对象,所以需要连续的空间,要避免“碎片化”。标记整理算法的整理阶段很好的解决了这一问题
优点
解决了标记-清除算法中碎片化的缺点
解决了复制算法中内存减半的缺点
缺点
相对于其他两种算法,效率最低
移动对象的同时,还需要调整region与对象的映射关系,是一种极为负重的操作
这种移动必须全程暂停用户应用程序才能进行,STW
CMS收集器
“和稀泥式”方案可以不再内存分配和访问上增加太大的负担
主要做法:
虚拟机平时多数时间采用标记-清除算法,暂时容忍内存碎片的存在
直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间
三种算法的对比
查看垃圾收集器信息:
List<GarbageCollectorMXBean> garbageCollectorMXBeans = ManagementFactory.getGarbageCollectorMXBeans();
for(GarbageCollectorMXBean gc:garbageCollectorMXBeans){
System.out.println(gc.getName());
System.out.println("--");
}
for(GarbageCollectorMXBean gc:garbageCollectorMXBeans){
System.out.println(gc.getName());
System.out.println("--");
}
HotSpot的算法细节实现
根节点枚举
内存分配与回收策略
内存自动化管理的根本目标是解决两个问题:
自动给对象分配内存
自动回收分配给对象的内存
对象的内存分配区域关系
差不多都在堆上分配
编译方式
对象大小
垃圾收集器实现
JVM参数设定
内存分配流程
对象优先在Eden分配,分配流程
大多数情况下,对象在新生代Eden区中分配
当Eden区没有足够的空间进行内存分配时,虚拟机将发起一次Minor GC
若对象在Eden中,内存不够,发生Minor GC时,准备放在Survivor空间,但是空间不够,则会通过分配担保机制提前转移到老年代
可以使用HotSpot虚拟机收集器日志参数:
-XX:+PrintGCDetails
大对象直接进入老年代
大对象就是指需要大量连续内存空间的Java对象
如:很长的字符串
如:元素盘大的数组
避免大对象的原因:
在内存分配空间时,导致还有不少的空间,但又提前触发垃圾回收,来获取更多的连续空间
当复制对象时,大对象就意味着高额的内存复制开销。
Hotspot虚拟机提供设置对象大小值,超过直接进入老年代
-XX:PretenureSizeThreshold
只对Serial和ParNew两款新生代收集器有效支持,可组合ParNew+CMS收集器
避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作
长期存活的对象将进入老年代
每个对象定义了一个对象年龄(Age)计数器-分代年龄,存储在对象头
对象每经历一次Minor GC后仍存活,并且能被Survivor容纳的话,对象年龄设置为1
在Survivor中每熬过一次Minor GC,则年龄加1
当年龄增加到一定程度(默认为15)就会被晋升到老年代
设置年龄的阈值:
-XX:MaxTenuringThreshold=15(默认)
需要垃圾收集器为:-XX:+UseConcMarkSweepGC
打开此开关后,使用ParNew + CMS + Serial Old的收集器组合进行内存回收。Serial Old收集器将作为CMS收集器出现Concurrent Mode Failure失败后的后备收集器使用
动态对象年龄判定
为了更好适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold的阈值才能晋升到老年代
如果在Survivor空间中低于或等于某年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代
简单说:在Survivor空间中,有一半以上内存的对象年龄都小于等于某个年龄,则大于这个年龄的对象都可以进入老年代
空间分配担保机制
在发生Minor GC之前,HotSpot虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间
若大于,条件成立
则Minor GC可以确保安全进行
若小于,条件不成立
则虚拟机会查看-XX:HandlePromotionFailure设置值是否允许担保失败
如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的
如果担保失败,则会进行一次Full GC
如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC
通常情况下会将-XX:HandlePromotionFailure=true开关打开,避免Full GC过于频繁
自由主题
0 条评论
下一页