变量
在Java中通过变量来操作内存中的某个存储空间,按照变量的类型可以将变量分为基本类型变量和引用类型变量;按照变量的位置可以将变量分为成员变量和局部变量。一个变量由以下几部分组成:
- 变量名:变量的名字
- 变量值:变量存储的值或引用
- 变量类型:变量存储值的类型或引用指向对象的类型
基本类型变量
基本类型 | 大小 | 包装类型 | 默认值 |
---|---|---|---|
boolean | 1bit | Boolean | false |
char | 16bit | Character | ‘u0000’ |
byte | 8bit | Byte | (byte)0 |
short | 16bit | Short | (short)0 |
int | 32bit | Integer | 0 |
long | 64bit | Long | 0L |
float | 32bit | Float | 0.0f |
double | 64bit | Double | 0.0d |
包装类
每一个基本类型都有一个包装类,除Float
和Double
外其它包装类都使用缓冲池技术来提升性能。其中Byte
、Short
、Integer
和Long
创建了数值[-127,127]
的缓冲池,Character
创建了数值[0,127]
的缓冲池,Boolean
只有TRUE
和FALSE
。凡是直接或间接使用Xxx.valueOf()
创建的并且数值在缓冲池之内的包装类都会返回同一对象的引用。
自动装箱和自动拆箱
自动装箱是指将基本类型用它对应的包装类包装起来,底层实际就是调用了包装类的valueOf
方法。
Integer a=1;
//即为
iconst_1
invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
astore_1
自动拆箱是指将包装类转换为它对应的基本类型,底层实际就是调用了包装类的xxxValue()
方法。
Integer a=1;
int b=a;
//字节码为:
iconst_1
invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
astore_1
aload_1
invokevirtual #3 <java/lang/Integer.intValue : ()I>
istore_2
还需要注意的是自动装箱和自动拆箱对数组无效。
包装类实例的比较
在对包装类实例进行比较时,必须要注意以下几点:
- 明确你要比较的是包装类对象是否是同一个,还是要比较包装类对应的基本类型值是否相等。
- 当包装类的
==
运算符在不遇见算术运算符时不会自动拆箱。 equal()
方法不处理类型转换问题。- 建议在包装类比较时使用
equal()
方法。
public class Person {
public static void main(String[] args) {
Integer a=1;
Integer b=2;
Integer c=3;
Integer d=3;
Integer e=128;
Integer f=128;
Long g=3L;
System.out.println(c==d);//true
System.out.println(e==f);//false
System.out.println(e==f+0);//true
System.out.println(c==(a+b));//true
System.out.println(c.equals(a+b));//true
System.out.println(g==(a+b));//true
System.out.println(g.equals(a+b));//false
}
}
数值字面量的默认类型
将数值类型的字面量赋值给数值类型变量时,如果字面量超出数值类型的取值范围,会导致编译错误;如果没有超出整数类型的取值范围,就可直接赋值。但还需要注意的是,JVM在处理数值字面量时,有一个默认的类型:
-
整数类型的默认类型是
int
,如果整数字面量超出了int
类型的取值范围,则必须在其后面加上字母L
,将直接量显性声明为long
类型,否则会导致编译错误。 -
浮点类型的默认类型是
double
,如果要直接为float
类型变量赋值,则必须在其后面加上字母F
。
类型转换
在基本类型中,除boolean
外其它基本类型之间都可以相互转换,这种转换可以分为以下三种情况:
- 类型提升
- 数值类型转换
- 字符与数值类型之间的转换
类型提升
当不同类型的基本类型变量参与运算时,编译器会按照以下箭头方式自动提升到相同的类型,类型提升的最低档次为int
。
数值类型转换
- 将小范围类型的变量转换为大范围类型不需要显性声明类型转换。
- 将大范围类型的变量转换为小范围类型必须强制类型转换。
字符与数值类型之间的转换
- 将数字类型转换成字符类型时,必须使用强制类型转换,转换结果只使用整数的低16位(浮点数类型将整数部分转换成字符类型)。
- 将字符类型转换成数字类型时,字符的统一码转换成指定的数值类型。如果字符的统一码超出了转换成的数值类型的取值范围,则必须强制类型转换。
浮点数运算精度丢失与BigDecimal
由于二进制计算机的原罪,计算机无法表示所有浮点数,从而导致看似相同的浮点数在判等运算时并不相等。BigDecimal
可以实现对浮点数的运算并且不会造成精度丢失。
整数超出最大范围与BigInteger
如果需要表示一个数值大于Long.MAX_VALUE
的整数,就需要使用BigInteger
,该类在内部使用int[]
数组来存储任意大小的整数数值。
引用类型变量
引用类型变量存储的并不是对象,而是一个reference
类型的值,该类型只存在于JVM内,本质是一个指向对象的指针。
JVM通过局部变量表上的reference
值访问堆中的对象,其中主流的访问方式有以下两种:
访问方式 | 示例图 |
---|---|
句柄访问 | ![]() |
直接访问(HotSpot) | ![]() |
注意,在创建引用类型变量时指定的变量类型是引用指向对象的类型,而不是引用的类型,在JVM中引用也是有类型的,可以分为强引用、软引用、弱引用和虚引用。我们通过new
关键字创建对象时,变量对于堆对象的引用就是强引用,只要对象存在强引用,就不会被垃圾回收。要想使用其它三种类型的引用,就必须通过java.lang.ref
包中代表它的类。
软引用
在系统即将发生OOM之前,会对存在软引用对象进行回收,软引用通常用于实现内存敏感地区,比如高速缓存。
Object obj=new Object()//声明强引用
SoftReference<Object> sf=new SoftReference<Object>(obj);
obj=null;//销毁强引用
弱引用
在下一次垃圾回收时,会对存在弱引用的对象进行回收。
Object obj=new Object()//声明强引用
WeakReference<Object> wf=new WeakReference<Object>(obj);
obj=null;//销毁强引用
虚引用
虚引用不会对对象的生命周期造成任何影响,它唯一目的就是在该对象被回收之前得到系统通知,虚引用必须和引用队列一起使用,因为当垃圾回收器回收一个对象时,如果发现它还有虚引用,那么垃圾回收器就会将这个虚引用加入引用队列以通知应用程序该对象的回收情况。
Object obj=new Object()//声明强引用
ReferenceQueue phantomQueue =new ReferenceQueue();
PhantomReference<Object> pf=new PhantomReference<Object>(obj,phantomQueue);
obj=null;//销毁强引用
成员变量和局部变量
成员变量是在类中定义的变量,局部变量是在代码块或方法中定义的变量。它们之间的区别如下:
- 存储方式:成员变量存储在堆中;局部变量存储在局部变量表中。
- 生存时间:成员变量随着对象的实例化而存在,随着对象的回收而消亡;局部变量随着代码块和方法的执行在存在,随着代码块和方法的执行结束而消亡。
- 默认值:如果没有为变量赋值,成员变量会自动赋初值;局部变量不会自动赋值。
类
Object
类即对象的类型,Java中所有类都直接或间接的继承自Object
类,在Object类中有以下11个方法:
public final native Class<?> getClass()
public native int hashCode()
protected native Object clone() throws CloneNotSupportedException
public boolean equals(Object obj)
public String toString()
public final native void notify()
public final native void notifyAll()
public final native void wait(long timeout) throws InterruptedException
public final void wait(long timeout, int nanos) throws InterruptedException
public final void wait() throws InterruptedException
protected void finalize() throws Throwable
下面首先对部分方法进行分析。
equals
equals()
方法用于比较两个对象是否相等,它的默认实现如下:
public boolean equals(Object obj) {
return (this == obj);
}
也就是说判断两个对象是否相等的逻辑完全可以由类的定义者制定(通过重写这个方法),如果没有重写这个方法。那么默认比较的是变量值,也就是reference
是否相等。重写该方法的步骤如下:
- 使用
==
判断 - 判断参数是否为
null
- 判断该参数是否为同类
- 如果是同类则进行向下转型
- 最后根据自己的逻辑进行比较
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o==null){
return false;
}
if (o.getClass()!=getClass()){
return false;
}
Person person = (Person) o;
return age == person.age && name.equals(person.name);
}
hashcode
hashcode()
方法是一个本地方法,用于返回对象的哈希码,哈希码用于确定对象在哈希表的位置。因此hashcode()
方法也可以判断两个对象是否相等,它与equals()
方法的区别在于equals()
方法判定的结果是确定性的但是效率低,hashcode()
方法效率高但是判定的结果并不是确定性的,也就是说不同的对象可能存在相同的哈希码,这是由于哈希算法的哈希冲突造成的。因此在重写equals()
方法时也必须重写hashcode()
方法,否则就会出现对象相等但哈希码不相等的尴尬局面。
clone
clone()
方法是一个本地方法,在搞清clone()
方法之前必须先理清两个概念:
- 浅拷贝:浅拷贝会在堆上创建一个新的对象,不过,对于原对象的引用属性而言,浅拷贝只会进行引用拷贝,也就是说拷贝对象和原对象的引用属性会指向堆中的同一个对象。
- 深拷贝:深拷贝会复制整个对象,包括对象引用属性指向的对象。
无论是实现哪种拷贝方式,重写clone()
方法的类必须实现Cloneable
接口,该接口是一个标记接口,实现该接口以向Object.clone()
方法表明该方法对该类实例的每个字段进行复制是合法的,否则就会抛出CloneNotSupportedException
异常。下面是浅拷贝的实现:
class ClassRoom implements Cloneable{
private Teacher teacher;
private Student student;
@Override
public ClassRoom clone() throws CloneNotSupportedException {
return (ClassRoom)super.clone();
}
}
下面是深拷贝的实现:
class ClassRoom implements Cloneable{
private Teacher teacher;
private Student student;
@Override
protected ClassRoom clone() throws CloneNotSupportedException {
ClassRoom clone = (ClassRoom) super.clone();
clone.setStudent(clone.getStudent().clone());
clone.setTeacher(clone.getTeacher().clone());
return clone;
}
}
类加载机制
一个类的所有信息在编译后会生成一个字节码文件,在使用该类之前,必须将该类的字节码文件加载到JVM中,一个类的加载过程如下:
比较特殊的是数组类的加载,因为数组类本身并不是由类加载系统加载的,而是由JVM在运行时根据需要而直接创建的,但数组的元素类型仍然需要依靠类加载系统去加载。如果数组的元素类型是引用类型,那么数组类的可访问性就和元素类型的可访问性一致,否则数组类的可访问性就被缺省定义为public
。
加载
加载阶段由类加载器完成,类加载器通过一个类的全限定类名加载该类的字节码文件,并将字节码文件的静态存储结构转化为元空间的运行时数据结构,接着在堆中生成一个代表这个类的java.lang.Class
对象作为元空间这个类的各种数据的接口:
类名称空间
每一个类加载器都有一个类名称空间,由不同的类加载器加载的类将存放在JVM内部不同的类名称空间中,所以在判断两个类是否相同时,不能只看它们的全限定类名是否相同,还应判断它们是否由同一个类加载器加载。
类加载器的分类
类加载器 | 说明 |
---|---|
引导类加载器 | 引导类加载器是由非java语言实现的,嵌套在JVM内部,它用来加载java核心类库,用于提供JVM自身需要的类,扩展类加载器和系统类加载器本身也是引导类加载器加载的。 |
扩展类加载器 | 由java语言实现,它主要加载核心包之外的扩展类库 |
应用程序类加载器 | 由java代码实现,该类加载器用于加载classpath 下的字节码文件,是自定义类的默认加载器。 |
双亲委派机制
如果一个类加载器收到类加载请求,它不会自己先去加载,而是把这个请求委托给父类加载器去执行,如果父类加载器还存在父类加载器,就继续向上委托,直到到达顶层的启动类加载器。如果父类加载器可以完成类的加载任务就成功返回,如果无法完成类加载任务,子类加载器才会自己去加载,这就是双亲委派机制。这样避免了类的重复加载并且保护了程序安全,防止核心类库被修改。
双亲委派机制的代码实现在ClassLoader
的loadClass
方法中,这也意味着我们只需要重写这个方法就可以破坏双亲委派机制。
// 首先,检查类是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类抛出异常则说明父类无法加载
}
if (c == null) {
// 父类无法加载时调用自身的findClass方法
long t1 = System.nanoTime();
c = findClass(name);
}
}
ClassLoader getParent() //返回该类加载器的超类加载器
Class<?> loadClass(String name) //加载name类
Class <?>findClass(String name) //查找name类
Class<?> findLoadedClass(String name)
Class<?> defineClass(String name, byte[] b, int off, int len) //将字节数组中的内容转换为一个Class对象
static InputStream getSystemResourceAsStream(String name)
自定义类加载器
自定义类加载器只需继承ClassLoader类重写findClass方法。
//重写findClass,如果你想加载的类还在类路径下那么此方式将毫无意义。
public class MyClassLoader extends ClassLoader {
@Override
public Class<?> findClass(String name) throws ClassNotFoundException {
try {
String filename=name.substring(name.lastIndexOf(".")+1)+".class";
InputStream in = getClass().getResourceAsStream(filename);
byte[] bytes = new byte[in.available()];
in.read(bytes);
return defineClass(name,bytes,0,bytes.length);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
ServiceLoader
如果一个基础类要调用用户的代码,那么双亲委派机制将不可能完成。此时就需要借助ServiceLoader类加载器。jdk6前的解决方法是使用线程上下文类加载器,如果在创建线程时未设置,它将从父线程中继承一个,如果在全局范围内都没设置,那么线程上下文类加载器将是应用程序类加载器。
验证
确保字节码文件中包含的信息符合当前虚拟机的要求,确保被加载类的正确性不会危害虚拟机自身安全。
准备
为类字段在堆中分配内存并且设置初始零值。如果类字段的属性表中含有ConstantValue
属性,那么类字段的初始值会设置为该属性指定的初始值。
类型 | 默认值 |
---|---|
byte | (byte)0 |
short | (short)0 |
int | 0 |
long | 0L |
float | 0.0f |
double | 0.0 |
char | \u0000 |
boolean | false |
reference | null |
解析
解析是JVM将常量池内的符号引用转换为直接引用的过程。可以对同一个符号引用进行多次解析,但通常JVM可以对第一次解析的结果进行缓存。不过对于invokedynamic
指令而言必须等到程序实际运行到这条指令时,解析动作才开始。
类或接口解析
- 如果要解析的类不是一个数组类型,那么JVM会将该类的全限定名传给当前类的类加载器,然后进行加载过程。
- 如果要解析的类是一个数组类型,而且数组元素的类型为对象,那么会按照第一步解析这个数组元素。
- 如果上面两步没有出现任何错误,那么类就加载成功了,在解析完成前还要进行符号引用验证,确认当前类对解析类是否有访问权限。如果没有访问权限,则抛出
IllegalAccessError
异常。
字段解析
字段解析之前首先要对字段所在的类进行解析,字段解析的步骤如下:
- 如果类包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,解析结束。
- 否则,如果类实现了接口,则按照继承关系从上往下搜索各个接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,解析结束。
- 否则,如果类不是
Object
,则按照继承关系从上到下搜索其父类,如果父类包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,解析结束。 - 否则,解析失败,抛出NoSuchFieldError异常。
- 如果成功返回了引用,则对这个字段进行权限认证,如果没有访问权限,则抛出
IllegalAccessError
异常。
类方法解析
类方法解析之前首先要对方法所在的类型进行解析,类方法解析的步骤如下:
- 如果该类型是一个接口,则抛出IncompatibeClassChangeError异常。
- 如果类中存在简单名称和描述符和目标匹配的方法,则返回该方法的直接引用。
- 否则,在该类的父类中搜索,存在匹配的方法则返回该方法的直接引用。
- 否则,在该类实现的接口中寻找,如果寻找到了说明该类是一个抽象类,抛出AbstractMethodError异常。
- 否则,直接抛出NoSuchMethodError异常。
- 如果成功返回了引用,则对这个方法进行权限认证,如果没有访问权限,则抛出
IllegalAccessError
异常。
接口方法解析
类方法解析之前首先要对方法所在的类型进行解析,接口方法解析的步骤如下:
- 如果该类型是一个类,则抛出IncompatibeClassChangeError异常。
- 如果接口中存在简单名称和描述符和目标匹配的方法,则返回该方法的直接引用。
- 否则,在该接口的父接口中搜索,存在匹配的方法则返回该方法的直接引用。
- 否则,直接抛出NoSuchMethodError异常。
初始化
初始化阶段JVM才真正运行类中定义的代码,初姞化阶段就是执行<clinit>()
方法的过程。此方法由编译器自动收集类中的所有类变量的赋值动作和静态代码块(静态代码块中只能访问在静态代码块之前定义的类变量,但可以给所有静态变量赋值)中的语句合并而来。编译器收集的顺序是按照语句在源文件中出现的顺序决定。若该类具有父类,JVM会保证子类的<clinit>()
执行前,父类的<clinit>()
已经执行完毕。对于接口而言,执行子接口的<clinit>()
方法之前并不会执行父接口的<clinit>()
,并且类在初始化时并不会执行接口的<clinit>()
。JVM必须保证一个类的<clinit>()
方法在多线程下会被同步加锁。也就是说一个类的<clinit>()
方法只能被执行一次。如果类和接口中没有静态代码块或者没有位类变量赋值,那么编译器就不会生成此方法。此外类和接口只有在主动使用的情况下才会执行<clinit>()
方法。
主动使用情况
- 使用new关键字实例化对象
- 调用一个类型的静态方法
- 读取或设置一个类型的静态字段(被final修饰已在编译期把结果放到常量池中的除外)
- 使用反射对类型进行反射调用的时候,如果类型没有初始化,那么需要对类型进行初始化。
- 初始类时需要先将其父类进行初始化。
- 实现含有默认方法的接口的类初始化时,那么该接口要在其之前初始化。
- JVM启动时,包含用户指定main方法的类。
被动使用
- 当访问一个静态字段的时候,只有真正声明这个字段的类才会被初始化,如子类引用父类中静态字段时不会初始化子类。
- 通过数组定义来引用类,不会触发类的初始化。
- 引用常量不会导致初始化。
- 调用
ClassLoader
类的loaderClass()
方法加载一个类。
类型转型
在引用类型变量之间没有类似基本类型变量之间的类型转换,但子类和基类之间在使用多态的前提下可以进行类型转型:
public class Person {
public static void main(String[] args) {
Person person1=new Teacher();//向上转型
Person person2=new Person();
Teacher teacher=(Teacher) person1;//向下转型
teacher=(Teacher)person2;//error,编译期不报错,运行时报错
}
}
class Teacher extends Person{
}
值得注意的是我们要区分强制类型转换和向下转型的区别,在对象类型之间是没有强制类型转换且只有在使用了向上转型后才能使用向下转型。
对象
对象的存储
在java中有两类对象:非数组对象和数组对象,它们在堆中存储的结构略有不同。如果是数组类型对象,那么对象头还包含一个用于存储数组长度的指针。
对象头
对象头在64位机器上为64位,它包含两类数据,Mark Word
用于存储对象自身的运行时数据:
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码,对象分代年龄 | 01 | 未锁定 |
偏向锁记录的指针 | 00 | 轻量级绑定 |
指向重量级锁的指针 | 10 | 膨胀 |
空、不需要记录信息 | 11 | GC标记 |
偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
类型指针指向对象类型元数据。
实例数据
程序代码内定义的各种类型字段的内容
对其填充
HotSpot虚拟机规定任意对象的大小都必须是8字节的整数倍,如果对象不符合规定的话就需要对其填充来补全。
对象实例化
当Java虚拟机遇到一条new
指令时,首先去检查这个指令的参数是否能在运行时常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。如果没有则先执行类加载过程。类加载完成后将在堆中为对象分配空间,空间的大小在类加载过程就已经确定。内存分配完成后就会进行对象的初始化(见下文)。
对象初始化
对象初始化分为三个步骤:初始化零值、设置对象头、使用前的初始化。
初始化零值
当我们使用new关键字实例化对象时,首先会在堆上为当前对象分配空间,分配完成后就会进行实例字段的初始化零值。
对象头的设置
使用前的初始化
初始化零值后,编译器会执行<init>()
方法,此方法也是编译器收集实例字段的显示初始化语句以及构造函数的赋值语句合并而来。在执行子类<init>()
方法时会先执行父类<init>()
方法。该方法中的初始化顺序就是源文件中出现的顺序,但是如果有{}
代码块那么{}
代码块将先执行。
public class Person {
String name="person";
int age;
int height;
{
height=100;
}
Person(){
age=10;
}
}
查看一下<init>()
方法字节码
0 aload_0
1 invokespecial #1 <java/lang/Object.<init>>
4 aload_0
5 ldc #2 <person>
7 putfield #3 <ClassloadingSubsystem/Person.name>
10 aload_0
11 bipush 100
13 putfield #4 <ClassloadingSubsystem/Person.height>
16 aload_0
17 bipush 10
19 putfield #5 <ClassloadingSubsystem/Person.age>
22 return
初始化测试
public class Father {
private int i=test();
private static int j=methods();
static {
System.out.println("(1)");
}
Father(){
System.out.println("(2)");
}
{
System.out.println("(3)");
}
public int test(){
System.out.println("(4)");
return 1;
}
public static int methods(){
System.out.println("(5)");
return 1;
}
}
class Son extends Father{
private int i=test();
private static int j=methods();
static {
System.out.println("(6)");
}
Son(){
System.out.println("(7)");
}
{
System.out.println("(8)");
}
public int test(){
System.out.println("(9)");
return 1;
}
public static int methods(){
System.out.println("(10)");
return 1;
}
public static void main(String[] args) {
Son son1 = new Son();
System.out.println();
Son son2 = new Son();
}
}
(5) (1) (10) (6) (9) (3) (2) (9) (8) (7)
(9) (3) (2) (9) (8) (7)
对象的消亡
分代收集理论
分代收集理论认为垃圾收集器应将堆划分为不同的区域,然后将回收对象依据年龄(熬过回收的次数)分配到不同的区域中存储。大多数垃圾收集器都遵循了分代收集理论。
在划分为不同的区域之后,垃圾收集器每次只回收其中某个或某些区域,因此有以下收集类型的分类:
- 新生代收集(Young GC):只对新生代进行来集回收
- 老年代收集(Old GC):只对老年代进行垃圾回收
- 混合收集(Mixed GC):只对新生代和部分老年代进行垃圾回收(G1专属)
- 整堆收集(Full GC):收集整个堆
垃圾收集算法
垃圾收集算法分为两个阶段:标记阶段和清理阶段。
标记阶段
在堆内存放着几乎所有的java对象,在GC回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象,只有已经被标记为死亡的对象,GC才会在回收时释放其占用的内存空间,这个过程称为垃圾标记阶段。
可达性分析算法
以GCRoots
为起始点,根据引用关系向下搜索,搜索的路径称为引用链,如果某对象没有与任何引用链相连,那就意味着该对象已经死亡,可以标记为垃圾对象。
可成为GCRoots的对象
- 在栈帧中引用的对象
- 本地方法栈内JNI引用的对象
- 元空间中类静态属性引用的对象
- 元空间中常量引用的对象
- 被
synchronized
持有的对象 - JVM内部的引用,如基本类型的Class对象、常驻的异常对象和系统类加载器
对象finalization机制
一个对象真正被标记为垃圾对象至少需要两次标记过程,如果对象在进行可达性分析后发现没有引用链与GCRoots相连,那么就会被第一次标记,随后再进行一次筛选,筛选的条件是对象是否有必要执行finalize()
方法,如果对象没有覆盖此方法或者已经被JVM调用,那么JVM将认定此对象没有必要执行finalize()
方法。此对象就再次被标记为垃圾对象从而被回收。否则就将此对象加入一个队列,然后由一条JVM自动建立的线程执行这些对象的finalize()
方法,如果在此方法内对象回到了GCRoots的引用链,那么就不会被回收,否则就会再次被标记从而被回收。值得注意的是对象的finalize()
方法只会被执行一次,无论进行几次来集回收。
列举GCRoots
垃圾收集器在列举GCRoots时会导致用户线程的停顿,因为可达性分析算法必须要在一个能保障一致性的快照中进行,这点不满足的话分析结果的准确性就无法保证。
OopMap
在HotSpot中,列举GCRoots时并不需要检查所有执行上下文,HotSpot使用了一组称为OopMap的数据结构,HotSpot可以将引用的位置添加到该数据结构中,在列举GCRoots时扫描这些OopMap即可。
记忆集和卡表
新生代和老年代的对象可能会出现跨代引用的情况,记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。在HotSpot中,使用一种称为卡表的方式实现记忆集,卡表是一个字节数组,数组上的每一个元素都对应这其标识内存区域中一块特定大小的内存块,这个内存块称为卡页,一个卡页内存中通常包含不止一个对象,只要卡页内有一个对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏,没有则标识为0。在垃圾收集时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把他们加入到GCRoots中一并扫描。
查找引用链
查找引用链是一个与用户线程并发执行的过程,这种并发执行在满足以下两个条件时会出现对象消失的问题:
- 用户线程为已经标记为存活的对象添加了新的引用。
- 用户线程断开了其它未扫描的对象到这些新对象的引用。
同时满足这两个条件就会出现这些新对象消失的问题。要解决这个问题,只要破坏这两个条件中的一个即可,由此出现了两种解决方案。
增量更新
增量更新破环了第一个条件:当已标记为存活的对象添加了新的引用时,这些标记玩的对象变为未标记。
原始快照
原始快照破坏了第二个条件:无论引用关系是否断开,都会按照未断开时的状态扫描。
写屏障
写屏障可以看作在JVM层面对引用类型字段赋值这个动作的AOP切面,在引用对象赋值时会产生一个环绕通知,在赋值前的部分的写屏障叫做写前屏障,在赋值后的部分的写屏障叫做写后屏障。上文提到的记忆集、增量更新、 原始快照等都是通过写屏障实现的。
清除阶段
标记-清除算法
在标记阶段完成后,将标记的对象统一进行清除。它的缺点是效率不高并且内存碎片化严重。
标记-复制算法
标记-复制算法将可用的内存空间按容量分成相等的两部分,每次只使用其中一部分,,在垃圾回收时将使用的内存区域中的存活对象移动到保留区域中,之后清除正在使用的内存区域,然后交换两个内存的角色。它的缺点是可利用的内存会少一半,并且在复制对象时会消耗大量时间。
标记-整理算法
在垃圾回收时将所有存活对象移动到内存的一端,然后清理掉边界以外的其它内存。它的缺点是必须更新对象的所有引用地址。
安全点和安全区域
安全点
用户线程并非在所有地方都能停顿下来开始垃圾回收,只有在特定的位置才能停下来,这些位置叫作安全点,通常会在执行时间较长的指令前设置安全点,当需要进行垃圾回收时,需要设置一个标志位,各个线程执行时会不停的主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上挂起。
安全区域
安全区域确保在一段程序代码片段中对象的引用关系不会发生变化,在这个区域中的任何位置开始垃圾回收都是安全的,当线程运行到安全区域时,首先标识已经进入安全区域,如果这段时间内发生GC,JVM会忽略标识为安全区域的线程,这主要用于解决一些线程处于阻塞状态而无法到达安全点的情况。当线程离开安全区域时,它要检查JVM是否列举完GCRoots,如果完成了,那线程就当什么也没发生,继续执行,否则它将一直等待,直到收到可以离开安全区的信号为止。
垃圾收集器
Serial
Serial是一个单线程收集器,它在进行垃圾回收时必须停止所有用户线程,直至它收集结束。
它的优点是简单高效,常用于资源受限的情况。
Serial Old
Serial的老年代版本。
ParNew
ParNew是Serial的多线程版本。
Parallel Scavenge
基本与ParNew相同,但它主要关注吞吐量而非减少STW时间,并且它还可以激活自适应调节功能。
Parallel Old
Parallel Scavenge的老年代版本。
CMS
CMS是一种以实现最短STW时间的垃圾收集器。它的运行过程比较复杂,分为以下四个阶段:
- 初始标记:标记一下GCRoots能直接关联的对象
- 并发标记:查找引用链的过程
- 重新标记:增量更新阶段
- 并发清除:清除标记死亡的对象
CMS垃圾收集器的缺点如下:
- 在并发阶段,虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢。
- CMS无法处理浮动垃圾,因此CMS不能等到老年区几乎完全填满再进行收集,必须预留一部分。
- 要是CMS预留的内存无法满足需求,就会出现一次并发失败,这时JVM将冻结用户线程,使用Serial Old垃圾收集器重新对老年区进行收集。
- CMS会产生大量的内存碎片。
G1
G1不再针对新生代或老年代进行回收,而是针对所有内存区域进行回收,哪块内存区域中的垃圾数量多,就回收哪块内存区域。G1也遵循分代收集理论,但是它不再坚持固定大小以及固定数量的粉黛区域划分,而是把连续的堆划分为多个大小相等的独立区域,每个区域都可以根据需求扮演新生代的Eden、Survivor或者老年代空间。还有一类特殊的humongous
区域,他们专门用于存储大对象,G1认为只要大小超过了一个区域容量的一半的对象即可判定为大对象。G1的大多数行为都把humongous
区域当作老年代处理。
G1的运作过程分为以下四个阶段:
- 初始标记:标记一下GCRoots能直接关联的对象
- 并发标记:查找引用链的过程
- 最终标记:原始快照阶段
- 筛选回收:对每个独立区域进行计划回收
内存分配到消亡的过程
当创建对象时首先在Eden区分配内存,Eden放满时进行YGC垃圾回收,将还在使用的对象放到S1(S2)区,对象每移动一次就会给它增加一个年龄值。
随着对象的创建,每当Eden满时就会进行YGC垃圾回收,但S1(S2)满时并不会进行YGC垃圾回收。当S1(S2)空间无法容纳一次YGC之后存活的对象时,就需要依赖其它区域进行分配担保。
当一个对象的年龄达到阈值(默认15)时,就会将这个对象移至老年代。
特殊情况
- 若Eden为空的情况下新建对象放不下,那么此时该对象将直接放入老年代。
- 如果老年代已满则将触发Full GC,若老年代回收后仍放不下对象则将触发OOM错误。
方法的调用和执行
值传递
调用
JVM方法调用指令
- invokestatic:用于调用static方法
- invokespecial :用于调用
<init>
方法、私有方法和父类中的方法 - invokeinterface :用于调用接口方法,会在运行时再确认一个实现该接口的对象
- invokevirtual:用于调用所有虚方法
- invokedynamic :先在运行时确定调用点符号引用所引用的方法,然后再执行该方法,前四条调用指令分派逻辑都固化在JVM内部,而该指令的分派逻辑是由用户自己决定的
虚方法和非虚方法
方法在程序运行之前就有一个可调用的版本,并且这个方法的调用版本在运行时期是不可改变的。这种方法称为非虚方法,除此之外的都成为虚方法。java中的非虚方法有:
- 静态方法
- 私有方法
- 实例构造器
- 父类方法
- 被final修饰的方法
也就是被invokespecial
和invokestatic
调用的方法。非虚方法的调用都可以在解析阶段确定唯一的调用版本。
静态分派与方法重载
public class Person {
public void hello(Person person){
System.out.println("person say hello");
}
public void hello(Teacher teacher){
System.out.println("teacher say hello");
}
public void hello(Student student){
System.out.println("student say hello");
}
public static void main(String[] args) {
Person teacher=new Teacher();
Person student=new Student();
Person person = new Person();
person.hello(teacher);//person say hello
person.hello(student);//person say hello
}
}
class Teacher extends Person{}
class Student extends Person{}
以teacher
变量为例,上面的Person
是teacher
变量的静态类型,而Teacher
是teacher
变量的运行时类型。静态类型是编译时可知的,所以在编译时编译器通过方法参数的静态类型决定选用那个重载版本,因此两个方法输出都为person say hello
。以静态类型为依据来决定方法执行版本的分派称为静态分派,静态分派是方法重载的本质。
动态分派与方法重写
public class Person {
public void hello(){
System.out.println("person say hello");
}
public static void main(String[] args) {
Person teacher=new Teacher();
Person student=new Student();
teacher.hello();//teacher say hello
student.hello();//student say hello
}
}
class Teacher extends Person{
@Override
public void hello() {
System.out.println("teacher say hello");
}
}
class Student extends Person{
@Override
public void hello() {
System.out.println("student say hello");
}
}
以上hello()
方法是一个虚方法,调用它的指令为invokevirtual
,该指令的执行步骤如下:
- 首先找到
teacher
对象的实际类型Teacher
- 如果在Teacher中找到方法签名相同的方法,就进行访问权限的验证,验证通过就返回此方法的直接引用,否则抛出异常。
- 如果没有找到签名相同的方法,就按照继承关系从下到上依次对Teacher的各个父类进行寻找和验证。
- 始终没有找到签名相同的方法则抛出异常。
这种在运行时通过实际类型确定方法执行版本的分派过程称为动态分派,动态分派是方法重写的本质。