如何运用 JVM 知识提高编程水平

什么是 JVM?

 A Java Virtual Machine(JVM)is an abstract computing machine that enables a computer to run a Java program.

为什么要有 JVM?

跨平台性

 JVM 的存在,使得 Java 程序 能够轻易地在多平台上移植,基本上脱离了对硬件的依赖性(这也满足了 David Parnas 的 “信息隐藏” 准则)

多语言性

 因为底层 JIT 编译优化、高效 GC、JUC 对多线程并发编程的支持,以及社区中海量成熟的库 等优点,使得很多语言都开发出可运行在 JVM 上的版本

 同时,多语言混合编程成为一种趋势,在需要快速开发、灵活部署 和 针对特定问题的 DSL 等场景下,选择恰当的 JVM-hosted language,可以最大化原有代码的价值

 那么,在日常的开发过程中,究竟应该如何运用 JVM 的知识,来逐步提高实际编程水平呢? 上下而求索后,找到了以下几个层面作为出发点

编码层面

递归 vs 尾递归

循环调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def inc(i: Int): Int = i + 1

# 10 亿次 for 循环调用
0: aload_0 | 从局部变量 0 中装载引用类型值
1: getfield #29 // Field i$1:Lscala/runtime/IntRef; | 从对象中获取字段
4: getstatic #33 // Field com/yuzhouwan/jit/Inc$.MODULE$:Lcom/yuzhouwan/jit/Inc$; | 从类中获取静态字段
7: aload_0 | 从局部变量 0 中装载引用类型值
8: getfield #29 // Field i$1:Lscala/runtime/IntRef; | 从对象中获取字段
11: getfield #38 // Field scala/runtime/IntRef.elem:I | 从对象中获取字段

14: invokevirtual #42 // Method com/yuzhouwan/jit/Inc$.inc:(I)I | 运行时按照对象的类来调用实例方法
17: putfield #38 // Field scala/runtime/IntRef.elem:I | 设置对象中字段的值

20: return | 从方法中返回,返回值为 void

# 被调用的累加方法
0: iload_1 | 从局部变量 1 中装载 int 类型值入栈
1: iconst_1 | 1(int) 值入栈
2: iadd | 将栈顶两 int 类型数相加,结果入栈
3: ireturn | 返回 int 类型值

10 亿次循环,大约 4014 ms

递归

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def rec(i: Int): Int = {
if (i == 1) return 1
rec(i - 1) + 1 // change 1 to i, then counting...
}

0: iload_1 | 从局部变量 1 中装载 int 类型值
1: iconst_1 | 1(int) 值入栈
2: if_icmpne 7 | 若栈顶两 int 类型值前小于等于后则跳转
5: iconst_1 | 1(int) 值入栈
6: ireturn | 返回 int 类型值

7: aload_0 | 从局部变量 0 中装载引用类型值
8: iload_1 | 从局部变量 1 中装载 int 类型值
9: iconst_1 | 1(int) 值入栈
10: isub | 将栈顶两 int 类型数相减,结果入栈

11: invokevirtual #24 // Method rec:(I)I | 运行时按照对象的类来调用实例方法
14: iconst_1 | 1(int) 值入栈
15: iadd | 将栈顶两 int 类型数相加,结果入栈
16: ireturn | 返回 int 类型值

1 万次递归,耗时 1 ms,速度低下的同时,超过一定数量(≈14940),还会报错 StackOverflowError

尾递归

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@tailrec
def tailRec(i: Int, iterator: Int): Int =
if (iterator > 0) tailRec(i + 1, iterator - 1) else i

0: iload_2 | 从局部变量 2 中装载 int 类型值入栈
1: iconst_0 | 0(int) 值入栈
2: if_icmple 16 | 若栈顶两 int 类型值前小于等于后则跳转

5: iload_1 | 从局部变量 1 中装载 int 类型值入栈
6: iconst_1 | 1(int) 值入栈
7: iadd | 将栈顶两 int 类型数相加,结果入栈

