赞
踩
Java是面向对象的语言,所谓“万事万物皆对象”就是Java是基于对象来设计程序的,没有对象程序就无法运行(8大基本类型除外),那么对象是如何创建的?在内存中又是怎么分配的呢?
在Java中我们有几种方式可以创建一个新的对象呢?总共有以下几种方式:
为了便于说明和理解,下文仅针对new出来的对象进行讨论。
Java中对象的创建过程就包含上图中的5个步骤,首先需要验证待创建对象的类是否已经被JVM加载,如果没有则会先进行类的加载,如果已经加载则会在堆中(不完全是堆,后文会讲到)分配内存;分配完内存后则是对对象的成员变量设置初始值(0或null),这样对象在堆中就创建好了。但是,这个对象是属于哪个类的还不知道,因为类信息存在于方法区,所以还需要设置对象的头部(当然头部中也不仅仅只有类型指针信息,稍后也会详细讲到),这样堆中才创建好了一个完整的对象,但是这个对象的成员变量还都是初始值,所以最后会调用init方法按照我们自己的意愿初始化对象,一个真正的对象就创建好了。
对象的整个创建过程是非常简单的,但是其中还有很多细节,比如对象会在哪里创建?分配内存有哪些方式?怎么保证线程安全?对象头中有哪些信息?下面一一讲解。
基本上所有的对象都是在堆中,但并非绝对,在JDK1.6版本引入了逃逸分析技术。逃逸分析就是指针对对象的作用域进行判定,当一个对象在方法中被定义后,如果被其它方法或其它线程访问到,就称为方法逃逸或线程逃逸。
该技术针对未逃逸的对象做了一个优化:栈上分配(除此之外还有同步消除、标量替换,这里暂时不讲)。这个优化是指当一个对象能被确定不会在该方法之外被引用,那么就可以直接在虚拟机栈中创建该对象,那么这个对象就可以随着线程的消亡而销毁,不再需要垃圾回收器进行回收。这个优化带来的收益是明显的,因为有相当一部分对象都只会在该方法内部被引用。逃逸分析默认是开启的,可以通过-XX:-DoEscapeAnalysis参数关闭。下面看一个实例:
- public class EscapeAnalysisTest {
-
- public static void main(String[] args) throws Exception {
-
- long start = System.currentTimeMillis();
-
- for (int i = 0; i < 50000000; i++) {//5000万次---5000万个对象
-
- allocate();
-
- }
-
- System.out.println((System.currentTimeMillis() - start) + " ms");
-
- Thread.sleep(600000);
-
- }
-
-
-
- static void allocate() {//逃逸分析(不会逃逸出方法)
-
- //这个myObject引用没有出去,也没有其他方法使用
-
- MyObject myObject = new MyObject(2020, 2020.6);
-
- }
-
-
-
- static class MyObject {
-
- int a;
-
- double b;
-
-
-
- MyObject(int a, double b) {
-
- this.a = a;
-
- this.b = b;
-
- }
-
- }
-
- }

