深入理解对象
我们知道,Java是一门面向对象设计的语言,面向对象的程序设计语言中有类和对象的概念。类就是具备某些共同特征的实体的集合,它是一种抽象的数据类型,它是对所具有相同特征实体的抽象。在面向对象的程序设计语言中,类是对一类“事物”的属性与行为的抽象。
而对象是类的具体的个体。比如,小王是Person类的一个对象。Person可能存在无数个对象(就好像地球上存在数十亿人一样)。而一个对象的创建,包括两个过程:初始化和实例化。
虚拟机中对象的创建过程
下面是对象创建过程的一个图示:
我们通常使用new关键字去创建一个对象,JVM首先会去检查相关类型是否已经加载并初始化,如果没有,JVM就会调用类加载器完成类的初始化。接下来,就会为对象分配内存空间。
我们知道Java堆是被所有线程共享的一块内存区域,主要用于存放对象实例,为对象分配内存就是把一块大小确定的内存从堆内存中划分出来,而这段内存又必须是连续的。分配的方式通常有指针碰撞和空闲列表两种实现。
- 指针碰撞法
假设Java堆中内存时完整的,已分配的内存和空闲内存分别在不同的一侧,通过一个指针作为分界点,需要分配内存时,仅仅需要把指针往空闲的一端移动与对象大小相等的距离。使用的GC收集器:Serial、ParNew,适用堆内存规整(即没有内存碎片)的情况下。这类垃圾收集器带有压缩整理功能。
- 空闲列表法
事实上,Java堆的内存并不是完整的,已分配的内存和空闲内存相互交错,JVM通过维护一个列表,记录可用的内存块信息,当分配操作发生时,从列表中找到一个足够大的内存块分配给对象实例,并更新列表上的记录。使用的GC收集器:CMS,适用堆内存不规整的情况下。而这也就是现代Java虚拟机的垃圾回收机制:标机-清除法。
我们都知道JVM是多线程的,假设线程1正在给A对象分配内存,指针还没有来的及修改,同时线程2在为B对象分配内存,仍引用这之前的指针指向等,这时候就会带来并发安全问题。为了解决并发安全问题,JVM采用了CAS加失败重试的机制以及本地线程分配缓冲的机制。前者属于乐观锁的一种,而CAS操作是一个原子操作,线程会先尝试去分配内存,更新的时候进行比较,如果内存块与期待值相同,则提交修改,如果不同,则自旋,重新移动指针。
而本地线程分配缓冲的机制的话,则是预先给线程分配一块空间TLAB(Thread Local Allocation Buffer),后面分配空间先在TLAB上分配,TLAB不够了再从堆上分配。
其实TLAB只是让每个线程在堆上有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已,。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB,而在老TLAB里的对象还留在原地什么都不用管。
当我们为一个对象分配好内存空间之后,还需要进行内存空间的初始化为零值,这一操作保证了对象的实例字段在java代码中,不赋初始值就可以直接使用。例如一个Integer对象,就初始化为0,一个Boolean对象初始化为false。
接下来,就会对内存空间进行设置,将其与对象的实例关联起来,就是在对象头中记录相关的信息。到这里对象的初始化,就完成了。
对象的内存布局
在一个对象里面,包括对象头、实例数据和对齐填充三个部分。在Hotspot里面,对象必须是8字节的整数倍大小,假如对象头加实例数据加起来为30字节的话,则会填充2字节的填充数据,以达到规整的目的。
在对象头里面,包含了多个数据,一是存储自身对象的运行时数据,包括哈希码、GC分代年龄、锁状态表示、线程持有的帧、偏向线程ID,偏向时间戳等。二是类型指针,确定对象是来自哪个实例。三是假如对象是数组对象,还有会一部分数据用来记录数组长度的数据。
对象的访问定位
我们在new出一个对象的时候,其实是使用一个引用去指向一个对象的实例。那么引用是怎么访问定位到对象上呢。
使用句柄
在这里,句柄被定义为了存放到对象实例数据的指针和到对象类型数据的指针的个体,而Java堆又划分了一块句柄池用于存放句柄。而Java栈帧中的局部变量表中存放了对象的reference(引用),而这个引用则指向了句柄池中的句柄,并进而通过指针去定位到对象的实例数据。
直接指针
而使用直接指针的话,引用直接指向了Java堆中的对象实例数据,相比句柄的使用无需二次映射,更加高效了。
对象的存活以及各种引用
我们知道,Java堆是用于分配对象实例的,当堆空间已经满的情况下,JVM就会进行垃圾回收。那么我们就必须区分,哪些对象需要回收,也就是哪些对象是死的,哪些对象是活的。在虚拟机规范里面,有那么几种方法:
引用计数算法(Reference Counting):
给对象添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器值减1;任何时刻计数器为0的对象,就会被认为是垃圾。使用引用计数器,具有较高的效率,但是它有一个缺陷,那就是很难解决对象之间的循环引用问题,例如objA.instance = objB及objB.instance = objA,如果除此之外这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知GC收集器回收它们。
public class IsAlive {
public Object instance =null;
//占据内存,便于判断分析GC
private byte[] bigSize = new byte[10*1024*1024];
public static void main(String[] args) {
IsAlive objectA = new IsAlive();
IsAlive objectB = new IsAlive();
//相互引用
objectA.instance = objectB;
objectB.instance = objectA;
//切断可达
objectA =null;
objectB =null;
//强制垃圾回收
System.gc();
}
}
可达性分析算法(Reachability Analysis):
通过一系列的称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(即从GC Roots到这个对象不可达)时,则证明此对象是不可用的。
在Java语言中,可作为GC Roots的对象包括以下几种:
(1) 虚拟机栈中引用的对象;
(2) 方法区中类静态属性引用的对象;
(3) 方法区中常量引用的对象;
(4) 本地方法栈中JNI(即一般说的Native方法)引用的对象;
但是如果有引用链,就一定不会被回收吗?还要看对象之间的引用关系。JVM里面有四种引用关系。
- 强引用
- 软引用 SoftReference
- 弱引用 WeakReference
- 虚引用 PhantomReference
强引用通常就是用等号来引用,GC不会回收被强引用的对象。而软引用的话,当快要发生内存溢出的话,就会被GC回收。如果是弱引用的话,当发生GC的时候,就会被回收掉。虚引用被定义出来之后则随时都可能会被GC回收。
测试软引用
public static void main(String[] args) {
User u = new User(1,"King"); //new是强引用
SoftReference<User> userSoft = new SoftReference<User>(u);//软引用
u = null;//干掉强引用,确保这个实例只有userSoft的软引用
System.out.println(userSoft.get()); //看一下这个对象是否还在
System.gc();//进行一次GC垃圾回收 千万不要写在业务代码中。
System.out.println("After gc");
System.out.println(userSoft.get());
//往堆中填充数据,导致OOM
List<byte[]> list = new LinkedList<>();
try {
for(int i=0;i<100;i++) {
//System.out.println("*************"+userSoft.get());
list.add(new byte[1024*1024*1]); //1M的对象
}
} catch (Throwable e) {
//抛出了OOM异常时打印软引用对象
System.out.println("Exception*************"+userSoft.get());
}
}
测试弱引用
public static void main(String[] args) {
User u = new User(1,"King");
WeakReference<User> userWeak = new WeakReference<User>(u);
u = null;//干掉强引用,确保这个实例只有userWeak的弱引用
System.out.println(userWeak.get());
System.gc();//进行一次GC垃圾回收,千万不要写在业务代码中。
System.out.println("After gc");
System.out.println(userWeak.get());
}
对象的分配策略
对象的分配原则
JVM在创建对象的过程中,分配存储空间的时候通常需要遵循以下原则:
- 对象优先在Eden中分配
- 空间分配担保
- 大对象直接进入老年代
- 长期存活的对象进入老年代
- 动态对象年龄判断
分配策略优化
同时,分配的时候也有如下的优化技术:
- 栈中优化对象:逃逸分析
- 堆中的优化技术:本地线程分配缓冲(TLAB)
当JVM遇到一条new指令的时候,首先判断是否在栈上分配,这时候就会使用到逃逸分析技术。在JIT的过程中,如果发现一个对象在方法中被定义后,作用域仅仅限于方法内,那么就称为没有发生方法逃逸,这时候,对象的分配就可以在栈上分配,当方法体执行结束了,栈上的空间也就跟着释放了,这样就可以提高JVM的效率,减少了GC的次数。
通过-XX:±DoEscapeAnalysis
: 表示开启或关闭逃逸分析
接下来,如果不满足逃逸分析,就判断是否在TLAB上分配,如果不满足,再进一步判断是否是大对象,如果不是的话,就会在Eden中分配。所以说,无论是TLAB,还是小对象,都满足对象优先在Eden上分配的原则。如果是大对象的话,则会在Tenured中分配。
垃圾回收算法
Oracle公司曾经进行过概率统计,在新生代中约98%的对象会在创建出来之后的第一次GC就会被回收掉,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。
我们知道Eden上只存放新生对象,而堆上又频繁着在发生GC回收,如果某个对象在GC中没有被回收掉,那个在对象的对象头的年龄上就会+1,然后从Eden移动到Survivor中。当再次发生回收时,将Survivor中还存活着的对象一次性的复制到另外一块Survivor,最后清理刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。这种发生在新生代上的GC回收算法被称为复制回收算法。
Eden | From | To | Tenured |
---|---|---|---|
8 | 1 | 1 | 20 |
而在对象头上,64位的JVM中age的比特位是只有4位的,因此能记录的最大年龄也就是1111(15次)。达到这个次数之后,对象就会进入老年代。
当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
空间分配担保,这是在老年代中的垃圾回收算法。正常的流程来讲,对象的跨代移动都是从Eden到From到To,直到GC年龄达到阈值后才会进入Tenured。
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,
- 如果大于,则此次Minor GC是安全的
- 如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。
如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。
上面提到了Minor GC依然会有风险,是因为新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间。
取平均值仍然是一种概率性的事件,如果某次Minor GC后存活对象陡增,远高于平均值的话,必然导致担保失败,如果出现了分配担保失败,就只能在失败后重新发起一次Full GC。虽然存在发生这种情况的概率,但大部分时候都是能够成功分配担保的,这样就避免了过于频繁执行Full GC。