8: iload_2 | 从局部变量 2 中装载 int 类型值入栈
9: iconst_1 | 1(int) 值入栈
10: isub | 将栈顶两 int 类型数相减,结果入栈

11: istore_2 | 将栈顶 int 类型值保存到局部变量 2
12: istore_1 | 将栈顶 int 类型值保存到局部变量 1
13: goto 0 | 无条件跳转到指定位置

16: iload_1 | 从局部变量 1 中装载 int 类型值入栈
17: ireturn | 返回 int 类型值

10 亿次尾递归,大约 1 ms

 通过以上 Scala 代码 和 对应的 Bytecode 可以分析得出,尾递归 作为递归的一种特殊情况,既保证了 代码的 简洁性

 也因为,尾递归的调用处于函数的末尾,之前函数累计下的所有信息都可以抹除掉。不需要像递归一样,在每次调用之后,都要存储 寄存器值、返回地址 等信息,从而可以避免 栈空间上的消耗,即同时保证了程序的 高效性

Tips: Full code is here.

并发编程

ReentrantReadWriteLock 锁的公平性

 提到并发编程,里面可以涉及的东西,包括 线程、锁、多线程、互斥同步、并行、并发(模型)、线程安全、内存模型 等等,足以写成好几本书。但是,这里我们只就一点来讨论,ReentrantReadWriteLock 锁的公平性

 首先,我们需要明确 公平锁(Fair)和 非公平锁(Nonfair)两者在锁机制上有什么区别?前者,加锁前检查是否有排队等待的线程,优先执行已经在排队等待的线程,保证先来先得;后者,则是加锁时不考虑队列中等待的线程,直接尝试获取锁,获取不到再加到队尾等待

 因此,重入读写锁默认的 非公平锁,可以避免 ReentrantLock 独占锁带来的吞吐量问题

 那么,进一步思考之后,新问题来了:在什么样的场景下,ReentrantReadWriteLock 的公平锁反而会是更佳的选择呢,为什么,又该怎么做?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class ReentrantLockFairness {

static Lock FAIR_LOCK = new ReentrantLock(true);
static Lock UNFAIR_LOCK = new ReentrantLock();

static class Fairness implements Runnable {
private Lock lock;

Fairness(Lock lock) {
this.lock = lock;
}

@Override
public void run() {
for (int i = 0; i < 5; i++) {
lock.lock();
try {
System.out.print(Thread.currentThread().getName());
} finally {
lock.unlock();
}
}
}
}
}

/**
* 公平锁,很少会连续获取到锁
*
* 0 0 0 0 0 1 2 4 3 1 2 4 3 1 2 4 3 1 2 4 3 1 2 4 3
* 0 1 0 1 0 1 0 1 4 0 1 4 4 4 4 2 2 2 2 2 3 3 3 3 3
* 1 0 2 4 3 1 0 2 4 3 1 0 2 4 3 1 0 2 4 3 1 0 2 4 3
*/
@Test
public void fair() throws Exception {
Thread thread;
for (int i = 0; i < 5; i++) {
thread = new Thread(new ReentrantLockFairness.Fairness(ReentrantLockFairness.FAIR_LOCK));
thread.setName(i + " ");
thread.start();
}
Thread.sleep(1000);
}
/**
* 相比公平锁,非公平锁 能经常性地连续获取到锁
*
* 0 0 0 0 0 3 3 3 3 3 1 1 1 1 1 2 2 2 2 2 4 4 4 4 4
* 0 0 2 2 2 2 2 4 4 4 4 4 1 1 1 1 1 0 0 0 3 3 3 3 3
* 1 1 1 1 1 4 4 4 4 4 0 0 0 0 0 2 2 2 2 2 3 3 3 3 3
*/
@Test
public void unfair() throws Exception {
Thread thread;
for (int i = 0; i < 5; i++) {
thread = new Thread(new ReentrantLockFairness.Fairness(ReentrantLockFairness.UNFAIR_LOCK));
thread.setName(i + " ");
thread.start();
}
Thread.sleep(1000);
}

 实际在使用的时候,公平锁 只需要在构造参数中设置即可,内部 AQSAbstractQueuedSynchronizer)中,利用 相对 ALock 而言空间复杂度更低的 CLH 队列锁 来实现公平性

 同时,共享锁 和 独占锁 分别实现了 读写操作,从而读操作之间是没有竞争冲突的,因此 ReentrantReadWriteLock 的最适场景是 读多于写