加上-XX:+PrintGC参数运行上面的方法,会看到控制台只是打印了执行时间5ms,但是若再加上-XX:-DoEscapeAnalysis关闭逃逸分析就会出现下面的结果:
[GC (Allocation Failure) 66384K->848K(251392K), 0.0012528 secs]
[GC (Allocation Failure) 66384K->816K(251392K), 0.0010461 secs]
[GC (Allocation Failure) 66352K->848K(316928K), 0.0009666 secs]
[GC (Allocation Failure) 131920K->784K(316928K), 0.0018284 secs]
[GC (Allocation Failure) 131856K->816K(438272K), 0.0009315 secs]
[GC (Allocation Failure) 262960K->684K(438272K), 0.0022738 secs]
[GC (Allocation Failure) 262828K->684K(700928K), 0.0005052 secs]308 ms
执行时间大大提升,主要是用在了GC回收上。
在HotSpot虚拟机中,对象在内存中分为三块:对象头、实例数据和对齐填充。如下图:
对象的内存布局上面这张图写的很清楚了,其中自身运行时数据了解一下有哪些信息即可,类型指针则是指向对象所属的类,如果对象是数组,则对象头中还会包含数组的长度信息;实例数据就是指对象的字段信息;最后对齐填充则不是必须的,因为为了方便处理和计算,HotSpot要求对象的大小必须是8字节的整数倍,因此当不满8字节的整数倍时,就需要对齐填充来补全。
当对象创建完成后就存在于堆中,那么栈中怎么定位并引用到该对象呢?虚拟机规范中本身并没有定义这一部分该如何实现,具体的实现取决于各个虚拟机厂商,而目前主流的定位方式有两种:句柄和直接指针。
以上两种方式在各个语言和框架都有使用,而本文所讨论的HotSpot虚拟机使用的是直接指针方式,因为对象的访问是非常频繁的,这时效率就显得格外重要。
JVM不需要我们手动释放内存,这是Java广受欢迎的原因之一,那么它是如何做到自动管理内存,回收不需要的对象的呢?既然要回收对象,那么就需要判断哪些对象是可以被回收的,即对象的死活判定,哪些对象不会再被引用?有两种实现方式:引用计数法和可达性分析。
以上4种非常好理解,是重点,需要熟记于心,因为上面4种对象是在方法运行时或常量引用的对象,在对应的生命周期是肯定不能被GC回收的,作为GC Roots自然再合适不过。另外还有下面几种可以作为了解:
除了堆中对象需要回收,方法区中的class对象也是可以被回收的,但是回收的条件非常苛刻:
可以看到方法区的回收条件是多么苛刻,所以方法区的回收率一般极低,因此可以通过-Xnoclassgc关闭方法区的回收,提升GC效率,但需要注意,关闭后将会导致方法区的内存永久被占用,导致OOM出现。
Java设计这四种引用的主要目的有两个:
强引用是最普遍的一种引用,我们写的代码,99.9999%都是强引用:
Object o = new Object();
这种就是强引用了,是不是在代码中随处可见,最亲切。
只要某个对象有强引用与之关联,这个对象永远不会被回收,即使内存不足,JVM宁愿抛出OOM,也不会去回收。
那么什么时候才可以被回收呢?当强引用和对象之间的关联被中断了,就可以被回收了。
我们可以手动把关联给中断了,方法也特别简单:
o = null;
我们可以手动调用GC,看看如果强引用和对象之间的关联被中断了,资源会不会被回收,为了更方便、更清楚的观察到回收的情况,我们需要新写一个类,然后重写finalize方法,下面我们来进行这个实验:
- public class Student {
- @Override
- protected void finalize() throws Throwable {
- System.out.println("Student 被回收了");
- }
- }
- public static void main(String[] args) {
- Student student = new Student();
- student = null;
- System.gc();
- }
运行结果:
Student 被回收了
可以很清楚的看到资源被回收了。
当然,在实际开发中,千万不要重写finalize方法
在实际的开发中,看到有一些对象被手动赋值为NULL,很大可能就是为了“特意提醒”JVM这块资源可以进行垃圾回收了。
下面先来看看如何创建一个软引用:
SoftReference<Student>studentSoftReference=new SoftReference<Student>(new Student());
软引用就是把对象用SoftReference包裹一下,当我们需要从软引用对象获得包裹的对象,只要get一下就可以了:
- SoftReference<Student>studentSoftReference=new SoftReference<Student>(new Student());
- Student student = studentSoftReference.get();
- System.out.println(student);
软引用有什么特点呢:
当内存不足,会触发JVM的GC,如果GC后,内存还是不足,就会把软引用的包裹的对象给干掉,也就是只有在内存不足,JVM才会回收该对象。
还是一样的,必须做实验,才能加深印象:
- SoftReference<byte[]> softReference = new SoftReference<byte[]>(new byte[1024*1024*10]);
- System.out.println(softReference.get());
- System.gc();
- System.out.println(softReference.get());
-
- byte[] bytes = new byte[1024 * 1024 * 10];
- System.out.println(softReference.get());
我定义了一个软引用对象,里面包裹了byte[],byte[]占用了10M,然后又创建了10Mbyte[]。
运行程序,需要带上一个参数:
-Xmx20M
代表最大堆内存是20M。
运行结果:
- [B@11d7fff
- [B@11d7fff
- null
可以很清楚的看到手动完成GC后,软引用对象包裹的byte[]还活的好好的,但是当我们创建了一个10M的byte[]后,最大堆内存不够了,所以把软引用对象包裹的byte[]给干掉了,如果不干掉,就会抛出OOM。
只有在内存不足,JVM才会回收该对象
软引用到底有什么用呢?比较适合用作缓存,当内存足够,可以正常的拿到缓存,当内存不够,就会先干掉缓存,不至于马上抛出OOM。
弱引用的使用和软引用类似,只是关键字变成了WeakReference:
- WeakReference<byte[]> weakReference = new WeakReference<byte[]>(new byte[1024*1024*10]);
- System.out.println(weakReference.get());
弱引用的特点是不管内存是否足够,只要发生GC,都会被回收:
- WeakReference<byte[]> weakReference = new WeakReference<byte[]>(new byte[1]);
- System.out.println(weakReference.get());
- System.gc();
- System.out.println(weakReference.get());
运行结果:
- [B@11d7fff
- null
可以很清楚的看到明明内存还很充足,但是触发了GC,资源还是被回收了。
弱引用的特点是不管内存是否足够,只要发生GC,都会被回收:
弱引用在很多地方都有用到,比如ThreadLocal、WeakHashMap。
虚引用又被称为幻影引用,我们来看看它的使用:
- ReferenceQueue queue = new ReferenceQueue();
- PhantomReference<byte[]> reference = new PhantomReference<byte[]>(new byte[1], queue);
- System.out.println(reference.get());
虚引用的使用和上面说的软引用、弱引用的区别还是挺大的,我们先不管ReferenceQueue 是个什么鬼,直接来运行:
null
竟然打印出了null,我们来看看get方法的源码:
- public T get() {
- return null;
- }
这是几个意思,竟然直接返回了null。
这就是虚引用特点之一了:无法通过虚引用来获取对一个对象的真实引用。
那虚引用存在的意义是什么呢?这就要回到我们上面的代码了,我们把代码复制下,以免大家再次往上翻:
- ReferenceQueue queue = new ReferenceQueue();
- PhantomReference<byte[]> reference = new PhantomReference<byte[]>(new byte[1], queue);
- System.out.println(reference.get());
创建虚引用对象,我们除了把包裹的对象传了进去,还传了一个ReferenceQueue,从名字就可以看出它是一个队列。
虚引用的特点之二就是 虚引用必须与ReferenceQueue一起使用,当GC准备回收一个对象,如果发现它还有虚引用,就会在回收之前,把这个虚引用加入到与之关联的ReferenceQueue中。
我们来用代码实践下吧:
- ReferenceQueue queue = new ReferenceQueue();
- List<byte[]> bytes = new ArrayList<>();
- PhantomReference<Student> reference = new PhantomReference<Student>(new Student(),queue);
- new Thread(() -> {
- for (int i = 0; i < 100;i++ ) {
- bytes.add(new byte[1024 * 1024]);
- }
- }).start();
-
- new Thread(() -> {
- while (true) {
- Reference poll = queue.poll();
- if (poll != null) {
- System.out.println("虚引用被回收了:" + poll);
- }
- }
- }).start();
- Scanner scanner = new Scanner(System.in);
- scanner.hasNext();
- }

运行结果:
- Student 被回收了
- 虚引用被回收了:java.lang.ref.PhantomReference@1ade6f1
我们简单的分析下代码:
第一个线程往集合里面塞数据,随着数据越来越多,肯定会发生GC。
第二个线程死循环,从queue里面拿数据,如果拿出来的数据不是null,就打印出来。
从运行结果可以看到:当发生GC,虚引用就会被回收,并且会把回收的通知放到ReferenceQueue中。
无法通过虚引用来获取对一个对象的真实引用。
虚引用必须与ReferenceQueue一起使用,当GC准备回收一个对象,如果发现它还有虚引用,就会在回收之前,把这个虚引用加入到与之关联的ReferenceQueue中
虚引用主要用来跟踪对象被垃圾回收器回收的活动。 虚引用与软引用和弱引用的一个区别在于:
虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
复制代码
String str = new String("abc"); ReferenceQueue queue = new ReferenceQueue(); // 创建虚引用,要求必须与一个引用队列关联 PhantomReference pr = new PhantomReference(str, queue);
程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要进行垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
虚拟机提供了一次自我拯救的机会给对象,即finalize方法。如果对象覆盖了该方法,当经过可达性分析后,就会进行一次判断,判断该对象是否有必要执行finalize方法,如果对象没有覆盖该方法或者已经执行过一次该方法都会判定为该对象没有必要执行finalize方法,在GC时被回收。否则就会将该对象放入到一个叫F-Queue的队列中,之后GC会对该队列的对象进行二次标记,即调用该方法,如果我们要让该对象复活,那么就只需要在finalize方法中将该对象重新与GC Roots关联上即可。
该方法是虚拟机提供给对象复活的唯一机会,但是该方法作用极小,因为使用不慎可能会导致系统崩溃,另外由于它的运行优先级也非常低,常常需要主线程等待它的执行,导致系统性能大大降低,所以基本上可以忘记该方法的存在了。
上文说到对象是在堆中分配内存的,但是堆中也是分为新生代和老年代的,新生代中又分了Eden、from、survivor区,那么对象具体会分配到哪个区呢?这涉及到对象的分配规则,下面一一说明。
大多数情况,对象直接在Eden区中分配内存,当Eden区内存不足时,就会进行一次MinorGC(新生代垃圾回收,可以通过-XX:+PrintGCDetails这个参数打印GC日志信息)。
什么是大对象?虚拟机提供了一个参数:-XX:PretenureSizeThreshold,当对象大小大于该值时,该对象就会直接被分配到老年代中(该参数只对Serial和ParNew垃圾收集器有效)。为什么不分配到新生代中呢?因为在新生代中每一次MinroGC都会导致对象在Eden、from和sruvivor中复制,如果存在很多这样的大对象,那么新生代的GC和复制效率就会极低(关于垃GC的内容后面的文章会详细讲解)。
既然对象优先在新生代中分配,那么什么时候会进入到老年代呢?这就和上文讲解的对象头中的分代年龄有关了,默认情况下超过15岁就会进入老年代,可以通过-XX:MaxTenuringThreshold参数进行设置。那岁数又是怎么增长的呢?每当对象熬过一次MiniorGC后年龄都会增加1岁。
但是虚拟机并不是要求对象年龄必须达到MaxTenuringThreshold才能晋升老年代,当Survivor空间中相同年龄的所有对象的大小总和大于Survivor空间一半时,年龄大于或等于该年龄的对象就会直接晋升到老年代。
在发生MiniorGC之前,虚拟机首先会检查老年代中最大可用的连续空间是否大于新生代所有对象的总和,如果大于则进行一次MiniorGC;否则,则会检查HandlePromotionFailure设置值是否允许担保失败。如果允许则会检查老年代最大连续空间是否大于历次晋升到老年代对象的平均大小,如果大于则进行一次MiniorGC,否则则进行一次FullGC。
为什么要这么设计呢?因为频繁的FullGC会导致性能大大降低,而取历次晋升老年代对象的平均大小肯定也不是百分百有效,因为存在对象突然大大增加的情况,这个时候就会出现担保失败的情况,也会导致FullGC。需要注意的是HandlePromotionFailure这个参数在JDK6Update24后就不会再影响到虚拟机的空间分配担保策略了,即默认老年代的连续空间大于新生代对象的总大小或历次晋升的平均大小就会进行MinorGC,否则进行FullGC。
本文概念性的东西非常多,这是学习JVM的难点和基础,但这是绕不开的一道坎,读者只有多看,多思考,写代码复现文中提到的概念,才能真正的理解这些基础知识。另外还有垃圾是怎么回收的?有哪些垃圾回收器?怎么选择?这些问题将在下一篇进行解答。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。