
类加载子系统负责从文件系统或者网络中加载Class文件,文件开头有特定的标识,类加载只负责Class文件的加载,至于它是否可以运行则由执行引擎决定。加载的类信息存放在方法区的内存空间。
初始化阶段就是执行类构造器方法clinit的过程,clinit方法是Javac编译器的自动生成物
public class ClassInitTest {
private static int num=1;
public static void main(String[] args) {
System.out.println(ClassInitTest.num);
}
}
client方法不需要定义,是Javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来:包含static变量时就会有clinit方法
public class ClassInitTest {
private static int num=1;
static {
num=2;
}
public static void main(String[] args) {
System.out.println(ClassInitTest.num);
}
}
编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值但是不能访问
public class ClassInitTest {
private static int num=1;
static {
num=2;
number = 20;
//System.out.println(number); //报错,非法的前向引用
}
private static int number = 10;
public static void main(String[] args) {
System.out.println(ClassInitTest.number);//10
}
}
若该类具有父类,Java虚拟机会保证在子类的clinit方法执行前,父类的clinit方法已经执行完毕
public class ClinitTest {
static class Father {
public static int A = 1;
static {
A = 2;
}
}
static class Son extends Father {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Son.B);
}
}
首先加载ClinitTest时会找到main方法,然后执行Son的初始化
但是Son继承了Father,因此还需要执行Father的初始化,同时将A赋值为2;
通过反编译得到Father的加载过程,首先看到原来的值被赋值成1,然后又被赋值成2返回,这就意味着父类中定义的静态语句块要优于子类的变量赋值操作
虚拟机必须保证一个类的clinit方法在多线程下被同步加锁
public class ClassInitTest {
public static void main(String[] args) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "线程 t1 开始");
new DeadThread();
}, "t1").start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "线程 t2 开始");
new DeadThread();
}, "t2").start();
}
}
class DeadThread {
static {
if (true) {
System.out.println(Thread.currentThread().getName() + "初始化当前类");
while(true) {
}
}
}
}
//结果
t1线程 t1 开始
t2线程 t2 开始
t2初始化当前类
从上面可以看出初始化后,只能够执行一次初始化,这就是同步加锁的过程
程序卡死原因分析:
两个线程同时加载ClassInitTest类,但ClassInitTest类中静态代码块中有一处死循环
先加载ClassInitTest类的线程抢到了同步锁,然后在类的静态代码块中执行死循环,另一个线程在等待同步锁的释放
所以无论哪一个线程先执行ClassInitTest类的加载,另一个类也不会继续执行
任何一个类声明以后,内部至少存在一个类的构造
public class ClassInitTest {
//任何一个类声明以后,内部至少存在一个类的构造器
private int a=1;
private static int c=3;
public static void main(String[] args) {
int b=2;
}
public ClassInitTest(){
a=10;
int d=20;
}
}
类加载器分类
类加载器虽然只用于实现类的加载动作,但是对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每个类加载器都拥有一个独立的类名称空间
比较两个类是否"相等",只有在这两个类是由同一个类加载器加载的前提下才有意义,否则即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
public class ClassLoaderTest {
public static void main(String[] args) {
//获取系统类加载器:sun.misc.Launcher$AppClassLoader@18b4aac2
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);
//获取其上层的:扩展类加载器:sun.misc.Launcher$ExtClassLoader@1540e19d
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);
//试图获取根加载器:null
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);
//获取自定义加载器:sun.misc.Launcher$AppClassLoader@18b4aac2
ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader);
//获取String类型的加载器:null
ClassLoader classLoader1 = String.class.getClassLoader();
System.out.println(classLoader1);
}
}
public class ClassLoaderTest {
public static void main(String[] args) {
System.out.println("***************启动类加载器***************");
URL[] urLs = Launcher.getBootstrapClassPath().getURLs();
for (URL element:urLs){
System.out.println(element.toExternalForm());
}
//从上面路径中随意选择一个类,看它的加载器:classLoader = null
ClassLoader classLoader = Provider.class.getClassLoader();
System.out.println("classLoader = " + classLoader);
}
}
//输出结果:
***************启动类加载器***************
file:/D:/Java/jdk1.8/jre/lib/resources.jar
file:/D:/Java/jdk1.8/jre/lib/rt.jar
file:/D:/Java/jdk1.8/jre/lib/sunrsasign.jar
file:/D:/Java/jdk1.8/jre/lib/jsse.jar
file:/D:/Java/jdk1.8/jre/lib/jce.jar
file:/D:/Java/jdk1.8/jre/lib/charsets.jar
file:/D:/Java/jdk1.8/jre/lib/jfr.jar
file:/D:/Java/jdk1.8/jre/classes
classLoader = null
启动类加载器负责加载存放在lib目录或者-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按文件名识别)类库加载到虚拟机的内存中
public class ClassLoaderTest {
public static void main(String[] args) {
System.out.println("***************扩展类加载器***************");
String extDirs = System.getProperty("java.ext.dirs");
for(String path:extDirs.split(";")){
System.out.println(path);
}
//从上面路径中随意选择一个类,看它的加载器:classLoader = sun.misc.Launcher$ExtClassLoader@29453f44
ClassLoader classLoader = CurveDB.class.getClassLoader();
System.out.println("classLoader = " + classLoader);
}
}
//输出结果:
***************扩展类加载器***************
D:Javajdk1.8jrelibext
C:WINDOWSSunJavalibext
classLoader = sun.misc.Launcher$ExtClassLoader@29453f44
扩展类加载器负责加载lib/ext目录中或者Java.ext.dirs系统变量所指定的路径中所有的类库
系统类加载器
应用程序类加载器,负责加载环境变量classpath或系统指定路径下的类库,是程序中默认类加载器,Java中应用类都是由它加载的
用户自定义类加载器:通过继承Java.lang.ClassLoader类的方式实现
优势
protected Class> loadClass(String var1, boolean var2) throws ClassNotFoundException {
synchronized(this.getClassLoadingLock(var1)) {
Class var4 = this.findLoadedClass(var1);
if (var4 == null) {
long var5 = System.nanoTime();
try {
if (this.parent != null) {
var4 = this.parent.loadClass(var1, false);
} else {
var4 = this.findBootstrapClassOrNull(var1);
}
} catch (ClassNotFoundException var10) {
;
}
if (var4 == null) {
long var7 = System.nanoTime();
var4 = this.findClass(var1);
PerfCounter.getParentDelegationTime().addTime(var7 - var5);
PerfCounter.getFindClassTime().addElapsedTimeFrom(var7);
PerfCounter.getFindClasses().increment();
}
}
if (var2) {
this.resolveClass(var4);
}
return var4;
}
}
由上面代码可以看出:先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器,加入父类加载器加载失败,抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载。
Class.forName与ClassLoader.loadClass区别?????
双亲委派模型破坏
Class只有在必须要首次使用时才会被装载,虚拟机不会无条件地装载Class类型,一个类或接口在初次使用前,必须要进行初始化,这里的"使用"指的是主动使用,主动使用Client方法就会被调用,主动情况有以下几种情况:
主动使用
public class ActiveUser1 {
public static void main(String[] args) {
//1.new的方式:输出结果:"order类的初始化过程",说明调用了Clinit方法
Order order=new Order();
}
//2.序列化的过程
@Test
public void test1(){
ObjectOutputStream oos=null;
try {
oos = new ObjectOutputStream(new FileOutputStream("order.dat"));
oos.writeObject(new Order());
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (oos!=null)
oos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
//3.反序列化过程:输出结果:"order类的初始化过程",说明调用了Clinit方法
@Test
public void test2(){
ObjectInputStream ois = null;
try {
ois = new ObjectInputStream(new FileInputStream("order.dat"));
Order order = (Order) ois.readObject();
} catch (IOException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
} finally {
try {
if(ois!=null)
ois.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
class Order implements Serializable{
//Clinit方法
static{
System.out.println("order类的初始化过程");
}
}
public class ActiveUser3 {
@Test
public void test1(){
try {
//反射调用Order类时,输出"order类的初始化过程"说明执行了初始化
Class clazz = Class.forName("com.Order");
} catch (Exception e) {
e.printStackTrace();
}
}
}
//order类的初始化过程
被动使用
被动使用Clinit方法不会被调用
public class PassiveUser1 {
@Test
public void test1(){
//不会调用Parent初始化过程
Parent[] parents=new Parent[10];
//class [Lcom.Parent;
System.out.println(parents.getClass());
//class java.lang.Object
System.out.println(parents.getClass().getSuperclass());
//输出结果:Parent初始化过程,new调用初始化过程
parents[0]=new Parent();
}
}
class Parent{
static {
System.out.println("Parent初始化过程");
}
public static int num=1;
}
public class PassiveUser1 {
@Test
public void test1(){
//输出结果"Person初始化过程"和"1"
System.out.println(Person.num);
//输出结果为1
System.out.println(Person.num2);
}
}
class Person{
static {
System.out.println("Person初始化过程");
}
public static int num=1;
//在链接过程的准备环节就被赋值为1了
public static final int num2=1;
}
public class PassiveUser1 {
@Test
public void test1(){
try {
//不会输出"Person初始化过程",即不会初始化类
Class> loadClass = ClassLoader.getSystemClassLoader().loadClass("com.Person");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
class Person{
static {
System.out.println("Person初始化过程");
}
public static int num=1;
public static final int num2=1;
}
如何判定一个类型是否属于"不再被使用的类"?????
类加载方式
Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类完全加载到JVM中,至于其他类则在需要的时候才加载,这样可以节省内存开销。
运行时数据区 程序计数器程序计数器是一块较小的内存空间,几乎可以忽略不计,也是运行速度最快的区域。它可以看作是当前线程所执行的字节码的行号指示器,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器完成。
由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为"线程私有"的内存。程序计数器生命周期与线程生命周期一致。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行的是Native本地方法,这个计数器值则应为空(Undefined),此内存区域没有规定任何OutOfMemoryError情况的区域和GC垃圾回收。
程序计数器为什么设定为私有的?????
多线程在一个特定的时间段内只会执行其中某一线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法就是为每个线程都分配一个PC寄存器,这样各个线程之间可以进行独立计算,不会出现互相干扰的情况;
虚拟机栈虚拟机栈是线程私有的,生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量、操作数栈、动态链接、方法出口等信息,每个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
运行原理
内部结构
局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表存放了编译期可知的各种Java虚拟机基本数据类型、对象引用类型(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其它与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)
由于局部变量表是建立在线程的栈上,是线程的私有数据,不存在数据安全问题。
所需容量大小在编译期确定下来并保存在方法的Code属性的maximum local variables数据项中,在方法运行期间是不会改变局部变量表的大小
方法嵌套调用的次数由栈的大小决定,栈越大方法嵌套调用次数越多。对于一个参数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧越大,以满足方法调用所需传递的信息增大的需求,进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会越少
局部变量表中的变量只在当前方法调用中有效,在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量的传递过程;当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
变量槽
public class LocalVariablesTest {
public String test2(Date dateP, String name2) {
dateP = null;
name2 = "songhongkang";
double weight = 130.5;//占据两个slot
char gender = '男';
return dateP + name2;
}
}
变量槽的重复利用
变量槽是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的
public class LocalVariablesTest {
public void test4() {
int a = 0;
{
int b = 0;
b = a + 1;
}
//变量c使用之前已经销毁的变量b占据的slot的位置
int c = a + 1;
}
}
变量分类
类变量有两次初始化的机会:一次是在"准备阶段",执行系统初始化,对类变量设置零值;另一次则是在"初始化"阶段,赋予代码中定义的初始值。
局部变量表不存在系统初始化的过程,这意味着一旦定义局部变量则必须人为的初始化,否则无法使用。局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
操作数栈操作数栈是一个后进先出的栈,栈的最大深度在编译时期被写入Code属性的max_stacks数据项中。方法刚开始执行时,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令向操作数栈中写入和提取内容,即出栈和入栈操作。
操作数栈作用
操作数栈案例
首先执行第一条语句,PC寄存器指向的是0,也就是指令地址为0,然后使用bipush让操作数15入栈
执行完第一条语句后,让PC+1指向下一行代码:就是将操作数栈的元素存储到局部变量表索引1的位置
为什么存储到局部变量索引1的位置?
因为该方法为实例方法,局部变量表索引为0的位置存放的是this
然后PC+1,指向的是下一行,让操作数8也入栈,同时执行store操作,存入局部变量表中
然后从局部变量表中依次将数据放在操作数栈中等待执行add操作
然后将操作数栈中的两个元素执行相加操作并存储在局部变量表3的位置
最后PC寄存器的位置指向10,也就是return方法,则直接退出方法
操作数栈方法调用
public class OperandStackTest {
public void testAddOperation(){
byte i=15;
int j=8;
int k=i+j;
}
public int getSum(){
int m = 10;
int n = 20;
int k = m + n;
return k;
}
public void testGetSum(){
//获取上一个栈桢返回的结果,并保存在操作数栈中
int i = getSum();
int j = 10;
}
}
总结:如果被调用的方法带有返回值的话,其返回值会将被压入当前栈帧的操作数栈中并更新PC寄存器中下一条需要执行的字节码指令
栈顶缓存技术
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接,Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用时就被转化为直接引用,这种转化成为静态解析;另一部分将在每一次运行期间都转化为直接引用,这部分成为动态链接。
案例代码追踪
public class DynamicLinkingTest {
int num = 10;
public void methodA(){
System.out.println("methodA()....");
}
public void methodB(){
System.out.println("methodB()....");
methodA();
num++;
}
}
为什么要用常量池?????
动态链接与静态链接
早期与晚期绑定
虚和非虚方法
普通调用指令:
动态调用指令:
方法重写本质:
虚方法表
当一个方法开始执行后,有两种方式退出这个方法:
无论以哪种方式退出,在方法退出后都返回到该方法被调用的位置;方法正在退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址值;方法异常退出时,返回地址要通过异常处理器表来确定,栈帧中就一般不会保存这部分的信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。只有具体到某一款Java虚拟机实现,会执行哪些操作才会确定下来。
堆堆是虚拟机所管理的内存中最大的一块内存空间并且被所有线程共享,在虚拟机启动时创建并确定内存大小(大小是可以调节的,主流虚拟机都是按可扩展实现的,通过参数-Xmx和-Xms设定),此内存区域的唯一目的就是存放对象实例,"几乎"所有的对象实例都是在这里分配内存的,从实际使用角度看,还有一些对象是在栈上分配的(逃逸分析)。
堆是GC执行垃圾回收的重点区域。
堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。像我们使用磁盘空间存储文件一样,并不要求每个文件都连续存放,但对于大对象,多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。
从分配内存的角度看,所有线程共享的堆中可以划分出多个线程私有的分配缓存区(TLAB),以提升对象分配时的效率。
问题:堆空间都是共享的吗?????
问题:为什么要有TLAB?????
TLAB说明:
Java7及之前堆内存逻辑分为三部分:新生区+养老区+永久区
Java8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间
堆内存大小设置
OutOfMemoryError异常
年轻代与老年代相关参数的设置?????
对象分配过程
内存分配策略
其实不分代完全可以,分代的唯一理由就是优化GC性能,如果没有分代,所有的对象都在一块,就如同把一个学校的人都关在一个教室,GC时要找到哪些对象没用,这样就会对堆的所有区域进行扫描,而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC时先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。
逃逸分析堆是分配对象的唯一选择吗?????
堆上分配创建对象的内存空间,堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用就可以访问到堆中存储的对象数据。虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。如果确定一个对象不会逃逸出线程之外,那可以让这个对象在栈上分配内存将是一个不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁,在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占比例是很大的。如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力会下降很多,栈上分配可以支持方法逃逸,但是不能支持线程逃逸。
案例:关闭逃逸分析:执行时间长;内存实例对象为遍历添加次数
案例:开启逃逸分析:执行时间短;内存中添加的对象大量减少
案例:将内存参数从1G改为256,关闭逃逸分析情况:发生GC,执行时间长
案例:将内存参数从1G改为256,开启逃逸分析情况:未发生GC,执行时间短
如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步代码块所使用的锁对象是否只能够被一个线程访问而没有被发布到其它线程,如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步,这个取消同步的过程叫做同步省略,也叫锁消除。
public void f() {
Object hollis = new Object();
synchronized(hollis) {
System.out.println(hollis);
}
}
代码中对hollis这个对象进行加锁,但是hollis对象的生命周期只在f()方法中并不会被其它线程所访问到,所以JIT编译阶段就会被优化掉。
public void f() {
Object hollis = new Object();
System.out.println(hollis);
}
所以在使用synchronize的时候,如果JIT经过逃逸分析之后发现并无线程安全问题的话,就会做锁消除。
代码优化-标量替换标量是指一个无法再分解成更小的数据的数据;Java中的原始数据类型就是标量,相对的那些还可以分解的数据就叫做聚合量,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量,在JIT阶段如果经过逃逸分析发现一个对象不会被外界访问的话,那么经过JIT优化就会把这个对象拆解成若干个其中包含的若干个成员变量来替换,这个过程就是标量替换。
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;
}
以上代码Ponit对象并没有逃逸出alloc方法并且point对象是可以拆解成标量的,那么JIT就不会直接创建Point对象,而是直接使用两个标量int x、int y来替代Ponit对象。
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x="+x+"; point.y="+y);
}
可以看到Point这个聚合量经过逃逸分析后,发现它并没有逃逸,就被替换成两个聚合量了,这样可以大大减少堆内存的占用,因为一旦不需要创建对象了,那么就不需要分配堆内存了,为栈上分配提供了基础。
方法区方法区是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译期编译后的代码缓存等数据。和Java堆一样它的实际的物理内存空间可以是不连续的(物理上可以不连续,逻辑上连续)。
方法区的大小可以选择固定大小或者可扩展,方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGen space 或者 java.lang.OutOfMemoryError:Metaspace
JVM启动时,方法区创建,JVM关闭时就是释放这个区域的内存。
HotSpot方法区演进
方法区大小的设置
如何解决OOM异常
方法区的内部结构
类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation)JVM必须在方法区中存储以下类型信息:
域信息
JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序,域的相关信息包括:
方法信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
类型信息
Compiled from "MethodInnerStrucTest.java"
/
public Object pop(){
if (size==0){
throw new EmptyStackException()
}
Object result=elements[--size];
elements[size]=null;
return result;
}
public void ensureCapacity(){
if (elements.length==size){
elements= Arrays.copyOf(elements,2*size+1);
}
}
}
如果一个栈先是增长,然后再收缩,从栈中弹出来的对象不会被当作垃圾回收,即使使用栈的程序不再引用这些对象,它们也不会被回收。因为栈内部维护着对这些对象的过期引用。过期引用指永远也不会再被解除的引用。
如果一个对象引用被无意识地保留了,垃圾回收机制不仅不会回收这个对象,而且不会回收被这个对象所引用的所有其他对象。
解决方法:一旦对象引用已经过期,只需清空这些引用即可。在本例中,只要一个元素被弹出栈,指向它的引用就过期了。
push时
pop时,当进行大量的pop操作时,由于引用未进行置空,gc是不会释放的
从上图中看以看出,如果栈先增长,在收缩,那么从栈中弹出的对象将不会被当作垃圾回收,即使程序不再使用栈中的这些对象,它们也不会回收,因为栈中仍然保存这对象的引用,俗称过期引用,这个内存泄露很隐蔽。
STW:指的是GC事件发生过程中会产生应用程序的停顿,停顿时整个应用程序线程都会被暂停,没有任何响应。STW事件和采用哪款GC无关,所有的GC都有这个事件,哪怕G1也不能完全避免STW情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能的缩短暂停时间。
并发与并行概念并发
并行
安全点中断实现方式
如何在GC发生时检查所有线程都跑到最近的安全点停顿下来呢?????
安全区域
安全区域执行流程
public class PhantomReferenceTest {
//当前类对象的声明
public static PhantomReferenceTest obj;
//引用队列
static ReferenceQueuephantomQueue=null;
public static class CheckRefQueue extends Thread{
@Override
public void run(){
while (true){
if (phantomQueue!=null){
PhantomReferenceobjt=null;
try {
objt = (PhantomReference) phantomQueue.remove();
} catch (InterruptedException e) {
e.printStackTrace();
}
if (objt!=null){
System.out.println("追踪垃圾回收过程:PhantomReferenceTest实例被GC了");
}
}
}
}
}
@Override
protected void finalize() throws Throwable{
super.finalize();
System.out.println("调用当前类的finalize方法");
obj=this;
}
public static void main(String[] args) {
CheckRefQueue t1 = new CheckRefQueue();
t1.setDaemon(true);
t1.start();
phantomQueue = new ReferenceQueue();
obj = new PhantomReferenceTest();
//构造了PhantomReferenceTest 对象的虚引用并指定了引用队列
PhantomReference phantom = new PhantomReference<>(obj, phantomQueue);
try{
//获取不到虚引用的对象
System.out.println(phantom.get());
//将强引用去除
obj=null;
//第一次进行GC,由于对象可复活,GC无法回收该对象
System.gc();
Thread.sleep(1000);
if (obj==null){
System.out.println("obj 是null");
}else{
System.out.println("obj 可用");
}
System.out.println("第2次GC");
obj=null;
//一旦将obj对象回收,就会将虚引用存放到引用队列中
System.gc();
if (obj==null){
System.out.println("obj 是null");
}else{
System.out.println("obj 可用");
}
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
//输出结果
null
调用当前类的finalize方法
obj 可用
第2次GC
obj 是null
追踪垃圾回收过程:PhantomReferenceTest实例被GC了
垃圾回收算法
垃圾是指运行程序中没有任何指针指向的对象,这个对象就是需要回收的垃圾。如果不及时对内存中的垃圾进行清理,那么这些垃圾对象所占的内存空间会一直保留到应用程序的结束,被保留的空间无法被其它对象使用,甚至可能导致内存溢出。
GC除了释放没用的对象也可以清除内存里的记录碎片,碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象。
垃圾回收主要关注于方法区和堆中的垃圾收集。堆是垃圾收集器的工作重点:频繁收集Young区、较少收集Old区、基本不收集元空间。
堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前首先需要区分内存中哪些是存活对象,哪些是已经死亡的对象,只有被标记为已经死亡的对象,GC才会在执行垃圾回收时释放掉其占用的内存空间,这个过程称为垃圾标记阶段;
当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。判断对象存活一般有两种:引用计数算法和可达性分析算法
引用计数算法引用计数算法对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况 。每当有一个地方引用该对象时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的,可进行回收。
public class ReferenceCountingGC {
//这个成员属性唯一的作用就是占用一点内存
private byte[] bigSize = new byte[5 * 1024 * 1024];//5MB
Object reference = null;
public static void main(String[] args) {
ReferenceCountingGC obj1 = new ReferenceCountingGC();
ReferenceCountingGC obj2 = new ReferenceCountingGC();
obj1.reference = obj2;
obj2.reference = obj1;
obj1 = null;
obj2 = null;
//显式的执行垃圾回收行为,这里发生GC,obj1和obj2能否被回收?
// System.gc();
}
}
如上图可知:不进行垃圾回收时,Eden区占用率为25%
如上图:Eden区占用率仅为3%,说明进行了垃圾回收,所以Java中使用的不是引用计数算法(如果采用的是引用计数算法,obj1和obj2存在相互引用,不应该被回收)。
优点:实现简单、垃圾对象便于辨识、判断效率高、回收没有延迟性。
缺点:需要单独字段存储计数器,增加了存储空间的开销;每次赋值都需要更新计数器,伴随着加法和减法操作,增加了时间开销;无法处理循环引用的情况;
可达性垃圾回收算法优点
可达性算法具有简单和执行高效的特点,并且可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
注意:不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程,两次标记后仍然是可回收对象则将面临回收。
GC Roots
总结:堆空间外的一些结构,比如虚拟机栈、本地方法栈、方法区、字符串常量池等地方对堆空间进行引用的都可以作为GC Roots进行可达性分析。
注意:如果要使用可达性分析算法来判断内存是否可回收,分析工作必须在一个能保证一致性(指整个分析期间整个执行系统看起来像被冻结在某个时间点上)的快照中进行,如果不满足的话分析结果的准确性就无法保证;这点也是导致GC进行时必须STW的重要原因,即使号称几乎不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。STW是JVM在后台自动发起和自动完成的,在用户不可见的情况下把用户正常的工作线程全部停掉。
对象的Finalization机制
对象的三种可能状态:
Finalize具体执行过程
判断一个对象是否可回收,至少要经历两次标记过程
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK=null;
public void isAlive(){
System.out.println("Yes,I am still alive");
}
protected void finalize() throws Throwable{
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK=this;
}
public static void main(String[] args) {
try{
SAVE_HOOK=new FinalizeEscapeGC();
//对象第一次成功拯救自己
SAVE_HOOK=null;
//调用垃圾回收器
System.gc();
System.out.println("第一次 gc");
//因为Finalizer线程优先级很低,暂停2秒等待它
Thread.sleep(2000);
if (SAVE_HOOK==null){
System.out.println("SAVE_HOOK is dead");
}else{
System.out.println("SAVE_HOOK is still alive");
}
System.out.println("第二次 gc");
//这次自救却失败了
SAVE_HOOK=null;
System.gc();
//因为Finalizer线程优先级很低,暂停2秒等待它
Thread.sleep(2000);
if (SAVE_HOOK==null){
System.out.println("SAVE_HOOK is dead");
}else{
System.out.println("SAVE_HOOK is still alive");
}
}catch(Exception e){
e.printStackTrace();
}
}
}
当不执行finalize方法时第一次就直接死亡
当执行finalize方法时,对象会进行一次自救。
清除并不是真的置空而是把需要清除的对象地址保存在空闲的地址列表里,下次有新对象需要加载时,判断垃圾的位置空间是否够,够就存放覆盖原有的地址。
算法缺点
算法原理
优点
缺点
算法原理
执行过程
标记清除和标记整理区别
指针碰撞
如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时只需要通过修改指针的偏移量将新的对象分配在第一个空闲内存位置上,这种分配方式叫做指针碰撞
优点
缺点
分代收集原因
分代收集依据
目前几乎所有的GC都采用分代收集算法执行垃圾回收的。
在HotSpot中基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。
增量收集算法
在垃圾回收过程中应用软件将处于一种Stop the World的状态,该状态下应用程序所有的线程都会挂起,暂停一切正常的工作等待垃圾回收的完成
如果垃圾回收时间过长,应用程序会被挂起很久将严重影响用户体验或者系统的稳定性,为解决这个问题导致了增量收集算法的诞生。
思想:
使用这种方式由于在垃圾回收过程中间断性地还执行了应用程序代码,所以能减少系统的停顿时间,但是因为线程切换和上下文转换的消耗会使得垃圾回收的总体成本上升造成系统吞吐量的下降。
分区算法
一般来说在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关 GC 产生的停顿也越长,为更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。
分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间,每一个小区间都独立使用,独立回收,这种算法的好处是可以控制一次回收多少个小区间 。
垃圾收集器
两个收集器间有连线,表明它们可以搭配使用。
红色虚线:表示在JDK8组合声明为废弃;JDK9中取消了这些组合的支持。
绿色虚线:JDK14中已经弃用。
青色虚线:JDK14中删除CMS垃圾回收器。
Serial收集器CMS整个过程分为4个主要阶段:初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段
优点
缺点
G1是一款面向服务器端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器以极高概率满足GC停顿时间的同时还兼具高吞吐量的性能特征。
G1收集器可以面向堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是垃圾优先,哪块内存中存放的垃圾数量最多,则侧重收集哪块区域。
G1是一个兼具并行与并发的回收器,它把堆内存分割成很多个大小相等的不相关的区域Region,每个区域都可以根据需要来扮演Eden空间、Survivor空间或者老年代空间,收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象,而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。
虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
垃圾回收过程
G1 GC的垃圾回收过程主要包括以下三个环节:
年轻代GC
然后开始如下回收过程:
初始标记阶段
根区域扫描
并发标记
再次标记
独占清理
并发清理阶段
混合回收
回收可选过程
收集器存在的问题