Tips: Full code is here and here.

synchronized 的性能之争

 在低并发的情况下($threads \lt 4$),高版本的 JDK 里面 synchronized 实现同步的性能更高。超过 15 个线程之后,则建议使用 ReentrantLock 可重入锁进行并发控制

 如果可以使用 ConcurrentHashMap / LongAdder(分段锁)实现的应用场景,则尽量避免使用 synchronized 进行实现

 另一方面,synchonrized 方法适用于重复 “释放锁,又获取锁” 的场景。我们可以利用 synchronized 的方法块使得锁,一直被持有,从而提高性能。例如,下面这个 StringBuffer 的场景,增加了 synchronized 块之后,可以使得性能与 StringBuilder 几乎无异

1
2
3
4
5
6
7
StringBuffer buffer = new StringBuffer();
synchronized (buffer) {
for (int i = 0; i < 9999999; i++) {
buffer.append(i);
buffer.delete(0, buffer.length() - 1);
}
}

Tips: Full code is here.
Tips: ConcurrentHashMap:Java 7 基于分段锁,Java 8 基于 CAS

参数调优层面

TimeSort

描述

 这里,列举技术群(群号见本文末尾)里讨论过的一个问题:ResourceManager crash because TimSort [YARN-4743]

分析

 依据报错信息,可定位出 Yarn 中 MergeSort 存在问题。随后,怀疑是 JDK7 中为了修复 JDK 本身的漏洞:比较器里面比较的两个值时,如果这两个值同时为空的话,则传入的顺序将决定比较的结果,因而破坏了比较器的 “传递性”。从而,使用 TimSort 替换了默认的 MergeSort 增强了 Comparator 的实现约束。更多可参见 Oracle 官网上已修复的 bugs 的归档列表:JDK-6804124 : Replace “modified mergesort” in java.util.Arrays.sort with timsort

方案

 最快的解决方式,便是屏蔽 JDK7 中新增的 “传递性” 检查(其实是一种倒退)。具体方式为,在 JVM 中配置 java.util.Arrays.useLegacyMergeSort=true,或者,在程序中设置对应的环境变量 System.setProperty("java.util.Arrays.useLegacyMergeSort", "true")

补充

 最终,该问题在 Hadoop3.x 中得到解决,官方修复了传递性问题,使其满足 TimSort 的规范

G1GC

 详见:《ZooKeeper 原理与优化 - G1GC

LongAdder

 详见:《ZooKeeper 原理与优化 - LongAdder

AlwaysPreTouch

 详见:《ZooKeeper 原理与优化 - swappiness

语种层面

 在不同应用场景下,我们要如何选择出合适的 JVM 语言,来满足各种各样的开发需求呢?

 一方面,我们可能需要 Java、Scala 此类静态编译的语言,通过编译时检查,来保证代码的安全性和一致性;而另一方面,则希望能够借助 JPython、Groovy 此类的动态语言,快速开发出我们想要的功能。下面,我们举两个比较常见的场景

Scala

 其一,利用 Scala 的表达性来编写 Scala Tester,使得单元测试的代码,更具有可读性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
object DefineA {

def main(args: Array[String]) {
bigMistake()
}

@DefineAnnotation
def bigMistake(): Unit = {
println("bigMistake...")
}
}

abstract class UnitTestStyle extends FlatSpec
with Matchers with OptionValues with Inside with Inspectors

