Java 面试复习_6
2019-5-29
作者:水不要鱼
(注:能力有限,如有说错,请指正!)
当我们使用 new 指令去创建一个对象的时候,主要会经历以下几个阶段:
- 检查常量池中是否有这个类的符号引用
- 检查这个符号引用指向的类是否已经被加载、解析以及初始化,如果没有的话就需要去执行加载的一系列工作了
- 为对象分配内存空间,当空间分配好之后还需要将空间初始化为零值,
- 当以上的工作都执行完毕了,就会执行
<init>
方法,将对象以我们的意愿进行初始化
这里面有两个点需要注意,都是和分配内存有关,一个是如何分配出一块内存,另一个就是多线程下内存分配的安全性。
- 如何分配出对象所需的内存空间
在 JVM 中存在两种内存分配方式:指针碰撞和空闲列表。如果内存是规整的,也就是说空闲区域和已使用区域刚好是分成两块区域的时候,
使用一个指针代表这两个区域的分界线就可以了,当需要分配一块内存空间时,直接将这个指针移动一段和这个空间大小相等的距离即可,这就是
指针碰撞。如果内存不是规整的,也就是空闲区域和已使用区域有很多块零散的,夹杂在一起,就需要用一张表来记录空闲的区域有哪些,在哪里,
当需要分配一块内存空间时,就从表上找出大小相当的一块或者几块区域来使用,这就是空闲列表。
很明显,指针碰撞所使用的方式在分配时速度是很快的,因为只需要更新一个指针的位置就可以了,但是想要使用指针碰撞的前提是
内存是规整的,这就要求垃圾收集器要有压缩整理的功能,将内存整理为规整的。
- 多线程下内存分配的安全性问题
由于对象分配内存空间时用的内存区域是共享的,假设我们使用指针碰撞,当一个对象已经在分配内存了,但是指针还没修改,
这时候又来了一个对象分配内存,还是使用的原来的指针,这就会出现多线程问题。多线程问题一般就是两种解法,同步处理和隔离处理。
同步处理很好理解,就是让所有操作同步执行,一个对象创建完才创建另外一个,有点像排队,在 JVM 中是采用 CAS 加失败重试的措施来保证同步的。
隔离处理也很好理解,既然这个问题是操作共享内存才会出现,那我们让他们操作的是不同的区域不就好了,当然,这就要求每个线程都要有自己的一份空间,
这个空间称为 TLAB (Thread Local Allocation Buffer)。这个方式是否开启,可以通过 JVM 参数来设置:-XX:+/-UseTLAB。
对象的内存布局
对象在 JVM 中的内存布局分为了 3 个区域:对象头
、实例数据
和对齐填充
。
- 对象头:一般包含两个数据,对象自身运行时的数据(Mark Word)和类型指针。如果是数组对象,就还会多一个数据用于记录数组的大小。
- 实例数据:就是我们在类中定义的数据,通常情况下,父类的数据会在子类前面,但是也有可能出现父类的变量中夹杂着一个子类的变量,
这是在开启 CompactFields 之后会发生的事情。而且,在 JVM 中,相同宽度的字段会被放在一起,这一方面是为了内存的管理,另一方面也是为了性能。
值得一提的是,byte 和 boolean 是一起的,也就是说 boolean 也是占一个字节,这点否决了一些人说的 boolean 占 1bit 的观点。 - 对齐填充:还是为了内存管理和性能,JVM 规定对象起始地址必须是 8 的整数倍,也就是说对象大小得是 8 的整数倍。对象头不用担心,因为就是 32bit 或者 64bit 的,
但是我们定义的实例数据就不一定了,所以这时候就需要进行字节填充,将对象大小填充到符合条件。
这是 JVM 中非常重要的一部分,而且垃圾回收算法也有很多,这里聊几个主要的:标记清除
,复制
,标记整理
。
-
标记清除算法:分为两个阶段,标记和清除。首先把所有需要回收的内存对象标记上,然后执行清除,注意到这个清除是原地清除,所以这会使得内存是不连续的。
这就意味着,经过这个算法进行垃圾回收之后,会产生大量不连续的内存空间,如果想使用指针碰撞模式就不行了,而且遇到需要分配一块大的内存空间的情况时,
由于内存不连续,所以有可能会触发新的一次垃圾回收。 -
复制算法:将内存区域分为两部分,一部分是目前正在使用的,另一部分是保留的空闲区域,每一次回收的时候,将使用区域的存活对象复制到保留的空闲区域上,
然后将正在使用的那一部分整块清理,就变成新的保留空闲区域,而原本的空闲区域就变成了正在使用的区域。这样做有很明显的缺点,一个是性能问题,一个是浪费问题。
由于每一次清理都需要复制存活的对象到另外一部分,所以这个复制肯定会消耗一些性能,不过几乎很多业务中,存活对象占的比重很小很小,所以复制所消耗的性能也就比较小。
另一个问题比较严重,就是浪费了一部分的内存区域,正常来说,这两部分应该是一样大的,这样才能相互替换,在这种情况下,就会造成一半内存空间的浪费,这是非常不值得的。
再一次因为很多业务中存活的对象的很少,所以我们可以假设空闲区域可以比正在使用的区域小很多,这样浪费的比例会小很多,但我们还是需要注意空闲区域不够的问题,这时候
可以使用一块备用区域,当空闲区域真的不够用的使用,就存到这个备用区域,相当于一个内存的担保人。而 HotSpot 中正是这么干的,它将内存区域分为了三部分,一个是
Eden 区
,另外两个都是Survivor 区
。正常使用的时候会使用 Eden 和一个 Survivor,而剩下的一个 Survivor 则是保留的空闲区域,当需要进行垃圾回收的时候,
就会将正在使用的 Eden 和 一个 Survivor 区中的存活对象复制到保留的 Survivor 区中,然后直接清空刚刚正在使用的两个区域。而 Survivor 显然比正在使用的空间要小,
如果出现空间不足的情况,就会将对象直接放入老年代。从这里也可以看出来,老年代不适合使用这个算法,因为没有内存担保人,如果需要开辟新的区域专门来做内存担保人,是得不偿失的,
也是没有解决浪费内存问题的。 -
标记整理算法:与标记清除算法类似,只是在标记之后不是直接清理,因为那样会产生不连续的空间,所以这个算法将清除更换为了整理,意思就是,先整理再清除。
为了让内存可以连续,整理的时候就需要将存活的对象移动到一边,然后清除另一边。仔细一看会发现和复制算法很像,不过复制算法是固定将内存分为两部分,一时间段内只能使用其中
一个部分,这也就产生了内存浪费,因为另一部分使用不了。而标记整理则没有这个要求,它可以使用完整的内存区域,只是说在清理的时候,会将存活的对象移动到固定的一边,
这样就可以直接清理另外一边了。从这里也可以看出来,需要移动对象就意味着会有性能的消耗,但是总的区域就这么大,当需要的复制的对象很多时,需要清理的内存就小了,同理,
反过来也成立。
我们需要关注的是,一般虚拟机都会使用混合的垃圾收集算法,就是将内存区域按不同的特点划分为不同的区域,比如新生代和老年代,然后再根据这两个区域的特点选择一个适合的
垃圾算法,这也被称为分代收集算法。
扩展:如何判断一个对象是否需要回收
一般常用的判断算法有两种:引用计数算法
和可达性分析算法
。我们就来聊聊这两种算法:
-
引用计数算法:为每个对象都设置一个计数器,用来记录这个对象的引用次数,当有一个地方引用的时候就加 1,没有的时候的减 1,当计数器为 0 也就意味着就没有地方在引用这个对象了,
就将这个对象回收了。看起来好像挺完美的,也有挺多语言使用的,比如 python。但是这个算法有一个问题,就是如何解决循环引用?举个例子,AB 两个对象,A 对象引用了 B对象,
B 对象引用了 A 对象,使用引用计数算法来看的话,这两个对象永远都得不到回收,因为计数器不为 0。 -
可达性分析算法:从一个“点”开始搜索引用到的对象,能被搜索到的对象就不回收,搜索不到的对象就回收。我们先来看循环引用的问题会不会发生,首先是 AB 两个对象,相互引用,
如果从 A 开始搜索,是可以搜索到 B 的,反过来也是这样,这就意味着 AB 不会被回收,但是,请注意,我一开始说的是从一个“点”出发开始搜索,
那 A 能成为这个点吗?换句话说,如果 A 都不能成为出发点,又怎么能搜索到 B?所以对于可达性分析算法来说,哪些引用属于这个“点”就很关键了。在 JVM 中,能作为出发点的有以下几种:
- Java 虚拟机栈中引用的对象
- 方法区中的静态变量
- 方法区中常量引用的对象
- 本地方法栈中引用的对象
扩展:Java 中的引用类型
在 Java 中存在四种引用类型:强引用,软引用,弱引用,虚引用。
-
强引用:我们正常使用的其实就是强引用,比如 Object obj = new Object(),这样的话,只要这个引用一直存在,就不会被 GC 回收。
-
软引用:使用 SoftReference 类可以做到 GC 回收时先不回收,当回收一次之后,内存真的不够了才回收。
class Test {
public static void main(String[] args){
SoftReference<String> reference = new SoftReference<>("我是软引用的字符串");
// 这个 get 不一定能获取到,如果被 GC 清理了,就会返回 null
System.out.println(reference.get());
}
}
- 弱引用:使用 WeakReference 就可以做到只要发生 GC 就回收这个对象。
class Test {
public static void main(String[] args){
WeakReference<String> reference = new WeakReference<>("我是弱引用的字符串");
// 这个 get 不一定能获取到,如果被 GC 清理了,就会返回 null
System.out.println(reference.get());
}
}
- 虚引用:这个引用在实际使用中没有任何用处,仅仅只是为了在对象回收的时候可以收到通知而已。
我们知道在 Object 类中有一个 finalize 方法,子类可以重写这个方法,以便在对象被回收时可以做一些工作。
但是这个方法会存在一些坑,首先一点就是一个对象的这个方法只会被调用一次,而且不一定会被执行完毕。
我们先来看 finalize 执行流程:
- 第一次 GC 的时候,进行可达性分析,发现对象 A 需要被回收,就将对象标记上
- 判断这个对象是否需要执行 finalize 方法,如果不需要就回收了,如果需要,就将对象加入一个 F-Queue 队列
- JVM 会有一个守护线程专门去检查这个队列并执行这些 finalize 方法,但是,JVM 并不会等待这个方法执行完,因为如果一个对象的 finalize
方法阻塞住,就会导致后面的对象都在等待,也就是说影响到 GC 的正常工作了 - 当 finalize 方法被执行过了(注意,我说的是“执行过了”而不是“执行完了”),就会再次对这个对象进行可达性分析
- 如果可达性分析发现对象有引用,就将它从待回收列表里面移除,如果没有引用,就进行回收。
从上面我们可以看到,finalize 方法并不一定保证会被执行完,只会保证执行过而且是只执行一次,所以如果你打算在
这个方法里面释放资源,是有可能出现资源没有被释放的情况的,不推荐使用它来释放资源,最好还是用 finally。
再一个就是,如果 finalize 方法代码复杂消耗时间,这个消耗是会导致 GC 的回收时间变长的,当然 GC 不会傻傻的等待,
而切断方法执行的后果就是导致 finalize 方法并不一定会被执行完。