记一次有趣的 Java 面试

bHP5z.png

笔者很久没有面试的时候被问过深度的 GC 知识了,看看腾讯的高级技术专家是如何面试 Java 的。

Q1: new 之后发生了什么?

面试的时候没有理解面试官想问的问题,回答的比较糟糕,反思一下,用下面的例子来说明

1
2
3
4
5
public class DemoApplication {
public static void main(String[] args) {
DemoApplication.class.getClassLoader().loadClass("io.xxx.xxx").newInstance();
}
}

首先:我们需要使用 Classloader 去加载这个类 loadClass 这里会涉及到 双亲委派模型 因为我们应用的启动都是通过 AppClassLoader 进行启动,因此哪怕我们自定实现了一个 Classloader 在其内部载入一个 魔改的String 类,但是因为不同的 ClassLoader 载入的同名类并不作为同一个对象,也不能算我们重写的 String.class

不过还有一个值得注意的在我们 defineClass 的内置函数中,我们尝试定义如下代码

结果是:

1
2
3
4
5
6
Exception in thread "main" java.lang.SecurityException: Prohibited package name: java.lang
at java.lang.ClassLoader.preDefineClass(ClassLoader.java:662)
at java.lang.ClassLoader.defineClass(ClassLoader.java:761)
at java.lang.ClassLoader.defineClass(ClassLoader.java:642)
at test.Main$TestClassLoader.loadClass(Main.java:24)
at test.Main.main(Main.java:10)

其实方法的注释上,我们也可以发现

1
2
3
4
5
* @throws  SecurityException
* If an attempt is made to add this class to a package that
* contains classes that were signed by a different set of
* certificates than this class (which is unsigned), or if
* <tt>name</tt> begins with "<tt>java.</tt>".

我们是没办法绕过JVM的安全机制的。

Q2:那我们如何热替换Class

从上一Q中,我们知道不同的 ClassLoader 载入的同名类是属于不同的类,那我们 Hot Swap Class

第一种方式:每次调用的时候都是 LoadClass,但是这样的方式相当于我们每一次都是载入一个新的Class。

不过值得注意的是对于已经 defineClass 的对象我们必须要构建出一个新的 ClassLoader 才能再次 reload 这个class。而且不同的 Classloader 载入的相同的 Class 在对比的时候并不是 相等 的。

1
2
3
final Class<?> aClass = new TestClassLoader().loadClass("test.Test");
final Class<?> bClass = new TestClassLoader().loadClass("test.Test");
System.out.println(aClass.equals(bClass)); // false

第二种方法: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 的引用对象。

bbokE.png

然后再去 Heap 开辟一个新空间。不过从这里在这次的面试中就有点自己知识的盲区了,我尝试着带着 Google 重新回顾一下。

按照理解的话 Linux 提供了 malloc 的标准函数进行内存的分配,但是这样的返回仅仅是一个 Address of a chunk memory,对于 Jvm 这样的虚拟机应该将这些地址都存储起来并且在恰当的时候进行 GC

malloc
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 中有提到。

bb3uH.png

以前我没太理解,我认为之所以要分分代是因为对于不同的区域的 GC 算法应该有所不同。

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 又分为 edenS0/S1Young 的运行机制如下:

  • 创建的对象都在 Eden
  • Eden 满的时候触发 Minor garbage collection,将存活的移动到 Survivor Space
  • 后续的 Minor GC 会回收所有的 Young 的对象,并且将 S0S1 来回倒腾对象
  • 当对象活超过 max age threshold 会将对象从 Survivor 移动到 Tenured Space

对于 JRockit 实现的时候并没有 S0S1 的说法,只有一个 survivor,为什么 Hotspot 需要呢?

Java GC: why two survivor regions? 中又说到,当我们出发了 Minor GC 之后,杀掉了一些对象之后,EdenSurvivor 都会出现碎片,为了减少碎片,Sun 折中选择了将 S0/S1 进行移动的过程中进行 压实

bbOuy.png

对于 eden 的对象要么选择去 survivor 要么选择 killed,每一次应该 eden 是清空的。

Old generation

上面对于 Serial GC 只是 Minor GC 的部分,当我们的 Eden/Survior 都满的情况下会触发 Full GC

buSAf.png

Parallel GC

Parallel GCSerial GC 一样对于 young 采用的是 mark-copy,对于 old 采用的 mark-sweep-compactSerial GC 不一样是采用了多线程的方式进行操作,在多核系统上较好。

Concurrent Mark and Sweep

CMS 对于 Young 采用的还是 mark-copy 的算法,Old Generation 部分就是 mostly concurrent mark-sweepyoung 部分就不多说,对于 old 部分。

因为 compact 很慢的一个过程,比较涉及到 move 对象,CMS 的设计目标是为了防止长时间的停顿( long pauses) 不过因为对于 Young 其实没啥区别,就主要看看 Old Generation

Old Generation

  • 采用 free-lists 算法管理空闲空间
  • 大部分的操作是不停止应用工作的
第一步:初始化标记,这一部分会 SOW,不过只需要找到所有在 Old 中的 Root

buvHe.png

第二步:并发标记

这一步不会 SOW
buwfy.png

第三步:重新标记

重新标记处理并发标记过程中因为用户程序同时运行而导致标记产生变动的对象的标记记录。stop-the-world。
buoZr.png

第四步:并发清除 Concurrent sweep

buBX5.png

对于 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 区域的回收,因此对于 COPYOLD 区域的对象并不是放在 Root 对象上进行关联的,通过了一种名为 记录集 的东西记录了所有的 OLD 区域对象对新生代对象的集合。

继续回答

对于完成了对象的分配之后,我们只是开辟了一个空间(Allocate the memory space of the object),下一步我们需要的是对对象进行初始化。包括默认值等,执行构造函数等。之后一步才是将这个对象的地址挂到那个命名变量上。

Q: Netty 有哪些优化手段

内存零拷贝

  1. Netty的接收和发送ByteBuffer使用直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用JVM的堆内存进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,然后才写入Socket中。相比于使用直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
  2. Netty的文件传输调用FileRegion包装的transferTo方法,可以直接将文件缓冲区的数据发送到目标Channel,避免通过循环write方式导致的内存拷贝问题。
  3. Netty提供CompositeByteBuf类, 可以将多个ByteBuf合并为一个逻辑上的ByteBuf, 避免了各个ByteBuf之间的拷贝。
  4. 通过wrap操作, 我们可以将byte[]数组、ByteBuf、ByteBuffer等包装成一个Netty ByteBuf对象, 进而避免拷贝操作。
  5. 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 阶段才会触发。
  • 停滞问题:MarkSweepSTW 跟着 Heap的增加,小对象的变多就会越来越慢。

参考