class DefineATest extends UnitTestStyle {

"DefineA's bigMistake method" should "output a information" in {
new DefineA
}
}

Tips: Full code is here.

Groovy

 其二,利用 Groovy 的简洁语法、开放类特性等,来完成 DSL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
def invokeMethod(String name, args) {
print "<${name}"
args.each {
arg ->
if (arg instanceof Map) {
arg.each {
print " ${it.key} ='${it.value}' "
}
} else if (arg instanceof Closure) {
print '>'
arg.delegate = this
def value = arg.call()
if (value) {
print "${value}"
}
}
}
println "</${name}>"
}
html {
head {
meta {
}
}
body {
table(style: 'margin:2px;') {
tr('class': 'trClass', style: 'padding:2px;') {
td { 'http://' }
td { 'yuzhouwan.' }
td { 'com' }
}
}
}
}

内部机制

实时 GC 的原理

 首先,需要明确定义什么是实时 GC(RTGC,Real-time Collection),即真正的 RTGC 要求对 GC 中 Mutator 赋值器的中断进行精确的控制

 常见的回收器 和 实时 GC 调度策略,有如下实现:

回收器 Collector 工作机制
万物静止式回收器 Stop-the-world collector Mutator 在内存分配,发现内存不足时,发起 GC
增量式回收器 Incremental collector 要求 Mutator 不仅在内存分配时,还需要在 访问堆(利用 “读写屏障” 的部分有序 来提高性能)的时候检查是否需要发起 GC
并发回收器 Concurrent collector GC 与 Mutator 的工作 并发执行
GC 调度 Scheduling 调度机制
基于工作的调度 Work-based scheduling 按照 Mutator 的每个工作单元分配 GC 任务
基于间隙的调度 Slack-based scheduling 实时任务的调度间隙完成 GC
基于时间的调度 Time-based scheduling 预留出独占式的 GC 时间
整合式的调度 税收与开支 策略 Tax-and-Spend 允许不同的线程可以有着不同的 Mutator 使用率,通过线程分配的弹性,尽可能地减少线程中断

 实际上,对吞吐量的提高和时间窗口、延时的缩小,在 Tax-and-Spend 调度策略应用到 Metronome 回收器之后,得以证实

沙箱

(利用 StarUML™ 绘制而成)

ClassLoader 隔离

 初步了解 JVM 沙箱的原理之后,我们便可以尝试解决一些安全性相关的问题了。下面以大型项目中,经常会遇到的依赖冲突问题为例。介绍一种以 ClassLoader 隔离来解决该问题的方式

场景描述

 Apache Druid 中 com.sun.jersey(1.19)和 Dataflow 中 org.glassfish.jersey(2.25.1)冲突问题

Tips: Dataflow 是自研的、类似于 Kafka Connect 的框架

解决方案

方案一

 Apache Druid 中升级 Jersey(但是大面积改动开源社区的代码,不利于后期升级与维护)

方案二

 降级 Dataflow 中的 Jersey(后续再接入其他的组件,Jersey 又不一样了,又该如何协调;降级后,可能部分功能不支持;改造的工作量也不小)

方案三

 使用其他的 RESTful 组件(Dropwizard / Ninja / Play / RestExpress / Restlet / Restx / SpringBoot / RestEasy 等)

 简单调研后,首选 RestEasy,不像 SpringBoot 那么重,比 Jersey 性能更好

方案四

 把 Dataflow 里面的依赖,都用 shade 隐藏起来,可以彻底解决和其他接入组件的依赖冲突的问题,避免后续又发现除了 Jersey 其他的冲突

 同时,因为 glassfish 里面存在类似 public static class Builder implements javax.ws.rs.client.Invocation.Builder 全限定名的写法,是无法被 shade 的,所以只能 shade 去隐藏 com.sun.jersey

Tips: Shade 插件的具体使用方法,详见《Maven 高级玩法

