记一次有趣的 Java 面试
笔者很久没有面试的时候被问过深度的 GC
知识了,看看腾讯的高级技术专家是如何面试 Java
的。
Q1: new 之后发生了什么?
面试的时候没有理解面试官想问的问题,回答的比较糟糕,反思一下,用下面的例子来说明
1 | public class DemoApplication { |
首先:我们需要使用 Classloader
去加载这个类 loadClass
这里会涉及到 双亲委派模型
因为我们应用的启动都是通过 AppClassLoader
进行启动,因此哪怕我们自定实现了一个 Classloader
在其内部载入一个 魔改的String
类,但是因为不同的 ClassLoader
载入的同名类并不作为同一个对象,也不能算我们重写的 String.class
。
不过还有一个值得注意的在我们 defineClass
的内置函数中,我们尝试定义如下代码
1 | static class TestClassLoader extends ClassLoader { |
结果是:
1 | Exception in thread "main" java.lang.SecurityException: Prohibited package name: java.lang |
其实方法的注释上,我们也可以发现
1 | * SecurityException |
我们是没办法绕过JVM的安全机制的。
Q2:那我们如何热替换Class
从上一Q中,我们知道不同的 ClassLoader
载入的同名类是属于不同的类,那我们 Hot Swap Class
第一种方式:每次调用的时候都是 LoadClass
,但是这样的方式相当于我们每一次都是载入一个新的Class。
1 | public class Test { |
不过值得注意的是对于已经 defineClass
的对象我们必须要构建出一个新的 ClassLoader
才能再次 reload
这个class。而且不同的 Classloader
载入的相同的 Class
在对比的时候并不是 相等
的。
1 | final Class<?> aClass = new TestClassLoader().loadClass("test.Test"); |
第二种方法:instrumentation::redefine
Arthas 热更新背后的原理就是 JavaAgent 技术。对于上面的方法我们是没有办法在运行时去修改逻辑,我们必须要在 Coding
的时候就将这个过程给固化下来,而Java提供了另外一种完全热更新的方案 Java Instrumentation
一种为 pre-main 方式,这种方式需要在虚拟机参数指定 Instrumentation 程序,然后程序启动之前将会完成修改或替换类,Skywalking就是这么个工作原理。JDK6 针对这种情况作出了改进,增加 agent-main 方式。我们可以在应用启动之后,再运行 Instrumentation
程序。启动之后,只有连接上相应的应用,我们才能做出相应改动,这里我们就需要使用 Java 提供 attach API
。
详细可以参考 手把手教你实现热更新功能,带你了解 Arthas 热更新背后的原理
继续 Q1:New阶段在我们载入Class之后做了些什么
方法区开辟空间存入缓存
Class文件
被我们载入的 Class(Method) Area
。不过 方法区(Permanent Generation)
的大小默认是无限制的,但是和普通的 GC
一样,这里也会发生 GC
,常见的是在启动的时候载入的大量的 Class
就会出现频繁的 GC
。
初始化对象
首先第一步我们需要在 Stack
分配一个指向 Heap
的引用对象。
然后再去 Heap
开辟一个新空间。不过从这里在这次的面试中就有点自己知识的盲区了,我尝试着带着 Google
重新回顾一下。
按照理解的话 Linux
提供了 malloc
的标准函数进行内存的分配,但是这样的返回仅仅是一个 Address of a chunk memory
,对于 Jvm
这样的虚拟机应该将这些地址都存储起来并且在恰当的时候进行 GC
。
1 | ptr_to_container = (Container *) malloc(sizeof(Container)); |
在 What happened when new an object in JVM 有说过这个,在 Hotspot
实现过程中,真实的 object
对象,包含了一些额外的信息在 object header
中,不过并没有在中发现具体的 Memoery Address
应该是被一个统一的管理器管理起来了。
因为 Class
的类型一定定义了数据的长度,Sizeof
函数对于 Java
显然是非必要的,我们如何从空闲的内存中获取数据呢?
The Java heap memory is regular (using a markup or a garbage collector with compression), using a pointer to the free location, and allocating memory moves the pointer equal to the allocated size.
The memory is not regular (the garbage collector using the markup cleanup), the virtual machine maintains a list of available memory blocks, and when the memory is allocated, a large enough memory space is found from the list to allocate the object and update the available memory list.
A GC is triggered when sufficient memory cannot be found
对于不同的GC算法,内存区域的开辟也有不同,常规模式下内存管理器
维护者 Free Point
指向空闲的区域,然后开辟空间移动指针即可,非常规的模式下直接将 block
块合并返回。不过下面那种模式更快,不过也会产生更多的碎片。
Q:那JVM为何要分代
在 Understanding Memory Management
中有提到。
在 HotSpot Virtual Machine Garbage Collection Tuning Guide 中说道:
Throughput is the percentage of total time not spent in garbage collection considered over long periods of time. Throughput includes time spent in allocation (but tuning for speed of allocation is generally not needed).
Pauses are the times when an application appears unresponsive because garbage collection is occurring.
对于GC算法来说,暂停时间
和 吞吐量
是最为核心的诉求,不同的搭配会产生不一样的效果
- Serial GC for both the Young and Old generations
- Parallel GC for both the Young and Old generations
- Parallel New for Young + Concurrent Mark and Sweep (CMS) for the Old Generation
- G1, which encompasses collection of both Young and Old generations
Serial GC
young generation
youg
又分为 eden
和 S0
/S1
,Young
的运行机制如下:
- 创建的对象都在
Eden
Eden
满的时候触发Minor garbage collection
,将存活的移动到Survivor Space
- 后续的
Minor GC
会回收所有的Young
的对象,并且将S0
在S1
来回倒腾对象 - 当对象活超过
max age threshold
会将对象从Survivor
移动到Tenured Space
对于 JRockit
实现的时候并没有 S0
和 S1
的说法,只有一个 survivor
,为什么 Hotspot
需要呢?
在 Java GC: why two survivor regions? 中又说到,当我们出发了 Minor GC
之后,杀掉了一些对象之后,Eden
和 Survivor
都会出现碎片,为了减少碎片,Sun
折中选择了将 S0/S1
进行移动的过程中进行 压实
。
对于 eden
的对象要么选择去 survivor
要么选择 killed
,每一次应该 eden
是清空的。
Old generation
上面对于 Serial GC
只是 Minor GC
的部分,当我们的 Eden/Survior
都满的情况下会触发 Full GC
Parallel GC
Parallel GC
和 Serial GC
一样对于 young
采用的是 mark-copy
,对于 old
采用的 mark-sweep-compact
和 Serial GC
不一样是采用了多线程的方式进行操作,在多核系统上较好。
Concurrent Mark and Sweep
CMS
对于 Young
采用的还是 mark-copy
的算法,Old Generation
部分就是 mostly concurrent mark-sweep
。 young
部分就不多说,对于 old
部分。
因为 compact
很慢的一个过程,比较涉及到 move
对象,CMS
的设计目标是为了防止长时间的停顿( long pauses) 不过因为对于 Young
其实没啥区别,就主要看看 Old Generation
Old Generation
- 采用
free-lists
算法管理空闲空间 - 大部分的操作是不停止应用工作的
第一步:初始化标记,这一部分会 SOW,不过只需要找到所有在 Old
中的 Root
第二步:并发标记
这一步不会 SOW
第三步:重新标记
重新标记处理并发标记过程中因为用户程序同时运行而导致标记产生变动的对象的标记记录。stop-the-world。
第四步:并发清除 Concurrent sweep
对于 CMS
来说,肉眼即可的问题就是 Free-List
带来的碎片化问题。当然我们可以设置 UseCMSCompactAtFullCollection
来开启 压实
操作。
G1 – Garbage First
G1按固定大小把内存划分为很多小区域(region),这个堆大概有2000多块;在逻辑上,某些小区域构成Eden,某些构成Survivor,某些构成老年代,这些小区域物理上是不相连的,并且构成新生代和老年代的区域可以动态改变。
G1
的过程和 CMS
是类似的,但是因为空间不再是连续的,我们可以将空间的属性进行变换。G1从整体来看是基于“标记-整理”算法实现收集,从局部(两个Region)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片。并且很多时候是重新定义Zone属于什么区域,因此会快很多。
YoungGC
YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC。
MixedGC
不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC。
分代算法
JVM
的分代算法和 Ungar
论文中一样。侧重在 Young
区域的回收,因此对于 COPY
到 OLD
区域的对象并不是放在 Root
对象上进行关联的,通过了一种名为 记录集
的东西记录了所有的 OLD
区域对象对新生代对象的集合。
继续回答
对于完成了对象的分配之后,我们只是开辟了一个空间(Allocate the memory space of the object),下一步我们需要的是对对象进行初始化。包括默认值等,执行构造函数等。之后一步才是将这个对象的地址挂到那个命名变量上。
Q: Netty 有哪些优化手段
内存零拷贝
- Netty的接收和发送ByteBuffer使用直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用JVM的堆内存进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,然后才写入Socket中。相比于使用直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
- Netty的文件传输调用FileRegion包装的transferTo方法,可以直接将文件缓冲区的数据发送到目标Channel,避免通过循环write方式导致的内存拷贝问题。
- Netty提供CompositeByteBuf类, 可以将多个ByteBuf合并为一个逻辑上的ByteBuf, 避免了各个ByteBuf之间的拷贝。
- 通过wrap操作, 我们可以将byte[]数组、ByteBuf、ByteBuffer等包装成一个Netty ByteBuf对象, 进而避免拷贝操作。
- ByteBuf支持slice操作,可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf, 避免内存的拷贝。
FastThreadLocal
FastThreadLocal 在创建 Thread 时候给每个对象分配了一个 index,之后通过这个 index 会比原生的 hashcode
方式好很多。
IntObjectMap
这里还没来得及看,先 Mark
Q:解释下 强引用 、软引用、弱引用、虚引用
- 正常都是 强引用
- 如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。比较适合缓存的场景。
- 弱引用在垃圾回收时,如果这个对象只被弱引用关联(没有任何强引用关联他),那么这个对象就会被回收。
- 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象的实例。
附录1:
Mark Sweep GC
标记清除应该最容易理解的算法了,对于 GC
的实现来说,是最为稳妥的方案,为了降低 STW
的时间。但是对于 JVM
来说,增加了很多子步骤
- 标记阶段可以采用
初始化标记
和并发标记
和Final Remark
分成三段,这样我们需要STW
的时间仅仅需要初始化标记
阶段和Final Remark
阶段。 Concurrent Preclean / Concurrent Abortable Preclean
都是为了老年代处理引用年轻代
的问题。- 最终再进行
Concurrent Sweep
对于 MarkSweep 有很明显的几个缺点
- 碎片化问题:因为在
Sweep
结束之后会导致内存碎片化,解决这个问题最常见的就是采用压实
,不过这个成本很高,只有在FullGC
阶段才会触发。 - 停滞问题:
MarkSweep
的STW
跟着Heap
的增加,小对象的变多就会越来越慢。