目录
2). 使用Class类的newInstance方法(反射机制)
3). 使用Constructor类的newInstance方法(反射机制)
1.4 分代收集算法 Generational Collection(分代收集)算法
更多Java架构师核心资料持续更新中https://github.com/kepeihong/data.git
一、对象和类的生命周期
Java 对象生命周期
在JVM运行空间中,对象的整个生命周期大致可以分为7个阶段:创建阶段(Creation)、应用阶段(Using)、不可视阶段(Invisible)、不可到达阶段(Unreachable)、可收集阶段(Collected)、终结阶段(Finalized)与释放阶段(Free)。上面的这7个阶段,构成了 JVM中对象的完整的生命周期。下面分别介绍对象在处于这7个阶段时的不同情形。
创建阶段
在对象创建阶段,系统要通过下面的步骤,完成对象的创建过程:
(1)为对象分配存储空间。
(2)开始构造对象。
(3)递归调用其超类的构造方法。
(4)进行对象实例初始化与变量初始化。
(5)执行构造方法体。
上面的5个步骤中的第3步就是指递归地调用该类所扩展的所有父类的构造方法,一个Java类(除Object类外)至少有一个父类(Object),这个规则既是强制的,也是隐式的。你可能已经注意到在创建一个Java类的时候,并没有显式地声明扩展(extends)一个Object父类。实际上,在 Java程序设计中,任何一个Java类都直接或间接的是Object类的子类。例如下面的代码:
public class A {
…
}
这个声明等同于下面的声明:
public class A extends java.lang.Object {
…
}
上面讲解了对象处于创建阶段时,系统所做的一些处理工作,其中有些过程与应用的性能密切相关,因此在创建对象时,我们应该遵循一些基本的规则,以提高应用的性能。
下面是在创建对象时的几个关键应用规则:
(1)避免在循环体中创建对象,即使该对象占用内存空间不大。
(2)尽量及时使对象符合垃圾回收标准。
(3)不要采用过深的继承层次。
(4)访问本地变量优于访问类中的变量。
关于规则(1)避免在循环体中创建对象,即使该对象占用内存空间不大,需要提示一下,这种情况在我们的实际应用中经常遇到,而且我们很容易犯类似的错误,例如下面的代码:
… …
for (int i = 0; i < 10000; ++i) {
Object obj = new Object();
System.out.println("obj= "+ obj);
}
… …
上面代码的书写方式相信对你来说不会陌生,也许在以前的应用开发中你也这样做过,尤其是在枚举一个Vector对象中的对象元素的操作中经常会这样书写,但这却违反了上述规则(1),因为这样会浪费较大的内存空间,正确的方法如下所示
… …
Object obj = null;
for (int i = 0; i < 10000; ++i) {
obj = new Object();
System.out.println("obj= "+ obj);
}
… …
采用上面的第二种编写方式,仅在内存中保存一份对该对象的引用,而不像上面的第一种编写方式中代码会在内存中产生大量的对象应用,浪费大量的内存空间,而且增大了系统做垃圾回收的负荷。因此在循环体中声明创建对象的编写方式应该尽量避免。
另外,不要对一个对象进行多次初始化,这同样会带来较大的内存开销,降低系统性能,如:
public class A {
private Hashtable table = new Hashtable ();
public A() {
// 将Hashtable对象table初始化了两次
table = new Hashtable();
}
}
正确的方式为:
public class B {
private Hashtable table = new Hashtable ();
public B() {
}
}
不要小看这个差别,它却使应用软件的性能相差甚远,如图2-5所示。
图2-5 初始化对象多次所带来的性能差别
看来在程序设计中也应该遵从“勿以恶小而为之”的古训,否则我们开发出来的应用也是低效的应用,有时应用软件中的一个极小的失误,就会大幅度地降低整个系统的性能。因此,我们在日常的应用开发中,应该认真对待每一行代码,采用最优化的编写方式,不要忽视细节,不要忽视潜在的问题。
应用阶段
当对象的创建阶段结束之后,该对象通常就会进入对象的应用阶段。这个阶段是对象得以表现自身能力的阶段。也就是说对象的应用阶段是对象整个生命周期中证明自身“存在价值”的时期。在对象的应用阶段,对象具备下列特征:
◆系统至少维护着对象的一个强引用(Strong Reference);
◆所有对该对象的引用全部是强引用(除非我们显式地使用了:软引用(Soft Reference)、弱引用(Weak Reference)或虚引用(Phantom Reference))。
上面提到了几种不同的引用类型。可能一些读者对这几种引用的概念还不是很清楚,下面分别对之加以介绍。在讲解这几种不同类型的引用之前,我们必须先了解一下Java中对象引用的结构层次。
Java对象引用的结构层次示意如图2-6所示。
图1 对象引用的结构层次示意
由图1我们不难看出,上面所提到的几种引用的层次关系,其中强引用处于顶端,而虚引用则处于底端。下面分别予以介绍。
1.强引用
强引用(Strong Reference)是指JVM内存管理器从根引用集合(Root Set)出发遍寻堆中所有到达对象的路径。当到达某对象的任意路径都不含有引用对象时,对这个对象的引用就被称为强引用。
2.软引用
软引用(Soft Reference)的主要特点是具有较强的引用功能。只有当内存不够的时候,才回收这类内存,因此在内存足够的时候,它们通常不被回收。另外,这些引用对象还能保证在Java抛出OutOfMemory 异常之前,被设置为null。它可以用于实现一些常用资源的缓存,实现Cache的功能,保证最大限度的使用内存而不引起OutOfMemory。再者,软可到达对象的所有软引用都要保证在虚拟机抛出OutOfMemoryError
之前已经被清除。否则,清除软引用的时间或者清除不同对象的一组此类引用的顺序将不受任何约束。然而,虚拟机实现不鼓励清除最近访问或使用过的软引用。下面是软引用的实现代码:
import java.lang.ref.SoftReference;
…
A a = new A();
…
// 使用 a
…
// 使用完了a,将它设置为soft 引用类型,并且释放强引用;
SoftReference sr = new SoftReference(a);
a = null;
…
// 下次使用时
if (sr!=null) {
a = sr.get();
}
else{
// GC由于内存资源不足,可能系统已回收了a的软引用,
// 因此需要重新装载。
a = new A();
sr=new SoftReference(a);
}
软引用技术的引进,使Java应用可以更好地管理内存,稳定系统,防止系统内存溢出,避免系统崩溃(crash)。因此在处理一些占用内存较大而且声明周期较长,但使用并不频繁的对象时应尽量应用该技术。正像上面的代码一样,我们可以在对象被回收之后重新创建(这里是指那些没有保留运行过程中状态的对象),提高应用对内存的使用效率,提高系统稳定性。但事物总是带有两面性的,有利亦有弊。在某些时候对软引用的使用会降低应用的运行效率与性能,例如:应用软引用的对象的初始化过程较为耗时,或者对象的状态在程序的运行过程中发生了变化,都会给重新创建对象与初始化对象带来不同程度的麻烦,有些时候我们要权衡利弊择时应用。
3.弱引用
弱引用(Weak Reference)对象与Soft引用对象的最大不同就在于:GC在进行回收时,需要通过算法检查是否回收Soft引用对象,而对于Weak引用对象, GC总是进行回收。因此Weak引用对象会更容易、更快被GC回收。虽然,GC在运行时一定回收Weak引用对象,但是复杂关系的Weak对象群常常需要好几次GC的运行才能完成。Weak引用对象常常用于Map数据结构中,引用占用内存空间较大的对象,一旦该对象的强引用为null时,对这个对象引用就不存在了,GC能够快速地回收该对象空间。与软引用类似我们也可以给出相应的应用代码:
import java.lang.ref.WeakReference;
…
A a = new A();
…
// 使用 a
…
// 使用完了a,将它设置为weak 引用类型,并且释放强引用;
WeakReference wr = new WeakReference (a);
a = null;
…
// 下次使用时
if (wr!=null) {
a = wr.get();
}
else{
a = new A();
wr = new WeakReference (a);
}
弱引用技术主要适用于实现无法防止其键(或值)被回收的规范化映射。另外,弱引用分为“短弱引用(Short Week Reference)”和“长弱引用(Long Week Reference)”,其区别是长弱引用在对象的Finalize方法被GC调用后依然追踪对象。基于安全考虑,不推荐使用长弱引用。因此建议使用下面的方式创建对象的弱引用。
WeakReference wr = new WeakReference(obj);
或
WeakReference wr = new WeakReference(obj, false);
虚引用(Phantom Reference)的用途较少,主要用于辅助finalize函数的使用。Phantom对象指一些执行完了finalize函数,并且为不可达对象,但是还没有被GC回收的对象。这种对象可以辅助finalize进行一些后期的回收工作,我们通过覆盖Reference的clear()方法,增强资源回收机制的灵活性。虚引用主要适用于以某种比 java 终结机制更灵活的方式调度 pre-mortem 清除操作。
4.虚引用
&注意 在实际程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
不可视阶段
在一个对象经历了应用阶段之后,那么该对象便处于不可视阶段,说明我们在其他区域的代码中已经不可以再引用它,其强引用已经消失,例如,本地变量超出了其可视范围,如下所示。
public void process () {
try {
Object obj = new Object();
obj.doSomething();
} catch (Exception e) {
e.printStackTrace();
}
while (isLoop) { // ... loops forever
// 这个区域对于obj对象来说已经是不可视的了
// 因此下面的代码在编译时会引发错误
obj.doSomething();
}
}
不可到达阶段如果一个对象已使用完,而且在其可视区域不再使用,此时应该主动将其设置为空(null)。可以在上面的代码行obj.doSomething();下添加代码行obj = null;,这样一行代码强制将obj对象置为空值。这样做的意义是,可以帮助JVM及时地发现这个垃圾对象,并且可以及时地回收该对象所占用的系统资源。
处于不可到达阶段的对象,在虚拟机所管理的对象引用根集合中再也找不到直接或间接的强引用,这些对象通常是指所有线程栈中的临时变量,所有已装载的类的静态变量或者对本地代码接口(JNI)的引用。这些对象都是要被垃圾回收器回收的预备对象,但此时该对象并不能被垃圾回收器直接回收。其实所有垃圾回收算法所面临的问题是相同的——找出由分配器分配的,但是用户程序不可到达的内存块。
可收集阶段、终结阶段与释放阶段
对象生命周期的最后一个阶段是可收集阶段、终结阶段与释放阶段。当对象处于这个阶段的时候,可能处于下面三种情况:
(1)垃圾回收器发现该对象已经不可到达。
(2)finalize方法已经被执行。
(3)对象空间已被重用。
当对象处于上面的三种情况时,该对象就处于可收集阶段、终结阶段与释放阶段了。虚拟机就可以直接将该对象回收了。
Java 类生命周期
1、类的生命周期
java类的生命周期就是指一个class文件从加载到卸载的全过程。
类的完整生命周期包括7个部分:加载(双亲委派)、链接(验证:(文件格式验证、元数据验证、字节码验证、符号引用验证)、准备(分配内存,初始化变量)、解析)、初始化、使用、卸载。,如下图所示
其中,验证——准备——解析 称为连接阶段,除了解析外,其他阶段是顺序发生的,而解析可以与这些阶段交叉进行,因为Java支持动态绑定(晚期绑定),需要运行时才能确定具体类型;在使用阶段实例化对象。
2、类的初始化触发
类的加载机制没有明确的触发条件,但是有5种情况下必须对类进行初始化,那么 加载——验证——准备 就必须在此之前完成了。
1:通过new关键字实例化对象、读取或设置类的静态变量、调用类的静态方法。
2:通过反射方式执行以上三种行为。
3:初始化子类的时候,会触发父类的初始化。
4:虚拟机启动时,初始化一个执行主类;(作为程序入口直接运行时(也就是直接调用main方法)。)
5、使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、RE_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发其初始化。
注意,有且只有五种情况必须对类进行初始化,这五种情况被称为“主动引用”,除了这五种情况,所有其他的类引用方式都不会触发类初始化,被称为“被动引用”。
请看主动引用的示例代码:
import java.lang.reflect.Field;
import java.lang.reflect.Method;
class InitClass{
static {
System.out.println("初始化InitClass");
}
public static String a = null;
public static void method(){}
}
class SubInitClass extends InitClass{}
public class Test1 {
/**
* 主动引用引起类的初始化的第四种情况就是运行Test1的main方法时
* 导致Test1初始化,这一点很好理解,就不特别演示了。
* 本代码演示了前三种情况,以下代码都会引起InitClass的初始化,
* 但由于初始化只会进行一次,运行时请将注解去掉,依次运行查看结果。
* @param args
* @throws Exception
*/
public static void main(String[] args) throws Exception{
// 主动引用引起类的初始化一: new对象、读取或设置类的静态变量、调用类的静态方法。
// new InitClass();
// InitClass.a = "";
// String a = InitClass.a;
// InitClass.method();
// 主动引用引起类的初始化二:通过反射实例化对象、读取或设置类的静态变量、调用类的静态方法。
// Class cls = InitClass.class;
// cls.newInstance();
// Field f = cls.getDeclaredField("a");
// f.get(null);
// f.set(null, "s");
// Method md = cls.getDeclaredMethod("method");
// md.invoke(null, null);
// 主动引用引起类的初始化三:实例化子类,引起父类初始化。
// new SubInitClass();
}
}
请看被动引用的示例代码:
3、类的加载过程
从用户角度来说,类(对象)的生命周期只需笼统理解为“加载——使用——卸载”即可,无需太过深入。所以,这里的类加载过程就是我们说的 加载——验证——准备——解析——初始化 这五个使用前的阶段。
1:加载
加载阶段,虚拟机需要完成三件事:通过类名字获取类的二进制字节流——将字节流的内容转存到方法区——在内存中生成一个Class对象作为该类方法区数据的访问入口。
其中,第一步:通过类名获取类的二进制字节流是通过类加载器来完成的。其加载过程使用“双亲委派模型”:
类加载器的层次结构为:
启动类加载器:加载系统环境变量下JAVA_HOME/lib目录下的类库。
扩展类加载器:加载JAVA_HOME/lib/ext目录下的类库。
应用程序类加载器(系统类加载器):加载用户类路径Class_Path指定的类库。(我们可以在使用第三方插件时,把jar包添加到ClassPath后就是使用了这个加载器)
自定义加载器:如果需要自定义加载时的规则(比如:指定类的字节流来源、动态加载时性能优化等),可以自己实现类加载器。
双亲委派模型是指:当一个类加载器收到类加载请求时,不会直接加载这个类,而是把这个加载请求委派给自己父加载器去完成。如果父加载器无法加载时,子加载器才会去尝试加载。
采用双亲委派模型的原因:避免同一个类被多个类加载器重复加载。
2:验证
当一个类被加载之后,必须要验证一下这个类是否合法,比如这个类是不是符合字节码的格式、变量与方法是不是有重复、数据类型是不是有效、继承与实现是否合乎标准等等。总之,这个阶段的目的就是保证加载的类是能够被jvm所运行。
3:准备
为类变量(静态变量)在方法区分配内存,并设置零值。注意:这里是类变量,不是实例变量,实例变量是对象分配到堆内存时根据运行时动态生成的。
4:解析
把常量池中的符号引用解析为直接引用:根据符号引用所作的描述,在内存中找到符合描述的目标并把目标指针指针返回。
5:初始化
类的初始化过程是这样的:按照顺序自上而下运行类中的变量赋值语句和静态语句,如果有父类,则首先按照顺序运行父类中的变量赋值语句和静态语句。先看一个例子,首先建两个类用来显示赋值操作:
下面是演示初始化顺序的代码:
public class Field1{
public Field1(){
System.out.println("Field1构造方法");
}
}
public class Field2{
public Field2(){
System.out.println("Field2构造方法");
}
}
下面的代码:
class InitClass2{
public static Field1 f1 = new Field1();
public static Field1 f2;
static{
System.out.println("运行父类静态代码");
}
}
class SubInitClass2 extends InitClass2{
public static Field2 f2 = new Field2();
static{
System.out.println("运行子类静态代码");
}
}
public class Test2 {
public static void main(String[] args) throws ClassNotFoundException{
new SubInitClass2();
}
}
初始化顺序为:第02行、第05行、第10行、第12行,各位可以运行程序查看结果。
在类的初始化阶段,只会初始化与类相关的静态赋值语句和静态语句,也就是有static关键字修饰的信息,而没有static修饰的赋值语句和执行语句在实例化对象的时候才会运行。
四使用
类的使用包括主动引用和被动引用,主动引用在初始化的章节中已经说过了,下面我们主要来说一下被动引用:
- 引用父类的静态字段,只会引起父类的初始化,而不会引起子类的初始化。
- 定义类数组,不会引起类的初始化。
- 引用类的常量,不会引起类的初始化
被动引用的示例代码:
class InitClass{
static {
System.out.println("初始化InitClass");
}
public static String a = null;
public final static String b = "b";
public static void method(){}
}
class SubInitClass extends InitClass{
static {
System.out.println("初始化SubInitClass");
}
}
public class Test4 {
public static void main(String[] args) throws Exception{
// String a = SubInitClass.a;// 引用父类的静态字段,只会引起父类初始化,而不会引起子类的初始化
// String b = InitClass.b;// 使用类的常量不会引起类的初始化
SubInitClass[] sc = new SubInitClass[10];// 定义类数组不会引起类的初始化
}
}
最后总结一下使用阶段:使用阶段包括主动引用和被动引用,主动饮用会引起类的初始化,而被动引用不会引起类的初始化。
当使用阶段完成之后,java类就进入了卸载阶段。
在类使用完之后,如果满足下面的情况,类就会被卸载:
- 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。
- 加载该类的ClassLoader已经被回收。
- 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
如果以上三个条件全部满足,jvm就会在方法区垃圾回收的时候对类进行卸载,类的卸载过程其实就是在方法区中清空类信息,java类的整个生命周期就结束了。
二、对象的创建
当一个对象被创建时,虚拟机就会为其分配内存来存放对象自己的实例变量及其从父类继承过来的实例变量(即使这些从超类继承过来的实例变量有可能被隐藏也会被分配空间)。在为这些实例变量分配内存的同时,这些实例变量也会被赋予默认值(零值)。在内存分配完成之后,Java虚拟机就会开始对新创建的对象按照程序猿的意志进行初始化。在Java对象初始化过程中,主要涉及三种执行对象初始化的结构,分别是 实例变量初始化、实例代码块初始化 以及 构造函数初始化。
创建对象的方式:
1). 使用new关键字创建对象
这是我们最常见的也是最简单的创建对象的方式,通过这种方式我们可以调用任意的构造函数(无参的和有参的)去创建对象。比如:
Student student = new Student();
2). 使用Class类的newInstance方法(反射机制)
我们也可以通过Java的反射机制使用Class类的newInstance方法来创建对象,事实上,这个newInstance方法调用无参的构造器创建对象,比如:
Student student2 = (Student)Class.forName("Student类全限定名").newInstance();
或者:
Student stu = Student.class.newInstance();
3). 使用Constructor类的newInstance方法(反射机制)
java.lang.relect.Constructor类里也有一个newInstance方法可以创建对象,该方法和Class类中的newInstance方法很像,但是相比之下,Constructor类的newInstance方法更加强大些,我们可以通过这个newInstance方法调用有参数的和私有的构造函数,比如:
public class Student {
private int id;
public Student(Integer id) {
this.id = id;
}
public static void main(String[] args) throws Exception {
Constructor<Student> constructor = Student.class
.getConstructor(Integer.class);
Student stu3 = constructor.newInstance(123);
}
}
使用newInstance方法的这两种方式创建对象使用的就是Java的反射机制,事实上Class的newInstance方法内部调用的也是Constructor的newInstance方法。
4). 使用Clone方法创建对象
无论何时我们调用一个对象的clone方法,JVM都会帮我们创建一个新的、一样的对象,特别需要说明的是,用clone方法创建对象的过程中并不会调用任何构造函数。关于如何使用clone方法以及浅克隆/深克隆机制,简单而言,要想使用clone方法,我们就必须先实现Cloneable接口并实现其定义的clone方法,这也是原型模式的应用。比如:
public class Student implements Cloneable{
private int id;
public Student(Integer id) {
this.id = id;
}
@Override
protected Object clone() throws CloneNotSupportedException {
// TODO Auto-generated method stub
return super.clone();
}
public static void main(String[] args) throws Exception {
Constructor<Student> constructor = Student.class
.getConstructor(Integer.class);
Student stu3 = constructor.newInstance(123);
Student stu4 = (Student) stu3.clone();
}
}
5). 使用(反)序列化机制创建对象
当我们反序列化一个对象时,JVM会给我们创建一个单独的对象,在此过程中,JVM并不会调用任何构造函数。为了反序列化一个对象,我们需要让我们的类实现Serializable接口
三、对象的存储
1.对象内存分配方式
指针碰撞、空闲列表(并发安全,cas机制、分配缓冲)
Java堆是被所有线程共享的一块内存区域,主要用于存放对象实例,为对象分配内存就是把一块大小确定的内存从堆内存中划分出来,通常有指针碰撞和空闲列表两种实现方式。
1.指针碰撞法
假设Java堆中内存时完整的,已分配的内存和空闲内存分别在不同的一侧,通过一个指针作为分界点,需要分配内存时,仅仅需要把指针往空闲的一端移动与对象大小相等的距离。使用的GC收集器:Serial、ParNew,适用堆内存规整(即没有内存碎片)的情况下。
2.空闲列表法
事实上,Java堆的内存并不是完整的,已分配的内存和空闲内存相互交错,JVM通过维护一个列表,记录可用的内存块信息,当分配操作发生时,从列表中找到一个足够大的内存块分配给对象实例,并更新列表上的记录。使用的GC收集器:CMS,适用堆内存不规整的情况下。
内存分配并发问题
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
- CAS: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB: 为每一个线程预先分配一块内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配。
如果想多了,创建一个对象还是挺麻烦的,需要这么多步骤,那么我们在开发过程中尽量非必须的对象创建呢?
创建对象要点:
- 类加载机制检查:JVM首先检查一个new指令的参数是否能在常量池中定位到一个符号引用,并且检查该符号引用代表的类是否已被加载、解析和初始化过
- 分配内存:把一块儿确定大小的内存从Java堆中划分出来
- 初始化零值:对象的实例字段不需要赋初始值也可以直接使用其默认零值,就是这里起得作用
- 设置对象头:存储对象自身的运行时数据,类型指针
- 执行<init>:为对象的字段赋值
栈上分配:方法内的对象只在当前方法使用,没有其他地方使用,没必要创建在堆里,这是分配在栈桢里的私有空间
public void a(){
Test t = new Test();
}
内存逃逸:对象太大,或者会被其他方法调用,都会将对象存储在堆中,也就是从栈里逃到了堆中。
public Test a(){
return new Test();
}
2.对象的内存布局
对象头(运行时数据、gc标志、对象分代年龄、锁状态)、实例数据、对其填充
在 HotSpot 虚拟机中,对象在内存中的布局主要分为三部分:对象头(Header)
、实例数据(Instance Data)
、对齐填充(Padding)
。
图一:对象内存布局模型示例
如图所示,对象头主要包含两部分数据: MarkWord、类型指针。其中第一部分数据 MarkWord 用于存储哈希码(HashCode)
、GC分代年龄
、锁状态标志位
、线程持有的锁、偏向线程ID等信息。这部分数据长度在32位和64位虚拟机中的长度为32bit和64bit。
对象头的另外一部分是类型指针,即对象指向他的类元数据指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。另外,如果对象是一个 Java 数组,那么对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息来确定 Java 对象的大小,但是从数组的元数据中却无法确定数组的大小 。
接下来的实例数据(Instance Data)是对象真正存储的有效信息,也是代码中所定义的各种类型的字段信息。
第三部分对其填充并不是一定存在的,也没有特别的意义,只是起到了占位的作用。存在的原因是因为 HotSpot 虚拟机要求对象的起始地址必须是8的整数倍,换句话说就是对象的大小必须是8字节的整数倍,又因为对象头部分正好是8字节的整数倍,所以当实例数据部分没有对齐时,就需要通过对齐填充来补全。
对象头的 MarkWord
为了能够更加具体形象的看到对象的内存布局,我们使用 OpenJDK 的 JOL 包来做实验,先添加 maven 依赖。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
先定义一个普通的 Java 对象,代码如下:
public class Person {
String str = "test";
Son son = new Son();
}
class Son{
}
新定义的Person有两个属性,一个是普通的字符串,还有一个是自定义的类对象。接下来我们利用 JOL 包下的 ClassLayout 来输出他的内存布局,代码如下:
public class SeeBin {
public static void main(String[] args) throws InterruptedException {
Person person = new Person();
System.out.println(ClassLayout.parseInstance(person).toPrintable());
}
}
代码中初始化了一个 Person 对象,随后便直接打印了他的内存布局,输出结果如下:
图中的1、2、3、4分别对应 MarkWord、类型指针、实例数据、对齐填充。
- MarkWord:共8字节,该对象刚新建,还处于无锁状态,所以锁标识位是 01
- 类型指针:共4字节,标识新建的 person 属于哪个类
- 实例数据:共8字节,定义的 Person 有两个属性,str 和 son,他们对应的类型分别为 String 和 Son,这两个属性每个占4个字节
- 对齐填充:共4个字节,前三个部分所占大小相加 8+ 4+ 8 = 20,不是8的整数倍,所以得填充4个字节凑齐24字节。描述信息中也有说明: loss due to the next object aligment
MarkWord 的锁信息
前面我们提到对象头 MarkWord 中主要保存有锁信息、GC信息、HashCode。那么我们现在就看下 MarkWord 中的锁信息。锁主要有偏向锁、轻量级锁、重量级锁,锁之间的升级过程我们就不在这里说明了,可以参考《深入理解 Java 虚拟机》,各种锁在 MarkWord 中锁标识位的使用情况如下图所示:
图三:对象 MarkWord 字节说明
对照上图,再结合之前图二中对新建对象的内存模型输出结果,结果中表示锁信息的二进制是 001,再对照图三,是一个无锁状态。
接下来再试一种情况,我们知道 HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁,为此,我们先让程序 sleep 5s后再去输出对象的内存模型,修改后的代码如下:
public class SeeBin {
public static void main(String[] args) throws InterruptedException {
//线程sleep 5s,确保虚拟机中偏向锁开启
Thread.sleep(5000);
new Thread(new Runnable() {
@Override
public void run() {
Person person = new Person();
synchronized (person){
//当前锁对象第一次被线程获取
System.out.println(ClassLayout.parseInstance(person).toPrintable());
}
}
}).start();
}
}
图四:偏向锁
在上一段代码中,我们让线程 sleep 5s并新建一个线程去获取锁对象,因为当前只有一个线程去获取这个锁对象,所以虚拟机会把对象头中的标志位设置为偏向锁(101),并将获取这个锁的线程 ID 放到对象的 MarkWord 中,具体的字节对应可以将图四与图三中的偏向锁部分结合来看。
下面是一些能够模拟重量级锁的代码,就不细说了。
/**
* 重量级锁
*/
public class SeeBin {
static Person person = new Person();
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
//三个线程去竞争访问一个锁对象
for (int i = 0; i < 3; i++) {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (person){
System.out.println(ClassLayout.parseInstance(person).toPrintable());
}
}
}).start();
}
}
}
3.对象分配
1.Eden,survivor(from),survivor(to)8:1:1,为了减少送到老年代的对象、设置两个survivor是为了解决碎片化的问题(复制回收算法)
2.新生代:老年代1:2 eden空间不足进行yougGC,每回收一次年龄加1
四、对象的访问
对象访问:句柄(reference句柄地址)、直接指针(reference对象地址)
句柄访问
Java 堆中会分配一块内存作为句柄池。reference 存储的是句柄地址。详情见图。
直接指针访问
reference 中直接存储对象地址
比较:使用句柄的最大好处是 reference 中存储的是稳定的句柄地址,在对象移动(GC)是只改变实例数据指针地址,reference 自身不需要修改。直接指针访问的最大好处是速度快,节省了一次指针定位的时间开销。如果是对象频繁 GC 那么句柄方法好,如果是对象频繁访问则直接指针访问好。
内存间交互操作:
(1)lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
(2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
(3)read(读取):作用于主内存变量,把主内存的一个变量读取到工作内存中。
(4)load(载入):作用于工作内存,把read操作读取到工作内存的变量载入到工作内存的变量副本中
(5)use(使用):作用于工作内存的变量,把工作内存中的变量值传递给一个执行引擎。
(6)assign(赋值):作用于工作内存的变量。把执行引擎接收到的值赋值给工作内存的变量。
(7)store(存储):把工作内存的变量的值传递给主内存
(8)write(写入):把store操作的值入到主内存的变量中
注意:
(1)不允许read、load、store、write操作之一单独出现
(2)不允许一个线程丢弃assgin操作
(3)不允许一个线程不经过assgin操作,就把工作内存中的值同步到主内存中
(4)一个新的变量只能在主内存中生成
(5)一个变量同一时刻只允许一条线程对其进行lock操作。但lock操作可以被同一条线程执行多次,只有执行相同次数的unlock操作,变量才会解锁
(6)如果对一个变量进行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或者assgin操作初始化变量的值。
(7)如果一个变量没有被锁定,不允许对其执行unlock操作,也不允许unlock一个被其他线程锁定的变量
(8)对一个变量执行unlock操作之前,需要将该变量同步回主内存中
五、对象的回收
1、垃圾回收算法
垃圾收集算法:(复制算法(年轻代)、标记清除算法、标记整理算法(老年代)、分代收集)
1.1 标记-清除算法(Mark-Sweep)
这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。具体过程如下图所示:
从图中可以很容易看出标记-清除算法实现起来比较容易,但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。
标记-清除算法采用从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,如下图所示。标记-清除算法不需要进行对象的移动,只需对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。
优点:
算法简单,可以解决循环引用问题
缺点:
a、回收时应用需要挂起(STW:stop the word)
b、对象多的时候,标记和清除效率不高
c、地址不连续,造成内存碎片
1.2 复制算法(Copying)
为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:
这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。
很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。
复制算法的提出是为了克服句柄的开销和解决内存碎片的问题。它开始时把堆分成 一个对象 面和多个空闲面, 程序从对象面为对象分配空间,当对象满了,基于copying算法的垃圾 收集就从根集合(GC Roots)中扫描活动对象,并将每个 活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。
优点:
在存活对象不多的情况下,性能高,能解决内存碎片和java垃圾回收算法之-标记清除中导致的引用更新问题。
缺点:
会造成一部分的内存浪费。不过可以根据实际情况,将内存块大小比例适当调整;如果存活对象的数量比较大,coping的性能会变得很差。
1.3 标记-整理算法(Mark-compact)
为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动(美团面试题目,记住是完成标记之后,先不清理,先移动再清理回收对象),然后清理掉端边界以外的内存(美团问过)
标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。
优点:
a、解决了内存碎片问题
b、内存空间更加具有连续性
缺点:
a、压缩阶段移动了可用对象,需要更新引用信息,速度慢,但不占CPU
b、在移动过程中其他线程无法访问堆内存
具体流程见下图:
1.4 分代收集算法 Generational Collection(分代收集)算法
分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。
目前大部分垃圾收集器对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间(一般为8:1:1),每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。
而由于老年代的特点是每次回收都只回收少量对象,一般使用的是Mark-Compact算法。
3.4.1 年轻代(Young Generation)的回收算法 (回收主要以Copying为主)
a) 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
b) 新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。一个Eden区,两个 Survivor区(一般而言)。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空(美团面试,问的太细,为啥保持survivor1为空,答案:为了让eden和survivor0 交换存活对象), 如此往复。当Eden没有足够空间的时候就会 触发jvm发起一次Minor GC
c) 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC(Major GC),也就是新生代、老年代都进行回收。
d) 新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。
3.4.2 年老代(Old Generation)的回收算法(回收主要以Mark-Compact为主)
a) 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
b) 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。
3.4.3 持久代(Permanent Generation)(也就是方法区)的回收算法
用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代也称方法区,具体的回收可参见上文2.5节。
再写一遍:
方法区存储内容是否需要回收的判断可就不一样咯。方法区主要回收的内容有:废弃常量和无用的类。对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面3个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
- 加载该类的
ClassLoader
已经被回收; - 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
5 新生代和老年代的区别(阿里面试官的题目):
**所谓的新生代和老年代是针对于分代收集算法来定义的,新生代又分为Eden和Survivor两个区。加上老年代就这三个区。数据会首先分配到Eden区 当中(当然也有特殊情况,如果是大对象那么会直接放入到老年代(大对象是指需要大量连续内存空间的java对象)。),当Eden没有足够空间的时候就会 触发jvm发起一次Minor GC。如果对象经过一次Minor GC还存活,并且又能被Survivor空间接受,那么将被移动到Survivor空 间当中。并将其年龄设为1,对象在Survivor每熬过一次Minor GC,年龄就加1,当年龄达到一定的程度(默认为15)时,就会被晋升到老年代 中了,当然晋升老年代的年龄是可以设置的。如果老年代满了就执行:Full GC 因为不经常执行,因此采用了 Mark-Compact算法清理
其实新生代和老年代就是针对于对象做分区存储,更便于回收等等
2、对象能否回收
对象是否存活(引用计数法、可达性分析)
2.1 引用计数算法
2.1.1 算法分析
引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。
2.1.2 优缺点
优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。
缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。
2.2 可达性分析算法
可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。
在Java语言中,可作为GC Roots的对象包括下面几种:(京东)
a) 虚拟机栈中引用的对象(栈帧中的本地变量表);
b) 方法区中类静态属性引用的对象;
c) 方法区中常量引用的对象;
d) 本地方法栈中JNI(Native方法)引用的对象。
这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。如图所示,对象object 5、object 6、object 7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。
现在问题来了,可达性分析算法会不会出现对象间循环引用问题呢?答案是肯定的,那就是不会出现对象间循环引用问题。GC Root在对象图之外,是特别定义的“起点”,不可能被对象图内的对象所引用。
对象生存还是死亡(To Die Or Not To Die)
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finapze()方法。当对象没有覆盖finapze()方法,或者finapze()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。程序中可以通过覆盖finapze()来一场"惊心动魄"的自我拯救过程,但是,这只有一次机会呦。
/**
* 此代码演示了两点:
* 1.对象可以在被GC时自我拯救。
* 2.这种自救的机会只有一次,因为一个对象的finapze()方法最多只会被系统自动调用一次
* @author zzm
*/
pubpc class FinapzeEscapeGC {
pubpc static FinapzeEscapeGC SAVE_HOOK = null;
pubpc void isApve() {
System.out.println("yes, i am still apve :)");
}
@Override
protected void finapze() throws Throwable {
super.finapze();
System.out.println("finapze mehtod executed!");
FinapzeEscapeGC.SAVE_HOOK = this;
}
pubpc static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinapzeEscapeGC();
//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
//因为finapze方法优先级很低,所以暂停0.5秒以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isApve();
} else {
System.out.println("no, i am dead :(");
}
//下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
//因为finapze方法优先级很低,所以暂停0.5秒以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isApve();
} else {
System.out.println("no, i am dead :(");
}
}
}
运行结果为:
finapze mehtod executed!
yes, i am still apve :)
no, i am dead :(
2.3 对象死亡(被回收)前的最后一次挣扎
即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。
第一次标记:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记;
第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()
方法。在finalize()
方法中没有重新与引用链建立关联关系的,将被进行第二次标记。
第二次标记成功的对象将真的会被回收,如果对象在finalize()
方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。猿们还跟的上吧,嘿嘿。
2.4 元空间如何判断是否需要回收
元空间存储内容是否需要回收的判断可就不一样咯。元空间主要回收的内容有:废弃常量和无用的类。对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面3个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
- 加载该类的
ClassLoader
已经被回收; - 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
关于类加载的原理,也是阿里面试的主角,面试官也问过我比如:能否自己定义String,答案是不行,因为jvm在加载类的时候会执行双亲委派,
2.5何时进入老年代
1.survivor放不下,
2.对象存活年龄达到阈值;
3.动态年龄判断(相同年龄对象大小之和大于survivor一半以上,年龄大于这个值的对象都会进入老年代);
4.大对象
2.6FULLGC
1.老年代内存使用率达到阈值
2.空间担保(YGC之前,检查老年代最大可用连续空间是否大于新生代所有对象之和,小于则YGC不安全,如果不允许担保失败,进行fullGC,允许,检查老年代是否大于历次晋升平均值大小,小于则触发fullGC,)
3.元空间不足进行扩容,达到指定值,触发FGC
4.system.gc()
原因:
- 系统一次性加载过多大对象
- 内存泄漏,频繁创建大对象,无法被回收
- 生成长生命周期对象
- 参数设置问题
- 调用GC方法
3.垃圾回收器
一、常见垃圾收集器
现在常见的垃圾收集器有如下几种:
新生代收集器:
Serial
ParNew
Parallel Scavenge
老年代收集器:
Serial Old
CMS
Parallel Old
堆内存垃圾收集器:G1
每种垃圾收集器之间有连线,表示他们可以搭配使用。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/6312de3f15094625399f3ed54f1f0187.png)
二、新生代垃圾收集器
(1)Serial 收集器
Serial 是一款用于新生代的单线程收集器,采用复制算法进行垃圾收集。Serial 进行垃圾收集时,不仅只用一条线程执行垃圾收集工作,它在收集的同时,所有的用户线程必须暂停(Stop The World)。
就比如妈妈在家打扫卫生的时候,肯定不会边打扫边让儿子往地上乱扔纸屑,否则一边制造垃圾,一遍清理垃圾,这活啥时候也干不完。
如下是 Serial 收集器和 Serial Old 收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,Serial 收集器以单线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行。
适用场景:Client 模式(桌面应用);单核服务器。
可以用 -XX:+UserSerialGC 来选择 Serial 作为新生代收集器。
(2)ParNew 收集器
ParNew 就是一个 Serial 的多线程版本,其它与Serial并无区别。ParNew 在单核 CPU 环境并不会比 Serial 收集器达到更好的效果,它默认开启的收集线程数和 CPU 数量一致,可以通过 -XX:ParallelGCThreads 来设置垃圾收集的线程数。
如下是 ParNew 收集器和 Serial Old 收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,ParNew 收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行。
适用场景:多核服务器;与 CMS 收集器搭配使用。当使用 -XX:+UserConcMarkSweepGC 来选择 CMS 作为老年代收集器时,新生代收集器默认就是 ParNew,也可以用 -XX:+UseParNewGC 来指定使用 ParNew 作为新生代收集器。
(3)Parallel Scavenge 收集器
Parallel Scavenge 也是一款用于新生代的多线程收集器,与 ParNew 的不同之处是ParNew 的目标是尽可能缩短垃圾收集时用户线程的停顿时间,Parallel Scavenge 的目标是达到一个可控制的吞吐量。
吞吐量就是 CPU 执行用户线程的的时间与 CPU 执行总时间的比值【吞吐量 = 运行用户代代码时间/(运行用户代码时间+垃圾收集时间)】,比如虚拟机一共运行了 100 分钟,其中垃圾收集花费了 1 分钟,那吞吐量就是 99% 。比如下面两个场景,垃圾收集器每 100 秒收集一次,每次停顿 10 秒,和垃圾收集器每 50 秒收集一次,每次停顿时间 7 秒,虽然后者每次停顿时间变短了,但是总体吞吐量变低了,CPU 总体利用率变低了。
可以通过 -XX:MaxGCPauseMillis 来设置收集器尽可能在多长时间内完成内存回收,可以通过 -XX:GCTimeRatio 来精确控制吞吐量。
如下是 Parallel 收集器和 Parallel Old 收集器结合进行垃圾收集的示意图,在新生代,当用户线程都执行到安全点时,所有线程暂停执行,ParNew 收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行;在老年代,当用户线程都执行到安全点时,所有线程暂停执行,Parallel Old 收集器以多线程,采用标记整理算法进行垃圾收集工作。
适用场景:注重吞吐量,高效利用 CPU,需要高效运算且不需要太多交互。
可以使用 -XX:+UseParallelGC 来选择 Parallel Scavenge 作为新生代收集器,jdk7、jdk8 默认使用 Parallel Scavenge 作为新生代收集器。
三、老年代垃圾收集器
(1)Serial Old 收集器
Serial Old 收集器是 Serial 的老年代版本,同样是一个单线程收集器,采用标记-整理算法。
如下图是 Serial 收集器和 Serial Old 收集器结合进行垃圾收集的示意图:
适用场景:Client 模式(桌面应用);单核服务器;与 Parallel Scavenge 收集器搭配;作为 CMS 收集器的后备预案。
(2)CMS(Concurrent Mark Sweep) 收集器
CMS 收集器是一种以最短回收停顿时间为目标的收集器,以 “ 最短用户线程停顿时间 ” 著称。整个垃圾收集过程分为 4 个步骤:
① 初始标记:标记一下 GC Roots 能直接关联到的对象,速度较快。
② 并发标记:进行 GC Roots Tracing,标记出全部的垃圾对象,耗时较长。
③ 重新标记:修正并发标记阶段引用户程序继续运行而导致变化的对象的标记记录,耗时较短。
④ 并发清除:用标记-清除算法清除垃圾对象,耗时较长。
整个过程耗时最长的并发标记和并发清除都是和用户线程一起工作,所以从总体上来说,CMS 收集器垃圾收集可以看做是和用户线程并发执行的。
CMS 收集器也存在一些缺点:
对 CPU 资源敏感:默认分配的垃圾收集线程数为(CPU 数+3)/4,随着 CPU 数量下降,占用 CPU 资源越多,吞吐量越小
无法处理浮动垃圾:在并发清理阶段,由于用户线程还在运行,还会不断产生新的垃圾,CMS 收集器无法在当次收集中清除这部分垃圾。同时由于在垃圾收集阶段用户线程也在并发执行,CMS 收集器不能像其他收集器那样等老年代被填满时再进行收集,需要预留一部分空间提供用户线程运行使用。当 CMS 运行时,预留的内存空间无法满足用户线程的需要,就会出现 “ Concurrent Mode Failure ”的错误,这时将会启动后备预案,临时用 Serial Old 来重新进行老年代的垃圾收集。
因为 CMS 是基于标记-清除算法,所以垃圾回收后会产生空间碎片,可以通过 -XX:UserCMSCompactAtFullCollection 开启碎片整理(默认开启),在 CMS 进行 Full GC 之前,会进行内存碎片的整理。还可以用 -XX:CMSFullGCsBeforeCompaction 设置执行多少次不压缩(不进行碎片整理)的 Full GC 之后,跟着来一次带压缩(碎片整理)的 Full GC。
适用场景:重视服务器响应速度,要求系统停顿时间最短。可以使用 -XX:+UserConMarkSweepGC 来选择 CMS 作为老年代收集器。
(3)Parallel Old 收集器
Parallel Old 收集器是 Parallel Scavenge 的老年代版本,是一个多线程收集器,采用标记-整理算法。可以与 Parallel Scavenge 收集器搭配,可以充分利用多核 CPU 的计算能力。
适用场景:与Parallel Scavenge 收集器搭配使用;注重吞吐量。jdk7、jdk8 默认使用该收集器作为老年代收集器,使用 -XX:+UseParallelOldGC 来指定使用 Paralle Old 收集器。
四、新生代和老年代垃圾收集器
G1 收集器
G1 收集器是 jdk1.7 才正式引用的商用收集器,现在已经成为 jdk9 默认的收集器。前面几款收集器收集的范围都是新生代或者老年代,G1 进行垃圾收集的范围是整个堆内存,它采用 “ 化整为零 ” 的思路,把整个堆内存划分为多个大小相等的独立区域(Region),在 G1 收集器中还保留着新生代和老年代的概念,它们分别都是一部分 Region,如下图:
每一个方块就是一个区域,每个区域可能是 Eden、Survivor、老年代,每种区域的数量也不一定。JVM 启动时会自动设置每个区域的大小(1M ~ 32M,必须是 2 的次幂),最多可以设置 2048 个区域(即支持的最大堆内存为 32M*2048 = 64G),假如设置 -Xmx8g -Xms8g,则每个区域大小为 8g/2048=4M。
为了在 GC Roots Tracing 的时候避免扫描全堆,在每个 Region 中,都有一个 Remembered Set 来实时记录该区域内的引用类型数据与其他区域数据的引用关系(在前面的几款分代收集中,新生代、老年代中也有一个 Remembered Set 来实时记录与其他区域的引用关系),在标记时直接参考这些引用关系就可以知道这些对象是否应该被清除,而不用扫描全堆的数据。
G1 收集器可以 “ 建立可预测的停顿时间模型 ”,它维护了一个列表用于记录每个 Region 回收的价值大小(回收后获得的空间大小以及回收所需时间的经验值),这样可以保证 G1 收集器在有限的时间内可以获得最大的回收效率。
如下图所示,G1 收集器收集器收集过程有初始标记、并发标记、最终标记、筛选回收,和 CMS 收集器前几步的收集过程很相似:
① 初始标记:标记出 GC Roots 直接关联的对象,这个阶段速度较快,需要停止用户线程,单线程执行。
② 并发标记:从 GC Root 开始对堆中的对象进行可达新分析,找出存活对象,这个阶段耗时较长,但可以和用户线程并发执行。
③ 最终标记:修正在并发标记阶段引用户程序执行而产生变动的标记记录。
④ 筛选回收:筛选回收阶段会对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来指定回收计划(用最少的时间来回收包含垃圾最多的区域,这就是 Garbage First 的由来——第一时间清理垃圾最多的区块),这里为了提高回收效率,并没有采用和用户线程并发执行的方式,而是停顿用户线程。
适用场景:要求尽可能可控 GC 停顿时间;内存占用较大的应用。可以用 -XX:+UseG1GC 使用 G1 收集器,jdk9 默认使用 G1 收集器。
五、JVM垃圾收集器总结
CMS(标记清除--初始标记(停顿)、并发标记、重新标记(停顿)、并发清除)
优点:最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作
缺点:因为并发阶段多线程占据 CPU 资源,如果 CPU 资源不足,效率会明显降低、清理阶段用户线程还在运行,产生浮动垃圾、产生空间碎片
G1(标记整理--初始标记(停顿)、并发标记、最终标记(停顿)、筛选回收)
优点:不会产生内存碎片
本文主要介绍了JVM中的垃圾回收器,主要包括串行回收器、并行回收器以及CMS回收器、G1回收器。他们各自都有优缺点,通常来说你需要根据你的业务,进行基于垃圾回收器的性能测试,然后再做选择。下面给出配置回收器时,经常使用的参数:
-XX:+UseSerialGC:在新生代和老年代使用串行收集器
-XX:+UseParNewGC:在新生代使用并行收集器
-XX:+UseParallelGC :新生代使用并行回收收集器,更加关注吞吐量
-XX:+UseParallelOldGC:老年代使用并行回收收集器
-XX:ParallelGCThreads:设置用于垃圾回收的线程数
-XX:+UseConcMarkSweepGC:新生代使用并行收集器,老年代使用CMS+串行收集器
-XX:ParallelCMSThreads:设定CMS的线程数量
-XX:+UseG1GC:启用G1垃圾回收器
六、常见问题
- 内存泄漏(申请内存后,无法释放已申请的内存空间)长生命周期对短生命周期的引用(static list)、数据库,网络连接关闭才能回收、内部类的生命周期长于外部类,内部类继承软引用或弱引用、hash值改变(代码问题)
- 栈溢出(方法死循环递归调用、不断创建线程)
- 堆溢出(不断创建对象、分配对象大于堆大小)
- 元空间溢出(内存空间不足,很多情况内存溢出往往是内存泄漏造成的)
七、调优
1、监控工具
jps命令
作用:查看所有的java进程
用法: jps
jstat命令
程序中有大对象,GC没办法回收,所以E和O特别高,会影响到代码的其他接口
作用:可查看堆的使用情况及垃圾回收情况
用法: jstat –gcutil pid
jstat -gcutil 397116--总结垃圾回收统计
S0:幸存1区当前使用比例
S1:幸存2区当前使用比例
E:伊甸园区使用比例
O:老年代使用比例
M:元数据区使用比例
CCS:压缩使用比例
YGC:年轻代垃圾回收次数
FGC:老年代垃圾回收次数
FGCT:老年代垃圾回收消耗时间
GCT:垃圾回收消耗总时间
jstat -gc 397116
S0C:第一个幸存区的大小
S1C:第二个幸存区的大小
S0U:第一个幸存区的使用大小
S1U:第二个幸存区的使用大小
EC:伊甸园区的大小
EU:伊甸园区的使用大小
OC:老年代大小
OU:老年代使用大小
MC:方法区大小
MU:方法区使用大小
CCSC:压缩类空间大小
CCSU:压缩类空间使用大小
YGC:年轻代垃圾回收次数
YGCT:年轻代垃圾回收消耗时间
FGC:老年代垃圾回收次数
FGCT:老年代垃圾回收消耗时间
GCT:垃圾回收消耗总时间
jmap命令
作用:可打印当前内存存储快照
用法: jmap -dump:format=b,file=#输出dump地址 pid
jstack命令
作用:栈信息查看和输出
用法: jstack pid >#输出文件名称
jconsole工具
JDK自带工具,可查看本地及远程的所有jvm信息,含类、各种内存、线程等信息
jvisualvm工具
JDK自带工具,查看本地及远程的所有jvm信息,还能导入分析堆栈日志信息,比jconsole更加直观
2、调优案例
1、JVM常见死锁问题产生原因和多种诊断方式
产生原因:互相等待对方的锁释
问题定位
方法一、导出栈信息后定位
a、使用jps查看所有的java pid
b、jstack pid >aaa.log--------------将pid的栈信息输出到当前目录的aaa.log
搜索deadlock,找到对应锁信息,以及等待的锁信息
方法二、使用JDK自带的jvisualvm工具直接检测
方法三、使用JDK自带的jconsole工具直接检测
2、服务器CPU飙升为100%问题排查
1.定位哪个程序占用的CPU较高
linux命令:top
windows:任务管理器
2.栈信息输出
命令格式:jstack pid > 文件信息
eg:jstack 5115 > a.txt
3.定位哪一个线程占用率高
常用命令:按shift+p可排序
ps -mp pid -o THREAD,tid,time
ps -Lfp pid
top -H ----直接查看高CPU的线程
4.线程ID转为16进制,访问转换地址
将子线程TID的5158做进制转换为16进制:1426
5.通过16进制的线程ID在栈信息中查找定位代码行
public String loop() {
boolean b = true;
while (b) {
}
return "123";
}
CPU飙升问题产生的背景
1、代码中存在死循环
2、定时任务跑批量
3、tomcat高并发项目的时候,所有线程都处在运行状态,消耗CPU资源
4、Redis的端口6379被注入挖矿程序
5、分布式锁的重试机制
a、乐观锁:能够保证用户线程一直在用户态,缺点是消耗CPU的资源
b、CAS自旋锁
如何避免CPU飙升问题
1、检查代码的死循环情况
2、定时任务项目要喝业务逻辑项目分开部署
a、降低业务逻辑项目CPU资源消耗
b、更好实现定时分片执行
3、接口比较耗时的代码不要写成同步,改为使用mq
4、对服务器接口实现限流、熔断和降级
5、端口号不要随意放开,要结合nginx、LVS等
6、写自旋锁一定要控制死循环次数
tips:预警系统很重要
3、调优指标
1、整体评估指标说明
1.吞吐量: 运行用户代码占总时间的比例
总运行时间:用户线程程序的运行时间(100s)+GC内存回收的时间 (1s)
比如程序运行时间100s/内存回收时间 垃圾回收1s 则吞吐量为100/101=99%
2.GC负荷:与吞吐量相反,指应用花在GC上的时间百分比
上例GC负荷为:1/101=1%
3.暂停时间:应用线程花在GC stop-the-world 的时间
暂时时间越小越好
4.GC频率:次数/GC频率越多,stw暂停时间越短;GC回收频率次数越少、stw暂停时间越长
5.反应速度:从一个对象变成垃圾道这个对象被回收的时间
吞吐量优先的收集器:Parallel并行收集器【Jdk8默认收集器】
响应时间优先的收集器:CMS(老年代)/ParNew(新生代)-注重stw时间越少
G1/ZGC同时注重吞吐量和响应时间优先
2、JVM调优方案
优化核心思路:
1、通过堆内存设置减少老年代垃圾回收的次数
2、配置垃圾回收器,减少STW的时间
1.避免用户线程暂停时间STW比较短
a.堆内存空间一定要充足,垃圾回收和最大堆内存无关,只和初始内存有关。
b.项目启动堆内存初始值与最大值一定保持一致,可减少垃圾回收的次数,提高吞吐量;
c.不建议调用System.gc(),容易造成STW;
d.不要在堆内存中存放大对象和全局变量,容易触发fullgc
e.合理根据项目堆内存情况,选择收集器
2.合理设定堆的初始大小和选择合理的垃圾收集器
a.起步阶段的个人网站,建议堆内存1GB 可以串行SerialGC,建议使用并行Parallel GC
b.有一定访问量的网站或APP,建议堆内存2g 建议使用Parallel GC
c.并发适中的APP或普通数据处理,建议堆内存4g 老年代CMS/新生代parnew
d.适用于并发要求较高的APP,建议堆内存8G(要16G的可以集群)建议G1收集器 注重低延迟和吞吐量
必填参数
-Xmx 堆最大可用值 测试结果:默认4G----物理内存的1/4
-Xms 堆初始值 测试结果:最大内存的1/16-----物理内存的1/64
-XX:+HeapDumpOnOutOfMemoryError 内存溢出的时候打印内存快照
-XX:HeapDumpPath=hdpserver_oom.hprof 内存溢出的时候内存快照保存路径
tips:
a、-Xmx和-Xms在内存不大的时候建议相同,内存很大的时候,建议配成2:1
b、内存溢出的快照配置很可能几个月不出现,但建议配置上,方便问题出现时的排查
c、垃圾回收器在JDK8之前建议用CMS,JDK8及之后建议用G1。
优化参数
-XX:+PrintGC 每次触发GC的时候打印相关日志
-XX:+PrintGCDetails 更详细的GC日志
堆设置
-Xmn 新生代堆最大可用值 默认是堆的1/3,官方推荐配置为整个堆的3/8
-XX:NewRatio 配置新生代与老年代占比 默认1:2 建议值:1:2或1:3 -XX:NewRatio=3
-XX:SurvivorRatio 用来设置新生代中eden空间和from/to空间的比例=eden/from=den/to 默认8:1
-XX:PermSize 初始化永久内存区域大小,默认4M,超出的话可能出现PermGen space错误。
-XX:MaxPermSize 设置永久内存区域最大大小
-XX:NewSize 作用跟-XX:NewRatio相似,不同的是精确的数值
-XX:MaxNewSize 设置最大Java新对象生产堆内存,
NewSize和MaxNewSize最好设成一致,数值都是1024的整数倍并且大于1MB。
-XX:MaxTenuringThreshold 设置垃圾最大年龄
-XX:GCTimeRatio 设置垃圾回收时间占程序运行时间的百分比,垃圾回收时间占程序运行时间百分比的公式为1/(1+n) ,如果n=19表示java可以用5%的时间来做垃圾回收,1/(1+19)=1/20=5%。
-Xss 我们线程栈空间大小 –Xss1m,jdk5.0前是256k---配置不适合太高
1.8元空间设置大小
-XX:MetaspaceSize 初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
-XX:MaxMetaspaceSize 最大空间,默认是没有限制的。