方案五

 使用 ClassLoader 或者 OSGi 进行隔离(同时,还可以将 Dataflow 里面的 K2H / K2ES / K2D 组件做成 热部署

 另外,前不久刚 release 的 Java 9 中的模块化,也可以完成组件隔离的工作。不过,考虑到需要保证生产级应用的 SLA 要求,暂不采用(截止 2018-6-5 JDK 9 最新 release 版本为 v9.0.4)

优缺点

优势 劣势
ClassLoader 实现简单 复杂功能,实现相对困难
OSGi OSGi 规范有很多成熟的框架;
轻松实现模块化,插件式加载卸载组件;
支持热部署
框架过重,容易引入风险
Java 9 Module 从 JVM 内核层面对模块化进行支持 目前尚未成熟

编码实战

组件拆分

Tips: 如何利用 assembly 插件对项目进行组件拆分,详见我的另一篇博客《Maven 高级玩法

判断是否为父子类关系
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 如果是由不同的 ClassLoader 加载的,即使是父子类关系,仍然无法做类型转换
// 不过可以通过 序列化 / 反序列化 的方式,绕过这个问题
assertEquals(true, A.class.isAssignableFrom(B.class));
assertEquals(true, B.class.isAssignableFrom(C.class));
assertEquals(true, A.class.isAssignableFrom(C.class));
assertEquals(true, A.class.getClassLoader().equals(B.class.getClassLoader()));
assertEquals(true, B.class.getClassLoader().equals(C.class.getClassLoader()));

interface A {
}
abstract class B implements A {
}
class C extends B {
}

Tips: Full code is here.

子类强转为父类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
boolean failed = false;
try {
B b = (B) C.class.newInstance();
} catch (Exception e) {
failed = true;
}
assertEquals(false, failed);
failed = false;
try {
C c = (C) B.class.newInstance();
} catch (Exception e) {
failed = true;
}
assertEquals(true, failed);

Tips: Full code is here.

对不同 ClassLoader 下的类进行类型强转
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
currentThread().setContextClassLoader(newClassLoader);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(clazz.newInstance());
try (ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bais) {
@Override
protected Class<?> resolveClass(ObjectStreamClass desc) {
String name = desc.getName();
try {
// 这里需要覆写 resolveClass 方法,否则默认会在 readObject 的时候,
// 用 sun.misc.VM#latestUserDefinedLoader 对 Class 进行解析判断,
// 并报错 “无法进行类型强转”
return Class.forName(name, false, newClassLoader);
} catch (ClassNotFoundException ex) {
throw new RuntimeException(ex);
}
}
}) {
return newClassLoader.loadClass(DataflowBase.class.getName()).cast(ois.readObject());
}
}
自定义 ClassLoader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;

public class PluginClassLoader extends URLClassLoader {

private static final String JAR_POSTFIX = ".jar";
private static final String URL_PROTOCOL_FILE = "file";

static {
ClassLoader.registerAsParallelCapable();
}

public PluginClassLoader(ClassLoader parent, String... jarPaths) {
super(new URL[]{}, parent);
for (String jarPath : jarPaths) loadJar(jarPath);
}

public PluginClassLoader(String... jarPaths) {
super(new URL[]{}, null); // MUST NOT: set parent classLoader as null!
for (String jarPath : jarPaths) loadJar(jarPath);
}

@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// System.out.println(String.format("Loading class: %s...", name));
return super.loadClass(name);
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
return super.findClass(name);
} catch (ClassNotFoundException e) {
return PluginClassLoader.class.getClassLoader().loadClass(name);
}
}

private void loadJar(String dirPath) {
File dir = new File(dirPath);
if (!dir.exists() || !dir.isDirectory()) return;
File[] files;
if ((files = dir.listFiles()) == null) return;
for (File file : files) {
if (file == null || !file.isFile() || !file.getName().endsWith(JAR_POSTFIX)) continue;
try {
this.addURL(file);
} catch (Exception e) {
// e.printStackTrace(); // skip failed url.
}
}
}

