1.对象创建流程是怎样的?有哪些步骤,分别有什么作用?
jvm创建对象主要经过类加载检查、分配内存、初始化、设置对象头、执行初始化方法这几个阶段,下面将逐步解析每一步的含义。
类加载检查
首先第一步是类加载检查,当虚拟机遇到new指令时,首先检查这个指令的参数能否在常量池中定位一个类的符号引用,并检查这个类是否已经加载、解析、初始化过,如果没有,就执行类加载流程。
分配内存
实际上为对象分配内存就是从堆中分割一块空间给当前对象。
在第二步分配内存时,我们要关心两个问题:
1.内存分配的方式是怎样的?
2.并发场景下,jvm怎么保证内存分配的原子性?
jvm内存分配方式有如下两种:
1.指针碰撞(Bump the Pointer):假如java堆中的内存都是规整的,已使用的内存在一边,未使用的内存在另一边,中间用指针进行分割,当要给对象分配内存时,只需要将指针按对象大小移动到未使用内存那边,那么内存就分配完毕。
2.空闲列表(Free List):假如java堆中的内存,已使用的和未使用的内存交织在一起,那么虚拟机就要维护一个可用内存列表,当要分配内存时,就查询这个列表上的可用内存进行分配。
解决并发分配内存:
1.虚拟机使用了CAS(compare and swap)配上失败重试的方式来保证更新操作的原子性
2.本地线程分配缓冲(Thread Local Allocation Buffer,TLAB):将内存分配的操作按线程划分在不同的空间中进行,即每个线程都预先从堆中分配到一块独有的内存空间,当本地线程的缓冲空间使用完,才会使用CAS去Eden区竞争内存。虚拟机是否使用TLAB,可以通过-XX:+UseTLAB这个参数来配置。
初始化
这里就是给对象的字段进行初始化零值,如果使用了TLAB,也可以提前到TLAB中进行。
设置对象头
对象在jvm中是有三部分组成,分别是对象头(Object Header)、实例数据(Instance Data)、对齐填充(Padding)。
在这一步主要是设置对象运行时的数据,比如哈希码、GC分代年龄、锁状态标志位、线程持有的锁、偏向线程ID、偏向时间戳等。对象头还有一部分是类型指针(klass point),指向的是类的元数据信息,也就是当前对象是哪个类的实例,如果是数组还需要设置数组的长度。
执行<init>()方法
在这一步是按照我们开发人员指定的值给属性进行初始化,执行完这个方法后,对象才是真正的创建完成。
2.类加载流程是怎样的?
我们编写好的代码,通常需要编译成class文件,然后加载到内存中才能被虚拟机识别和使用。将一个class文件加载到内存中,可以从两方面进行了解,首先就是类加载的过程,其次就是jvm的类加载器,那么下面将从这两方面进行详细的介绍。
2.1 类加载的流程
类加载主要有加载、验证、准备、解析、初始化、使用、卸载这七个步骤。
加载
在类加载阶段会完成三件事:
1.通过类的全限定名,来获取这个类的二进制字节流
2.将读取到的字节流存放到运行时数据区的方法区
3.在堆中生成一个对应这个类的对象,用于访问方法区这个类的入口通俗点说,就是将class文件存放到方法区中,然后在堆里面生成一个该类的java.lang.Class对象,用来访问类的信息
验证
这个阶段主要验证字节码的格式是否符合规范
准备
这个阶段主要是给静态变量分配内存、设置初始零值(int类型赋值0)。但是这里有个特殊的地方,就是在给final修饰的字段进行赋值时,是赋予指定的值。
基本类型的默认零值:
// final修饰的静态字段在准备阶段会赋指定的值
public static final int value = 111
解析
虚拟机将常量池中的符号引用替换为直接引用。
符号引用:用一组符号来描述所引用的对象,通过符号可以定位指定的数据。
直接引用:可以是直接指向对象的指针、内存相对偏移量。
静态链接:在类加载过程将符号引用替换为直接引用,就叫静态链接。
动态链接:在程序运行时,将符号引用天魂成直接引用,就叫动态链接。
初始化
在当前阶段,则会根据开发人员指定的值去给字段进行赋值,然后执行静态代码块。
2.2 类加载器
类加载器的主要作用:通过一个类的全限定名来获取该类的二进制字节流,实现这个过程的就是类加载器(Class Loader)。
Jvm中有三种类加载,分别是启动类加载器(Bootstrap Class Loader)、扩展类加载器(Extension Class Loader)、应用程序类加载器(Application Class Loader),其中启动类加载器是C++实现,因此启动类加载器不能在java中获取。
启动类加载器(Bootstrap Class Loader):负责加载\lib目录下的核心类库。
扩展类加载器(Extension Class Loader):负责加载\lib\ext目录下的扩展类库。
应用程序类加载器(Application Class Loader):负责加载用户类路径(classpath)下的所有类库。
java应用程序都是由这三种类加载器相互配合来实现类的加载,它们直接的关系如下图所示:
这种工作模式被称为类加载器双亲委派机制,它们的执行流程是:当某一个类要被加载时,当前类的加载器会先检查自己是否已经加载过这个类,如果没有加载过,就会委派给父类加载器(直到最顶级的类加载器),如果父类加载器加载失败,那么子加载器才会去加载该类。
双亲委派的好处:
1.沙箱安全机制,避免核心的类库被篡改,假如我们自己也定义一个String类,那么类加载器是不会加载的。
2.避免重复加载相同的类。
类和类加载器的关系:在java中,同一个类被不同的类加载器加载,那么这两个类也不相同。
3.对象内存分配
对象内存分配主要流程如下图所示:
对象栈上分配:
大多数对象都是分配在堆上,但是如果某些对象经过“逃逸分析”,发现不会发生逃逸现象,该对象则会在栈上进行分配。如果栈上的连续空间不足以放下当前对象,那么jvm会使用“标量替换”,将对象进行拆分后存放栈内。对象分配在栈上,可以随着栈帧的出栈而销毁,减轻垃圾回收的压力。
逃逸分析:该对象只作用在本方法内,无法被外部访问到,就代表没有逃逸,相反,如果可以被外部访问,则是逃逸。使用XX:+DoEscapeAnalysis(开启逃逸分析)-XX:- DoEscapeAnalysis(关闭逃逸分析)。
标量替换:将一个聚合量(java中的对象)拆分成若干份,根据程序访问,将用到的成员变量恢复为原始类型,这就是标量替换。使用-XX:+EliminateAllocations(开启标量替换)-XX:-EliminateAllocations(关闭标量替换)。
// test1中user逃逸出test1方法
public User test1() {
User user = new User();
user.setId(1);
user.setName("test");
return user;
}
// test2中user没有逃逸出去
public void test2() {
User user = new User();
user.setId(1);
user.setName("test");
}
对象Eden区分配:
大多数情况下,对象都是在Eden区进行分配,如果Eden区内存不足,虚拟机会执行MinorGc。如上图所示,年轻代分为Eden区、Survivor0、Survivor1区,它们之间占比为8:1:1。
大对象直接进入老年代:
大对象指的是连续占用大量内存空间的java对象,比如数组。虚拟机提供了-XX:PretenureSizeThreshold参数,可以设置对象达到某个阈值后,直接进入老年代中,这样做的好处是避免了大对象在年轻代中来回的复制。这-XX:PretenureSizeThreshold参数只在Serial和ParNew两款年轻代收集器下有效。
长期存活的对象进入老年代:
虚拟机给每个对象的头部都设置了一个Gc分代年龄计数器,每经历过一次Gc,计数器就会+1,默认是达到15次后,就会晋升到老年代中。可以使用-XX:MaxTenuringThreshold=15参数来进行设置。
对象动态年龄判断:
一批对象的总大小大于当前Survivor区的50%,那么大于等于这批对象年龄的对象,直接进入老年代中。例如当前Survivor区有一批对象,年龄1+2+3+n大于当前Survivor区的50%,此时就会将大于等于n的对象,直接转移到老年代中。对象动态年龄判断一般在minor Gc执行后触发。
空间分配担保:
在执行minor Gc前,虚拟机会先检查老年代剩余可用空间是否大于新生代所有对象的大小,如果大于就执行minor Gc;否则,虚拟机将检查是否配置了担保参数“-XX:HandlePromotionFailure”,如果有担保参数,就会判断老年代剩余可用空间是否大于历次晋升到老年代对象的平均大小,如果不符合就执行full Gc,符合就执行minor Gc。