private void addURL(File file) {
try {
super.addURL(new URL(URL_PROTOCOL_FILE, null, file.getCanonicalPath()));
} catch (Exception e) {
throw new RuntimeException(String.format("Cannot add url [%s]!", file.getAbsolutePath()), e);
}
}
}
主体逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// 通过 ClassLoader 的反射进行对象初始化
// 并使用对象流,避免不同 ClassLoader 之间类型转换的问题
(String type) -> AccessController.<AbstractBean>doPrivileged(
(PrivilegedAction) () -> {
ClassLoader old = currentThread().getContextClassLoader();
try {
Class<? extends AbstractBean> parse = (Class<? extends AbstractBean>) AbstractBean.parse(type);
final PluginClassLoader plugin = PluginUtils.CLASSLOADERS.get(type);
currentThread().setContextClassLoader(plugin);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(parse.newInstance());
try (ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bais) {
@Override
protected Class<?> resolveClass(ObjectStreamClass desc) {
String name = desc.getName();
try {
return Class.forName(name, false, plugin);
} catch (ClassNotFoundException ex) {
throw new FmException(ex);
}
}
}) {
Object readObj = ois.readObject();
return (AbstractBean) readObj;
return plugin.loadClass(AbstractBean.class.getName()).cast(readObj);
}
}
} catch (InstantiationException | IllegalAccessException | IOException | ClassNotFoundException e) {
throw new RuntimeException(e);
} finally {
if (old != null) currentThread().setContextClassLoader(old);
}
});

// 判断传入的 type 类型是否是合法的
public static Class<?> parse(String type) throws FmException {
Type subType = Type.OTHER;
try {
subType = Type.valueOf(type.toUpperCase(Locale.ENGLISH));
} catch (IllegalArgumentException ignored) {
}
String className = type;
if (!Type.OTHER.equals(subType)) className = subType.getTypeName();
return PluginUtils.parseType(type, className, AbstractBean.class);
}

// 依据 type 类型加载指定目录下的依赖包,并加载对应的 class
@SuppressWarnings("unchecked")
public static <T> Class<?> parseType(String type, String className, Class<T> base) throws FmException {
ClassLoader oldClassLoader = currentThread().getContextClassLoader();
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
try {
PluginClassLoader pc;
if (!CLASSLOADERS.containsKey(type))
pc = new PluginClassLoader(/*oldClassLoader*/systemClassLoader,
getPluginPathByTypeName(type), getCommonPath());
else pc = CLASSLOADERS.get(type);
currentThread().setContextClassLoader(pc);
Class<?> baseClazz = pc.loadClass(base.getName());
Class<?> subClazz = pc.loadClass(className);
if (baseClazz.isAssignableFrom(subClazz)) {
CLASSLOADERS.put(type, pc);
return subClazz;
} else throw new RuntimeException(String.format("Class %s is not a child of %s!", className, base.getName()));
} catch (ClassNotFoundException e) {
throw new RuntimeException("Unable to load type: ".concat(className), e);
}/* finally {
currentThread().setContextClassLoader(oldClassLoader);
}*/
}

Tips: Full code is here.

内存模型

 内存模型中,主要的组成分为 主内存工作内存 两部分(线程访问工作内存,工作内存通过 8 种原子性的操作,来和主内存交互),特性包括 原子性可见性有序性(其中,有序性 由 volatile / synchronized / happens-before 原则 来保证)

(利用 Visio™ 绘制而成)


 相信熟知 JVM 相关知识,对于提升日常编程水平,还有很多方面可以探索。限于本人有限的水平,暂时只能总结出以上几点。希望此文能起到抛砖引玉的作用,期待各位精彩的观点和建议 ^_^

欢迎加入我们的技术群,一起交流学习

群名称 群号
人工智能(高级)
人工智能(进阶)
大数据
算法
数据库