This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,265 @@
<audio id="audio" title="第15讲 | synchronized和ReentrantLock有什么区别呢" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bf/95/bfc6d7164fc0535d7aff6219afc6d695.mp3"></audio>
从今天开始我们将进入Java并发学习阶段。软件并发已经成为现代软件开发的基础能力而Java精心设计的高效并发机制正是构建大规模应用的基础之一所以考察并发基本功也成为各个公司面试Java工程师的必选项。
今天我要问你的问题是, synchronized和ReentrantLock有什么区别有人说synchronized最慢这话靠谱吗
## 典型回答
synchronized是Java内建的同步机制所以也有人称其为Intrinsic Locking它提供了互斥的语义和可见性当一个线程已经获取当前锁时其他试图获取的线程只能等待或者阻塞在那里。
在Java 5以前synchronized是仅有的同步手段在代码中 synchronized可以用来修饰方法也可以使用在特定的代码块儿上本质上synchronized方法等同于把方法全部语句用synchronized块包起来。
ReentrantLock通常翻译为再入锁是Java 5提供的锁实现它的语义和synchronized基本相同。再入锁通过代码直接调用lock()方法获取代码书写也更加灵活。与此同时ReentrantLock提供了很多实用的方法能够实现很多synchronized无法做到的细节控制比如可以控制fairness也就是公平性或者利用定义条件等。但是编码中也需要注意必须要明确调用unlock()方法释放,不然就会一直持有该锁。
synchronized和ReentrantLock的性能不能一概而论早期版本synchronized在很多场景下性能相差较大在后续版本进行了较多改进在低竞争场景中表现可能优于ReentrantLock。
## 考点分析
今天的题目是考察并发编程的常见基础题,我给出的典型回答算是一个相对全面的总结。
对于并发编程,不同公司或者面试官面试风格也不一样,有个别大厂喜欢一直追问你相关机制的扩展或者底层,有的喜欢从实用角度出发,所以你在准备并发编程方面需要一定的耐心。
我认为,锁作为并发的基础工具之一,你至少需要掌握:
<li>
理解什么是线程安全。
</li>
<li>
synchronized、ReentrantLock等机制的基本使用与案例。
</li>
更进一步,你还需要:
<li>
掌握synchronized、ReentrantLock底层实现理解锁膨胀、降级理解偏斜锁、自旋锁、轻量级锁、重量级锁等概念。
</li>
<li>
掌握并发包中java.util.concurrent.lock各种不同实现和案例分析。
</li>
## 知识扩展
专栏前面几期穿插了一些并发的概念,有同学反馈理解起来有点困难,尤其对一些并发相关概念比较陌生,所以在这一讲,我也对会一些基础的概念进行补充。
首先,我们需要理解什么是线程安全。
我建议阅读Brain Goetz等专家撰写的《Java并发编程实战》Java Concurrency in Practice虽然可能稍显学究但不可否认这是一本非常系统和全面的Java并发编程书籍。按照其中的定义线程安全是一个多线程环境下正确性的概念也就是保证多线程环境下**共享的**、**可修改的**状态的正确性,这里的状态反映在程序中其实可以看作是数据。
换个角度来看,如果状态不是共享的,或者不是可修改的,也就不存在线程安全问题,进而可以推理出保证线程安全的两个办法:
<li>
封装:通过封装,我们可以将对象内部状态隐藏、保护起来。
</li>
<li>
不可变:还记得我们在[专栏第3讲](http://time.geekbang.org/column/article/6906)强调的final和immutable吗就是这个道理Java语言目前还没有真正意义上的原生不可变但是未来也许会引入。
</li>
线程安全需要保证几个基本特性:
<li>
**原子性**,简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制实现。
</li>
<li>
**可见性**是一个线程修改了某个共享变量其状态能够立即被其他线程知晓通常被解释为将线程本地状态反映到主内存上volatile就是负责保证可见性的。
</li>
<li>
**有序性**,是保证线程内串行语义,避免指令重排等。
</li>
可能有点晦涩,那么我们看看下面的代码段,分析一下原子性需求体现在哪里。这个例子通过取两次数值然后进行对比,来模拟两次对共享状态的操作。
你可以编译并执行可以看到仅仅是两个线程的低度并发就非常容易碰到former和latter不相等的情况。这是因为在两次取值的过程中其他线程可能已经修改了sharedState。
```
public class ThreadSafeSample {
public int sharedState;
public void nonSafeAction() {
while (sharedState &lt; 100000) {
int former = sharedState++;
int latter = sharedState;
if (former != latter - 1) {
System.out.printf(&quot;Observed data race, former is &quot; +
former + &quot;, &quot; + &quot;latter is &quot; + latter);
}
}
}
public static void main(String[] args) throws InterruptedException {
ThreadSafeSample sample = new ThreadSafeSample();
Thread threadA = new Thread(){
public void run(){
sample.nonSafeAction();
}
};
Thread threadB = new Thread(){
public void run(){
sample.nonSafeAction();
}
};
threadA.start();
threadB.start();
threadA.join();
threadB.join();
}
}
```
下面是在我的电脑上的运行结果:
```
C:\&gt;c:\jdk-9\bin\java ThreadSafeSample
Observed data race, former is 13097, latter is 13099
```
将两次赋值过程用synchronized保护起来使用this作为互斥单元就可以避免别的线程并发的去修改sharedState。
```
synchronized (this) {
int former = sharedState ++;
int latter = sharedState;
// …
}
```
如果用javap反编译可以看到类似片段利用monitorenter/monitorexit对实现了同步的语义
```
11: astore_1
12: monitorenter
13: aload_0
14: dup
15: getfield   #2               // Field sharedState:I
18: dup_x1
56: monitorexit
```
我会在下一讲对synchronized和其他锁实现的更多底层细节进行深入分析。
代码中使用synchronized非常便利如果用来修饰静态方法其等同于利用下面代码将方法体囊括进来
```
synchronized (ClassName.class) {}
```
再来看看ReentrantLock。你可能好奇什么是再入它是表示当一个线程试图获取一个它已经获取的锁时这个获取动作就自动成功这是对锁获取粒度的一个概念也就是锁的持有是以线程为单位而不是基于调用次数。Java锁实现强调再入性是为了和pthread的行为进行区分。
再入锁可以设置公平性fairness我们可在创建再入锁时选择是否是公平的。
```
ReentrantLock fairLock = new ReentrantLock(true);
```
这里所谓的公平性是指在竞争场景中,当公平性为真时,会倾向于将锁赋予等待时间最久的线程。公平性是减少线程“饥饿”(个别线程长期等待锁,但始终无法获取)情况发生的一个办法。
如果使用synchronized我们根本**无法进行**公平性的选择其永远是不公平的这也是主流操作系统线程调度的选择。通用场景中公平性未必有想象中的那么重要Java默认的调度策略很少会导致 “饥饿”发生。与此同时,若要保证公平性则会引入额外开销,自然会导致一定的吞吐量下降。所以,我建议**只有**当你的程序确实有公平性需要的时候,才有必要指定它。
我们再从日常编码的角度学习下再入锁。为保证锁释放每一个lock()动作我建议都立即对应一个try-catch-finally典型的代码结构如下这是个良好的习惯。
```
ReentrantLock fairLock = new ReentrantLock(true);// 这里是演示创建公平锁,一般情况不需要。
fairLock.lock();
try {
// do something
} finally {
fairLock.unlock();
}
```
ReentrantLock相比synchronized因为可以像普通对象一样使用所以可以利用其提供的各种便利方法进行精细的同步操作甚至是实现synchronized难以表达的用例
<li>
带超时的获取锁尝试。
</li>
<li>
可以判断是否有线程,或者某个特定线程,在排队等待获取锁。
</li>
<li>
可以响应中断请求。
</li>
<li>
...
</li>
这里我特别想强调**条件变量**java.util.concurrent.Condition如果说ReentrantLock是synchronized的替代选择Condition则是将wait、notify、notifyAll等操作转化为相应的对象将复杂而晦涩的同步操作转变为直观可控的对象行为。
条件变量最为典型的应用场景就是标准类库中的ArrayBlockingQueue等。
我们参考下面的源码,首先,通过再入锁获取条件变量:
```
/** Condition for waiting takes */
private final Condition notEmpty;
/** Condition for waiting puts */
private final Condition notFull;
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity &lt;= 0)
    throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);
notEmpty = lock.newCondition();
notFull =  lock.newCondition();
}
```
两个条件变量是从**同一再入锁**创建出来然后使用在特定操作中如下面的take方法判断和等待条件满足
```
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
    while (count == 0)
        notEmpty.await();
    return dequeue();
} finally {
    lock.unlock();
}
}
```
当队列为空时试图take的线程的正确行为应该是等待入队发生而不是直接返回这是BlockingQueue的语义使用条件notEmpty就可以优雅地实现这一逻辑。
那么怎么保证入队触发后续take操作呢请看enqueue实现
```
private void enqueue(E e) {
// assert lock.isHeldByCurrentThread();
// assert lock.getHoldCount() == 1;
// assert items[putIndex] == null;
final Object[] items = this.items;
items[putIndex] = e;
if (++putIndex == items.length) putIndex = 0;
count++;
notEmpty.signal(); // 通知等待的线程,非空条件已经满足
}
```
通过signal/await的组合完成了条件判断和通知等待线程非常顺畅就完成了状态流转。注意signal和await成对调用非常重要不然假设只有await动作线程会一直等待直到被打断interrupt
从性能角度synchronized早期的实现比较低效对比ReentrantLock大多数场景性能都相差较大。但是在Java 6中对其进行了非常多的改进可以参考性能[对比](https://dzone.com/articles/synchronized-vs-lock)在高竞争情况下ReentrantLock仍然有一定优势。我在下一讲进行详细分析会更有助于理解性能差异产生的内在原因。在大多数情况下无需纠结于性能还是考虑代码书写结构的便利性、可维护性等。
今天作为专栏进入并发阶段的第一讲我介绍了什么是线程安全对比和分析了synchronized和ReentrantLock并针对条件变量等方面结合案例代码进行了介绍。下一讲我将对锁的进阶内容进行源码和案例分析。
## 一课一练
关于今天我们讨论的synchronized和ReentrantLock你做到心中有数了吗思考一下你使用过ReentrantLock中的哪些方法呢分别解决什么问题
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,284 @@
<audio id="audio" title="第16讲 | synchronized底层如何实现什么是锁的升级、降级" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/95/de/954f1b49e90575183558eaca0a55c8de.mp3"></audio>
我在[上一讲](http://time.geekbang.org/column/article/8799)对比和分析了synchronized和ReentrantLock算是专栏进入并发编程阶段的热身相信你已经对线程安全以及如何使用基本的同步机制有了基础今天我们将深入了解synchronize底层机制分析其他锁实现和应用场景。
今天我要问你的问题是 synchronized底层如何实现什么是锁的升级、降级
## 典型回答
在回答这个问题前先简单复习一下上一讲的知识点。synchronized代码块是由一对儿monitorenter/monitorexit指令实现的Monitor对象是同步的基本实现[单元](https://docs.oracle.com/javase/specs/jls/se10/html/jls-8.html#d5e13622)。
在Java 6之前Monitor的实现完全是依靠操作系统内部的互斥锁因为需要进行用户态到内核态的切换所以同步操作是一个无差别的重量级操作。
现代的OracleJDK中JVM对此进行了大刀阔斧地改进提供了三种不同的Monitor实现也就是常说的三种不同的锁偏斜锁Biased Locking、轻量级锁和重量级锁大大改进了其性能。
所谓锁的升级、降级就是JVM优化synchronized运行的机制当JVM检测到不同的竞争状况时会自动切换到适合的锁实现这种切换就是锁的升级、降级。
当没有竞争出现时默认会使用偏斜锁。JVM会利用CAS操作[compare and swap](https://en.wikipedia.org/wiki/Compare-and-swap)在对象头上的Mark Word部分设置线程ID以表示这个对象偏向于当前线程所以并不涉及真正的互斥锁。这样做的假设是基于在很多应用场景中大部分对象生命周期中最多会被一个线程锁定使用偏斜锁可以降低无竞争开销。
如果有另外的线程试图锁定某个已经被偏斜过的对象JVM就需要撤销revoke偏斜锁并切换到轻量级锁实现。轻量级锁依赖CAS操作Mark Word来试图获取锁如果重试成功就使用普通的轻量级锁否则进一步升级为重量级锁。
我注意到有的观点认为Java不会进行锁降级。实际上据我所知锁降级确实是会发生的当JVM进入安全点[SafePoint](http://blog.ragozin.info/2012/10/safepoints-in-hotspot-jvm.html)的时候会检查是否有闲置的Monitor然后试图进行降级。
## 考点分析
今天的问题主要是考察你对Java内置锁实现的掌握也是并发的经典题目。我在前面给出的典型回答涵盖了一些基本概念。如果基础不牢有些概念理解起来就比较晦涩我建议还是尽量理解和掌握即使有不懂的也不用担心在后续学习中还会逐步加深认识。
我个人认为,能够基础性地理解这些概念和机制,其实对于大多数并发编程已经足够了,毕竟大部分工程师未必会进行更底层、更基础的研发,很多时候解决的是知道与否,真正的提高还要靠实践踩坑。
后面我会进一步分析:
<li>
从源码层面稍微展开一些synchronized的底层实现并补充一些上面答案中欠缺的细节有同学反馈这部分容易被问到。如果你对Java底层源码有兴趣但还没有找到入手点这里可以成为一个切入点。
</li>
<li>
理解并发包中java.util.concurrent.lock提供的其他锁实现毕竟Java可不是只有ReentrantLock一种显式的锁类型我会结合代码分析其使用。
</li>
## 知识扩展
我在[上一讲](http://time.geekbang.org/column/article/8799)提到过synchronized是JVM内部的Intrinsic Lock所以偏斜锁、轻量级锁、重量级锁的代码实现并不在核心类库部分而是在JVM的代码中。
Java代码运行可能是解释模式也可能是编译模式如果不记得请复习[专栏第1讲](http://time.geekbang.org/column/article/6845)),所以对应的同步逻辑实现,也会分散在不同模块下,比如,解释器版本就是:
[src/hotspot/share/interpreter/interpreterRuntime.cpp](http://hg.openjdk.java.net/jdk/jdk/file/6659a8f57d78/src/hotspot/share/interpreter/interpreterRuntime.cpp)
为了简化便于理解,我这里会专注于通用的基类实现:
[src/hotspot/share/runtime/](http://hg.openjdk.java.net/jdk/jdk/file/6659a8f57d78/src/hotspot/share/runtime/)
另外请注意链接指向的是最新JDK代码库所以可能某些实现与历史版本有所不同。
首先synchronized的行为是JVM runtime的一部分所以我们需要先找到Runtime相关的功能实现。通过在代码中查询类似“monitor_enter”或“Monitor Enter”很直观的就可以定位到
<li>
[sharedRuntime.cpp](http://hg.openjdk.java.net/jdk/jdk/file/6659a8f57d78/src/hotspot/share/runtime/sharedRuntime.cpp)/hpp它是解释器和编译器运行时的基类。
</li>
<li>
[synchronizer.cpp](https://hg.openjdk.java.net/jdk/jdk/file/896e80158d35/src/hotspot/share/runtime/synchronizer.cpp)/hppJVM同步相关的各种基础逻辑。
</li>
在sharedRuntime.cpp中下面代码体现了synchronized的主要逻辑。
```
Handle h_obj(THREAD, obj);
 if (UseBiasedLocking) {
   // Retry fast entry if bias is revoked to avoid unnecessary inflation
   ObjectSynchronizer::fast_enter(h_obj, lock, true, CHECK);
 } else {
   ObjectSynchronizer::slow_enter(h_obj, lock, CHECK);
 }
```
其实现可以简单进行分解:
- UseBiasedLocking是一个检查因为在JVM启动时我们可以指定是否开启偏斜锁。
偏斜锁并不适合所有应用场景撤销操作revoke是比较重的行为只有当存在较多不会真正竞争的synchronized块儿时才能体现出明显改善。实践中对于偏斜锁的一直是有争议的有人甚至认为当你需要大量使用并发类库时往往意味着你不需要偏斜锁。从具体选择来看我还是建议需要在实践中进行测试根据结果再决定是否使用。
还有一方面是偏斜锁会延缓JIT 预热的进程,所以很多性能测试中会显式地关闭偏斜锁,命令如下:
```
-XX:-UseBiasedLocking
```
- fast_enter是我们熟悉的完整锁获取路径slow_enter则是绕过偏斜锁直接进入轻量级锁获取逻辑。
那么fast_enter是如何实现的呢同样是通过在代码库搜索我们可以定位到synchronizer.cpp。 类似fast_enter这种实现解释器或者动态编译器都是拷贝这段基础逻辑所以如果我们修改这部分逻辑要保证一致性。这部分代码是非常敏感的微小的问题都可能导致死锁或者正确性问题。
```
void ObjectSynchronizer::fast_enter(Handle obj, BasicLock* lock,
                                bool attempt_rebias, TRAPS) {
 if (UseBiasedLocking) {
   if (!SafepointSynchronize::is_at_safepoint()) {
     BiasedLocking::Condition cond = BiasedLocking::revoke_and_rebias(obj, attempt_rebias, THREAD);
     if (cond == BiasedLocking::BIAS_REVOKED_AND_REBIASED) {
       return;
     }
} else {
     assert(!attempt_rebias, &quot;can not rebias toward VM thread&quot;);
     BiasedLocking::revoke_at_safepoint(obj);
}
   assert(!obj-&gt;mark()-&gt;has_bias_pattern(), &quot;biases should be revoked by now&quot;);
 }
 slow_enter(obj, lock, THREAD);
}
```
我来分析下这段逻辑实现:
<li>
[biasedLocking](http://hg.openjdk.java.net/jdk/jdk/file/6659a8f57d78/src/hotspot/share/runtime/biasedLocking.cpp)定义了偏斜锁相关操作revoke_and_rebias是获取偏斜锁的入口方法revoke_at_safepoint则定义了当检测到安全点时的处理逻辑。
</li>
<li>
如果获取偏斜锁失败则进入slow_enter。
</li>
<li>
这个方法里面同样检查是否开启了偏斜锁,但是从代码路径来看,其实如果关闭了偏斜锁,是不会进入这个方法的,所以算是个额外的保障性检查吧。
</li>
另外,如果你仔细查看[synchronizer.cpp](https://hg.openjdk.java.net/jdk/jdk/file/896e80158d35/src/hotspot/share/runtime/synchronizer.cpp)里会发现不仅仅是synchronized的逻辑包括从本地代码也就是JNI触发的Monitor动作全都可以在里面找到jni_enter/jni_exit
关于[biasedLocking](http://hg.openjdk.java.net/jdk/jdk/file/6659a8f57d78/src/hotspot/share/runtime/biasedLocking.cpp)的更多细节我就不展开了明白它是通过CAS设置Mark Word就完全够用了对象头中Mark Word的结构可以参考下图
<img src="https://static001.geekbang.org/resource/image/b1/fc/b1221c308d2aaf13d0d677033ee406fc.png" alt="" />
顺着锁升降级的过程分析下去,偏斜锁到轻量级锁的过程是如何实现的呢?
我们来看看slow_enter到底做了什么。
```
void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {
 markOop mark = obj-&gt;mark();
 if (mark-&gt;is_neutral()) {
      // 将目前的Mark Word复制到Displaced Header上
lock-&gt;set_displaced_header(mark);
// 利用CAS设置对象的Mark Word
   if (mark == obj()-&gt;cas_set_mark((markOop) lock, mark)) {
     TEVENT(slow_enter: release stacklock);
     return;
   }
   // 检查存在竞争
 } else if (mark-&gt;has_locker() &amp;&amp;
            THREAD-&gt;is_lock_owned((address)mark-&gt;locker())) {
// 清除
   lock-&gt;set_displaced_header(NULL);
   return;
 }
 // 重置Displaced Header
 lock-&gt;set_displaced_header(markOopDesc::unused_mark());
 ObjectSynchronizer::inflate(THREAD,
                          obj(),
                             inflate_cause_monitor_enter)-&gt;enter(THREAD);
}
```
请结合我在代码中添加的注释,来理解如何从试图获取轻量级锁,逐步进入锁膨胀的过程。你可以发现这个处理逻辑,和我在这一讲最初介绍的过程是十分吻合的。
<li>
设置Displaced Header然后利用cas_set_mark设置对象Mark Word如果成功就成功获取轻量级锁。
</li>
<li>
否则Displaced Header然后进入锁膨胀阶段具体实现在inflate方法中。
</li>
今天就不介绍膨胀的细节了,我这里提供了源代码分析的思路和样例,考虑到应用实践,再进一步增加源代码解读意义不大,有兴趣的同学可以参考我提供的[synchronizer.cpp](http://hg.openjdk.java.net/jdk/jdk/file/896e80158d35/src/hotspot/share/runtime/synchronizer.cpp)链接,例如:
<li>
**deflate_idle_monitors**是分析**锁降级**逻辑的入口这部分行为还在进行持续改进因为其逻辑是在安全点内运行处理不当可能拖长JVM停顿STWstop-the-world的时间。
</li>
<li>
fast_exit或者slow_exit是对应的锁释放逻辑。
</li>
前面分析了synchronized的底层实现理解起来有一定难度下面我们来看一些相对轻松的内容。 我在上一讲对比了synchronized和ReentrantLockJava核心类库中还有其他一些特别的锁类型具体请参考下面的图。
<img src="https://static001.geekbang.org/resource/image/f5/11/f5753a4695fd771f8178120858086811.png" alt="" />
你可能注意到了这些锁竟然不都是实现了Lock接口ReadWriteLock是一个单独的接口它通常是代表了一对儿锁分别对应只读和写操作标准类库中提供了再入版本的读写锁实现ReentrantReadWriteLock对应的语义和ReentrantLock比较相似。
StampedLock竟然也是个单独的类型从类图结构可以看出它是不支持再入性的语义的也就是它不是以持有锁的线程为单位。
为什么我们需要读写锁ReadWriteLock等其他锁呢
这是因为虽然ReentrantLock和synchronized简单实用但是行为上有一定局限性通俗点说就是“太霸道”要么不占要么独占。实际应用场景中有的时候不需要大量竞争的写操作而是以并发读取为主如何进一步优化并发操作的粒度呢
Java并发包提供的读写锁等扩展了锁的能力它所基于的原理是多个读操作是不需要互斥的因为读操作并不会更改数据所以不存在互相干扰。而写操作则会导致并发一致性的问题所以写线程之间、读写线程之间需要精心设计的互斥逻辑。
下面是一个基于读写锁实现的数据结构,当数据量较大,并发读多、并发写少的时候,能够比纯同步版本凸显出优势。
```
public class RWSample {
private final Map&lt;String, String&gt; m = new TreeMap&lt;&gt;();
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
public String get(String key) {
    r.lock();
    System.out.println(&quot;读锁锁定!&quot;);
    try {
        return m.get(key);
    } finally {
        r.unlock();
    }
}
public String put(String key, String entry) {
    w.lock();
System.out.println(&quot;写锁锁定!&quot;);
    try {
        return m.put(key, entry);
    } finally {
        w.unlock();
    }
}
// …
}
```
在运行过程中,如果读锁试图锁定时,写锁是被某个线程持有,读锁将无法获得,而只好等待对方操作结束,这样就可以自动保证不会读取到有争议的数据。
读写锁看起来比synchronized的粒度似乎细一些但在实际应用中其表现也并不尽如人意主要还是因为相对比较大的开销。
所以JDK在后期引入了StampedLock在提供类似读写锁的同时还支持优化读模式。优化读基于假设大多数情况下读操作并不会和写操作冲突其逻辑是先试着读然后通过validate方法确认是否进入了写模式如果没有进入就成功避免了开销如果进入则尝试获取读锁。请参考我下面的样例代码。
```
public class StampedSample {
private final StampedLock sl = new StampedLock();
void mutate() {
    long stamp = sl.writeLock();
    try {
        write();
    } finally {
        sl.unlockWrite(stamp);
    }
}
Data access() {
    long stamp = sl.tryOptimisticRead();
    Data data = read();
    if (!sl.validate(stamp)) {
        stamp = sl.readLock();
        try {
            data = read();
        } finally {
            sl.unlockRead(stamp);
        }
    }
    return data;
}
// …
}
```
注意这里的writeLock和unLockWrite一定要保证成对调用。
你可能很好奇这些显式锁的实现机制Java并发包内的各种同步工具不仅仅是各种Lock其他的如[Semaphore](https://docs.oracle.com/javase/10/docs/api/java/util/concurrent/Semaphore.html)、[CountDownLatch](https://docs.oracle.com/javase/10/docs/api/java/util/concurrent/CountDownLatch.html),甚至是早期的[FutureTask](https://docs.oracle.com/javase/10/docs/api/java/util/concurrent/FutureTask.html)等,都是基于一种[AQS](https://docs.oracle.com/javase/10/docs/api/java/util/concurrent/locks/AbstractQueuedSynchronizer.html)框架。
今天我全面分析了synchronized相关实现和内部运行机制简单介绍了并发包中提供的其他显式锁并结合样例代码介绍了其使用方法希望对你有所帮助。
## 一课一练
关于今天我们讨论的你做到心中有数了吗?思考一个问题,你知道“自旋锁”是做什么的吗?它的使用场景是什么?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,211 @@
<audio id="audio" title="第17讲 | 一个线程两次调用start()方法会出现什么情况?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8d/94/8deca6c623d51354ba32124922a06d94.mp3"></audio>
今天我们来深入聊聊线程相信大家对于线程这个概念都不陌生它是Java并发的基础元素理解、操纵、诊断线程是Java工程师的必修课但是你真的掌握线程了吗
今天我要问你的问题是一个线程两次调用start()方法会出现什么情况?谈谈线程的生命周期和状态转移。
## 典型回答
Java的线程是不允许启动两次的第二次调用必然会抛出IllegalThreadStateException这是一种运行时异常多次调用start被认为是编程错误。
关于线程生命周期的不同状态在Java 5以后线程状态被明确定义在其公共内部枚举类型java.lang.Thread.State中分别是
<li>
新建NEW表示线程被创建出来还没真正启动的状态可以认为它是个Java内部状态。
</li>
<li>
就绪RUNNABLE表示该线程已经在JVM中执行当然由于执行需要计算资源它可能是正在运行也可能还在等待系统分配给它CPU片段在就绪队列里面排队。
</li>
<li>
在其他一些分析中会额外区分一种状态RUNNING但是从Java API的角度并不能表示出来。
</li>
<li>
阻塞BLOCKED这个状态和我们前面两讲介绍的同步非常相关阻塞表示线程在等待Monitor lock。比如线程试图通过synchronized去获取某个锁但是其他线程已经独占了那么当前线程就会处于阻塞状态。
</li>
<li>
等待WAITING表示正在等待其他线程采取某些操作。一个常见的场景是类似生产者消费者模式发现任务条件尚未满足就让当前消费者线程等待wait另外的生产者线程去准备任务数据然后通过类似notify等动作通知消费线程可以继续工作了。Thread.join()也会令线程进入等待状态。
</li>
<li>
计时等待TIMED_WAIT其进入条件和等待状态类似但是调用的是存在超时条件的方法比如wait或join等方法的指定超时版本如下面示例
</li>
```
public final native void wait(long timeout) throws InterruptedException;
```
- 终止TERMINATED不管是意外退出还是正常执行结束线程已经完成使命终止运行也有人把这个状态叫作死亡。
在第二次调用start()方法的时候线程可能处于终止或者其他非NEW状态但是不论如何都是不可以再次启动的。
## 考点分析
今天的问题可以算是个常见的面试热身题目,前面的给出的典型回答,算是对基本状态和简单流转的一个介绍,如果觉得还不够直观,我在下面分析会对比一个状态图进行介绍。总的来说,理解线程对于我们日常开发或者诊断分析,都是不可或缺的基础。
面试官可能会以此为契机,从各种不同角度考察你对线程的掌握:
<li>
相对理论一些的面试官可以会问你线程到底是什么以及Java底层实现方式。
</li>
<li>
线程状态的切换,以及和锁等并发工具类的互动。
</li>
<li>
线程编程时容易踩的坑与建议等。
</li>
可以看出,仅仅是一个线程,就有非常多的内容需要掌握。我们选择重点内容,开始进入详细分析。
## 知识扩展
首先,我们来整体看一下线程是什么?
从操作系统的角度可以简单认为线程是系统调度的最小单元一个进程可以包含多个线程作为任务的真正运作者有自己的栈Stack、寄存器Register、本地存储Thread Local但是会和进程内其他线程共享文件描述符、虚拟地址空间等。
在具体实现中线程还分为内核线程、用户线程Java的线程实现其实是与虚拟机相关的。对于我们最熟悉的Sun/Oracle JDK其线程也经历了一个演进过程基本上在Java 1.2之后JDK已经抛弃了所谓的[Green Thread](https://en.wikipedia.org/wiki/Green_threads),也就是用户调度的线程,现在的模型是一对一映射到操作系统内核线程。
如果我们来看Thread的源码你会发现其基本操作逻辑大都是以JNI形式调用的本地代码。
```
private native void start0();
private native void setPriority0(int newPriority);
private native void interrupt0();
```
这种实现有利有弊总体上来说Java语言得益于精细粒度的线程和相关的并发操作其构建高扩展性的大型应用的能力已经毋庸置疑。但是其复杂性也提高了并发编程的门槛近几年的Go语言等提供了协程[coroutine](https://en.wikipedia.org/wiki/Coroutine)大大提高了构建并发应用的效率。于此同时Java也在[Loom](http://openjdk.java.net/projects/loom/)项目中孕育新的类似轻量级用户线程Fiber等机制也许在不久的将来就可以在新版JDK中使用到它。
下面,我来分析下线程的基本操作。如何创建线程想必你已经非常熟悉了,请看下面的例子:
```
Runnable task = () -&gt; {System.out.println(&quot;Hello World!&quot;);};
Thread myThread = new Thread(task);
myThread.start();
myThread.join();
```
我们可以直接扩展Thread类然后实例化。但在本例中我选取了另外一种方式就是实现一个Runnable将代码逻放在Runnable中然后构建Thread并启动start等待结束join
Runnable的好处是不会受Java不支持类多继承的限制重用代码实现当我们需要重复执行相应逻辑时优点明显。而且也能更好的与现代Java并发库中的Executor之类框架结合使用比如将上面start和join的逻辑完全写成下面的结构
```
Future future = Executors.newFixedThreadPool(1)
.submit(task)
.get();
```
这样我们就不用操心线程的创建和管理也能利用Future等机制更好地处理执行结果。线程生命周期通常和业务之间没有本质联系混淆实现需求和业务需求就会降低开发的效率。
从线程生命周期的状态开始展开那么在Java编程中有哪些因素可能影响线程的状态呢主要有
<li>
线程自身的方法除了start还有多个join方法等待线程结束yield是告诉调度器主动让出CPU另外就是一些已经被标记为过时的resume、stop、suspend之类据我所知在JDK最新版本中destory/stop方法将被直接移除。
</li>
<li>
基类Object提供了一些基础的wait/notify/notifyAll方法。如果我们持有某个对象的Monitor锁调用wait会让当前线程处于等待状态直到其他线程notify或者notifyAll。所以本质上是提供了Monitor的获取和释放的能力是基本的线程间通信方式。
</li>
<li>
并发类库中的工具比如CountDownLatch.await()会让当前线程进入等待状态直到latch被基数为0这可以看作是线程间通信的Signal。
</li>
我这里画了一个状态和方法之间的对应图:
<img src="https://static001.geekbang.org/resource/image/31/dc/3169b7ca899afeb0359f132fb77c29dc.png" alt="" />
Thread和Object的方法听起来简单但是实际应用中被证明非常晦涩、易错这也是为什么Java后来又引入了并发包。总的来说有了并发包大多数情况下我们已经不再需要去调用wait/notify之类的方法了。
前面谈了不少理论下面谈谈线程API使用我会侧重于平时工作学习中容易被忽略的一些方面。
先来看看守护线程Daemon Thread有的时候应用中需要一个长期驻留的服务程序但是不希望其影响应用退出就可以将其设置为守护线程如果JVM发现只有守护线程存在时将结束进程具体可以参考下面代码段。**注意,必须在线程启动之前设置。**
```
Thread daemonThread = new Thread();
daemonThread.setDaemon(true);
daemonThread.start();
```
再来看看[Spurious wakeup](https://en.wikipedia.org/wiki/Spurious_wakeup)。尤其是在多核CPU的系统中线程等待存在一种可能就是在没有任何线程广播或者发出信号的情况下线程就被唤醒如果处理不当就可能出现诡异的并发问题所以我们在等待条件过程中建议采用下面模式来书写。
```
// 推荐
while ( isCondition()) {
waitForAConfition(...);
}
// 不推荐可能引入bug
if ( isCondition()) {
waitForAConfition(...);
}
```
Thread.onSpinWait()这是Java 9中引入的特性。我在[专栏第16讲](http://time.geekbang.org/column/article/9042)给你留的思考题中提到“自旋锁”spin-wait, busy-waiting也可以认为其不算是一种锁而是一种针对短期等待的性能优化技术。“onSpinWait()”没有任何行为上的保证而是对JVM的一个暗示JVM可能会利用CPU的pause指令进一步提高性能性能特别敏感的应用可以关注。
再有就是慎用[ThreadLocal](https://docs.oracle.com/javase/9/docs/api/java/lang/ThreadLocal.html)这是Java提供的一种保存线程私有信息的机制因为其在整个线程生命周期内有效所以可以方便地在一个线程关联的不同业务模块之间传递信息比如事务ID、Cookie等上下文相关信息。
它的实现结构,可以参考[源码](http://hg.openjdk.java.net/jdk/jdk/file/ee8524126794/src/java.base/share/classes/java/lang/ThreadLocal.java)数据存储于线程相关的ThreadLocalMap其内部条目是弱引用如下面片段。
```
static class ThreadLocalMap {
static class Entry extends WeakReference&lt;ThreadLocal&lt;?&gt;&gt; {
    /** The value associated with this ThreadLocal. */
    Object value;
    Entry(ThreadLocal&lt;?&gt; k, Object v) {
        super(k);
value = v;
    }
     }
  // …
}
```
当Key为null时该条目就变成“废弃条目”相关“value”的回收往往依赖于几个关键点即set、remove、rehash。
下面是set的示例我进行了精简和注释
```
private void set(ThreadLocal&lt;?&gt; key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode &amp; (len-1);
for (Entry e = tab[i];; …) {
    //…
    if (k == null) {
// 替换废弃条目
        replaceStaleEntry(key, value, i);
        return;
    }
      }
tab[i] = new Entry(key, value);
int sz = ++size;
//  扫描并清理发现的废弃条目,并检查容量是否超限
if (!cleanSomeSlots(i, sz) &amp;&amp; sz &gt;= threshold)
    rehash();// 清理废弃条目,如果仍然超限,则扩容(加倍)
}
```
具体的清理逻辑是实现在cleanSomeSlots和expungeStaleEntry之中如果你有兴趣可以自行阅读。
结合[专栏第4讲](http://time.geekbang.org/column/article/6970)介绍的引用类型我们会发现一个特别的地方通常弱引用都会和引用队列配合清理机制使用但是ThreadLocal是个例外它并没有这么做。
这意味着,废弃项目的回收**依赖于显式地触发,否则就要等待线程结束**进而回收相应ThreadLocalMap这就是很多OOM的来源所以通常都会建议应用一定要自己负责remove并且不要和线程池配合因为worker线程往往是不会退出的。
今天我介绍了线程基础分析了生命周期中的状态和各种方法之间的对应关系这也有助于我们更好地理解synchronized和锁的影响并介绍了一些需要注意的操作希望对你有所帮助。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗今天我准备了一个有意思的问题写一个最简单的打印HelloWorld的程序说说看运行这个应用Java至少会创建几个线程呢然后思考一下如何明确验证你的结论真实情况很可能令你大跌眼镜哦。
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,230 @@
<audio id="audio" title="第18讲 | 什么情况下Java程序会产生死锁如何定位、修复" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/09/dd/0948d7f4d81a344f455cf7d784f2b0dd.mp3"></audio>
今天,我会介绍一些日常开发中类似线程死锁等问题的排查经验,并选择一两个我自己修复过或者诊断过的核心类库死锁问题作为例子,希望不仅能在面试时,包括在日常工作中也能对你有所帮助。
今天我要问你的问题是什么情况下Java程序会产生死锁如何定位、修复
## 典型回答
死锁是一种特定的程序状态,在实体之间,由于循环依赖导致彼此一直处于等待之中,没有任何个体可以继续前进。死锁不仅仅是在线程之间会发生,存在资源独占的进程之间同样也可能出现死锁。通常来说,我们大多是聚焦在多线程场景中的死锁,指两个或多个线程之间,由于互相持有对方需要的锁,而永久处于阻塞的状态。
你可以利用下面的示例图理解基本的死锁问题:
<img src="https://static001.geekbang.org/resource/image/ea/6c/ea88719ec112dead21334034c9ef8a6c.png" alt="" />
定位死锁最常见的方式就是利用jstack等工具获取线程栈然后定位互相之间的依赖关系进而找到死锁。如果是比较明显的死锁往往jstack等就能直接定位类似JConsole甚至可以在图形界面进行有限的死锁检测。
如果程序运行时发生了死锁,绝大多数情况下都是无法在线解决的,只能重启、修正程序本身问题。所以,代码开发阶段互相审查,或者利用工具进行预防性排查,往往也是很重要的。
## 考点分析
今天的问题偏向于实用场景大部分死锁本身并不难定位掌握基本思路和工具使用理解线程相关的基本概念比如各种线程状态和同步、锁、Latch等并发工具就已经足够解决大多数问题了。
针对死锁,面试官可以深入考察:
<li>
抛开字面上的概念,让面试者写一个可能死锁的程序,顺便也考察下基本的线程编程。
</li>
<li>
诊断死锁有哪些工具如果是分布式环境可能更关心能否用API实现吗
</li>
<li>
后期诊断死锁还是挺痛苦的,经常加班,如何在编程中尽量避免一些典型场景的死锁,有其他工具辅助吗?
</li>
## 知识扩展
在分析开始之前先以一个基本的死锁程序为例我在这里只用了两个嵌套的synchronized去获取锁具体如下
```
public class DeadLockSample extends Thread {
private String first;
private String second;
public DeadLockSample(String name, String first, String second) {
    super(name);
    this.first = first;
    this.second = second;
}
public  void run() {
    synchronized (first) {
        System.out.println(this.getName() + &quot; obtained: &quot; + first);
        try {
            Thread.sleep(1000L);
            synchronized (second) {
                System.out.println(this.getName() + &quot; obtained: &quot; + second);
            }
        } catch (InterruptedException e) {
            // Do nothing
        }
    }
}
public static void main(String[] args) throws InterruptedException {
    String lockA = &quot;lockA&quot;;
    String lockB = &quot;lockB&quot;;
    DeadLockSample t1 = new DeadLockSample(&quot;Thread1&quot;, lockA, lockB);
    DeadLockSample t2 = new DeadLockSample(&quot;Thread2&quot;, lockB, lockA);
    t1.start();
    t2.start();
    t1.join();
    t2.join();
}
}
```
这个程序编译执行后几乎每次都可以重现死锁请看下面截取的输出。另外这里有个比较有意思的地方为什么我先调用Thread1的start但是Thread2却先打印出来了呢这就是因为线程调度依赖于操作系统调度器虽然你可以通过优先级之类进行影响但是具体情况是不确定的。
<img src="https://static001.geekbang.org/resource/image/86/0e/869f3a3d7b759fbfb794f8c81047f30e.png" alt="" />
下面来模拟问题定位我就选取最常见的jstack其他一些类似JConsole等图形化的工具请自行查找。
首先可以使用jps或者系统的ps命令、任务管理器等工具确定进程ID。
其次调用jstack获取线程栈
```
${JAVA_HOME}\bin\jstack your_pid
```
然后,分析得到的输出,具体片段如下:
<img src="https://static001.geekbang.org/resource/image/1f/8b/1fcc1a521b801a5f7428d5229525a38b.png" alt="" />
最后结合代码分析线程栈信息。上面这个输出非常明显找到处于BLOCKED状态的线程按照试图获取waiting的锁ID请看我标记为相同颜色的数字查找很快就定位问题。 jstack本身也会把类似的简单死锁抽取出来直接打印出来。
在实际应用中,类死锁情况未必有如此清晰的输出,但是总体上可以理解为:
**区分线程状态 -&gt; 查看等待目标 -&gt; 对比Monitor等持有状态**
所以,理解线程基本状态和并发相关元素是定位问题的关键,然后配合程序调用栈结构,基本就可以定位到具体的问题代码。
如果我们是开发自己的管理工具需要用更加程序化的方式扫描服务进程、定位死锁可以考虑使用Java提供的标准管理API[ThreadMXBean](https://docs.oracle.com/javase/9/docs/api/java/lang/management/ThreadMXBean.html#findDeadlockedThreads--)其直接就提供了findDeadlockedThreads()方法用于定位。为方便说明我修改了DeadLockSample请看下面的代码片段。
```
public static void main(String[] args) throws InterruptedException {
ThreadMXBean mbean = ManagementFactory.getThreadMXBean();
Runnable dlCheck = new Runnable() {
    @Override
    public void run() {
        long[] threadIds = mbean.findDeadlockedThreads();
        if (threadIds != null) {
                    ThreadInfo[] threadInfos = mbean.getThreadInfo(threadIds);
                    System.out.println(&quot;Detected deadlock threads:&quot;);
            for (ThreadInfo threadInfo : threadInfos) {
                System.out.println(threadInfo.getThreadName());
            }
         }
      }
   };
      ScheduledExecutorService scheduler =Executors.newScheduledThreadPool(1);
      // 稍等5秒然后每10秒进行一次死锁扫描
       scheduler.scheduleAtFixedRate(dlCheck, 5L, 10L, TimeUnit.SECONDS);
// 死锁样例代码…
}
```
重新编译执行,你就能看到死锁被定位到的输出。在实际应用中,就可以据此收集进一步的信息,然后进行预警等后续处理。但是要注意的是,对线程进行快照本身是一个相对重量级的操作,还是要慎重选择频度和时机。
**如何在编程中尽量预防死锁呢?**
首先,我们来总结一下前面例子中死锁的产生包含哪些基本元素。基本上死锁的发生是因为:
<li>
互斥条件类似Java中Monitor都是独占的要么是我用要么是你用。
</li>
<li>
互斥条件是长期持有的,在使用结束之前,自己不会释放,也不能被其他线程抢占。
</li>
<li>
循环依赖关系,两个或者多个个体之间出现了锁的链条环。
</li>
所以,我们可以据此分析可能的避免死锁的思路和方法。
**第一种方法**
如果可能的话尽量避免使用多个锁并且只有需要时才持有锁。否则即使是非常精通并发编程的工程师也难免会掉进坑里嵌套的synchronized或者lock非常容易出问题。
我举个[例子](https://bugs.openjdk.java.net/browse/JDK-8198928) Java NIO的实现代码向来以锁多著称一个原因是其本身模型就非常复杂某种程度上是不得不如此另外是在设计时考虑到既要支持阻塞模式又要支持非阻塞模式。直接结果就是一些基本操作如connect需要操作三个锁以上在最近的一个JDK改进中就发生了死锁现象。
我将其简化为下面的伪代码问题是暴露在HTTP/2客户端中这是个非常现代的反应式风格的API非常推荐学习使用。
```
/// Thread HttpClient-6-SelectorManager:
readLock.lock();
writeLock.lock();
// 持有readLock/writeLock调用close需要获得closeLock
close();
// Thread HttpClient-6-Worker-2 持有closeLock
implCloseSelectableChannel (); //想获得readLock
```
在close发生时 HttpClient-6-SelectorManager线程持有readLock/writeLock试图获得closeLock与此同时另一个HttpClient-6-Worker-2线程持有closeLock试图获得readLock这就不可避免地进入了死锁。
这里比较难懂的地方在于closeLock的持有状态就是我标记为绿色的部分**并没有在线程栈中显示出来**,请参考我在下图中标记的部分。
<img src="https://static001.geekbang.org/resource/image/b7/24/b7961a84838b5429a8f59826b91ed724.png" alt="" /><br />
<br />
更加具体来说,请查看[SocketChannelImpl](http://hg.openjdk.java.net/jdk/jdk/file/ce06058197a4/src/java.base/share/classes/sun/nio/ch/SocketChannelImpl.java)的663行对比implCloseSelectableChannel()方法实现和[AbstractInterruptibleChannel.close()](http://hg.openjdk.java.net/jdk/jdk/file/ce06058197a4/src/java.base/share/classes/java/nio/channels/spi/AbstractInterruptibleChannel.java)在109行的代码这里就不展示代码了。
所以,从程序设计的角度反思,如果我们赋予一段程序太多的职责,出现“既要…又要…”的情况时,可能就需要我们审视下设计思路或目的是否合理了。对于类库,因为其基础、共享的定位,比应用开发往往更加令人苦恼,需要仔细斟酌之间的平衡。
**第二种方法**
如果必须使用多个锁,尽量设计好锁的获取顺序,这个说起来简单,做起来可不容易,你可以参看著名的[银行家算法](https://en.wikipedia.org/wiki/Banker%27s_algorithm)。
一般的情况,我建议可以采取些简单的辅助手段,比如:
- 将对象(方法)和锁之间的关系,用图形化的方式表示分别抽取出来,以今天最初讲的死锁为例,因为是调用了同一个线程所以更加简单。
<img src="https://static001.geekbang.org/resource/image/1e/59/1e23562b6ff34206b11c5ec07608fb59.png" alt="" />
- 然后根据对象之间组合、调用的关系对比和组合,考虑可能调用时序。
<img src="https://static001.geekbang.org/resource/image/ee/75/ee413b86e8775c63e7947955646db975.png" alt="" />
- 按照可能时序合并,发现可能死锁的场景。
<img src="https://static001.geekbang.org/resource/image/9b/e7/9bbad67e205e54e8f7ec8ad37872a9e7.png" alt="" /><br />
<br />
**第三种方法**
使用带超时的方法,为程序带来更多可控性。
类似Object.wait(…)或者CountDownLatch.await(…)都支持所谓的timed_wait我们完全可以就不假定该锁一定会获得指定超时时间并为无法得到锁时准备退出逻辑。
并发Lock实现如ReentrantLock还支持非阻塞式的获取锁操作tryLock()这是一个插队行为barging并不在乎等待的公平性如果执行时对象恰好没有被独占则直接获取锁。有时我们希望条件允许就尝试插队不然就按照现有公平性规则等待一般采用下面的方法
```
if (lock.tryLock() || lock.tryLock(timeout, unit)) {
  // ...
  }
```
**第四种方法**
业界也有一些其他方面的尝试比如通过静态代码分析如FindBugs去查找固定的模式进而定位可能的死锁或者竞争情况。实践证明这种方法也有一定作用请参考[相关文档](https://plugins.jetbrains.com/plugin/3847-findbugs-idea)。
除了典型应用中的死锁场景其实还有一些更令人头疼的死锁比如类加载过程发生的死锁尤其是在框架大量使用自定义类加载时因为往往不是在应用本身的代码库中jstack等工具也不见得能够显示全部锁信息所以处理起来比较棘手。对此Java有[官方文档](https://docs.oracle.com/javase/7/docs/technotes/guides/lang/cl-mt.html)进行了详细解释并针对特定情况提供了相应JVM参数和基本原则。
今天,我从样例程序出发,介绍了死锁产生原因,并帮你熟悉了排查死锁基本工具的使用和典型思路,最后结合实例介绍了实际场景中的死锁分析方法与预防措施,希望对你有所帮助。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗?今天的思考题是,有时候并不是阻塞导致的死锁,只是某个线程进入了死循环,导致其他线程一直等待,这种问题如何诊断呢?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,327 @@
<audio id="audio" title="第19讲 | Java并发包提供了哪些并发工具类" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6b/1e/6bbdab57a6d659804f0202559397761e.mp3"></audio>
通过前面的学习我们一起回顾了线程、锁等各种并发编程的基本元素也逐步涉及了Java并发包中的部分内容相信经过前面的热身我们能够更快地理解Java并发包。
今天我要问你的问题是Java并发包提供了哪些并发工具类
## 典型回答
我们通常所说的并发包也就是java.util.concurrent及其子包集中了Java并发的各种基础工具类具体主要包括几个方面
<li>
提供了比synchronized更加高级的各种同步结构包括CountDownLatch、CyclicBarrier、Semaphore等可以实现更加丰富的多线程操作比如利用Semaphore作为资源控制器限制同时进行工作的线程数量。
</li>
<li>
各种线程安全的容器比如最常见的ConcurrentHashMap、有序的ConcurrentSkipListMap或者通过类似快照机制实现线程安全的动态数组CopyOnWriteArrayList等。
</li>
<li>
各种并发队列实现如各种BlockingQueue实现比较典型的ArrayBlockingQueue、 SynchronousQueue或针对特定场景的PriorityBlockingQueue等。
</li>
<li>
强大的Executor框架可以创建各种不同类型的线程池调度任务运行等绝大部分情况下不再需要自己从头实现线程池和任务调度器。
</li>
## 考点分析
这个题目主要考察你对并发包了解程度,以及是否有实际使用经验。我们进行多线程编程,无非是达到几个目的:
<li>
利用多线程提高程序的扩展能力,以达到业务对吞吐量的要求。
</li>
<li>
协调线程间调度、交互,以完成业务逻辑。
</li>
<li>
线程间传递数据和状态,这同样是实现业务逻辑的需要。
</li>
所以,这道题目只能算作简单的开始,往往面试官还会进一步考察如何利用并发包实现某个特定的用例,分析实现的优缺点等。
如果你在这方面的基础比较薄弱,我的建议是:
<li>
从总体上,把握住几个主要组成部分(前面回答中已经简要介绍)。
</li>
<li>
理解具体设计、实现和能力。
</li>
<li>
再深入掌握一些比较典型工具类的适用场景、用法甚至是原理,并熟练写出典型的代码用例。
</li>
掌握这些通常就够用了,毕竟并发包提供了方方面面的工具,其实很少有机会能在应用中全面使用过,扎实地掌握核心功能就非常不错了。真正特别深入的经验,还是得靠在实际场景中踩坑来获得。
## 知识扩展
首先,我们来看看并发包提供的丰富同步结构。前面几讲已经分析过各种不同的显式锁,今天我将专注于
<li>
[CountDownLatch](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/CountDownLatch.html),允许一个或多个线程等待某些操作完成。
</li>
<li>
[CyclicBarrier](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/CyclicBarrier.html),一种辅助性的同步结构,允许多个线程等待到达某个屏障。
</li>
<li>
[Semaphore](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/Semaphore.html)Java版本的信号量实现。
</li>
Java提供了经典信号量[Semaphore](https://en.wikipedia.org/wiki/Semaphore_(programming))的实现它通过控制一定数量的允许permit的方式来达到限制通用资源访问的目的。你可以想象一下这个场景在车站、机场等出租车时当很多空出租车就位时为防止过度拥挤调度员指挥排队等待坐车的队伍一次进来5个人上车等这5个人坐车出发再放进去下一批这和Semaphore的工作原理有些类似。
你可以试试使用Semaphore来模拟实现这个调度过程
```
import java.util.concurrent.Semaphore;
public class UsualSemaphoreSample {
public static void main(String[] args) throws InterruptedException {
    System.out.println(&quot;Action...GO!&quot;);
    Semaphore semaphore = new Semaphore(5);
    for (int i = 0; i &lt; 10; i++) {
        Thread t = new Thread(new SemaphoreWorker(semaphore));
        t.start();
    }
}
}
class SemaphoreWorker implements Runnable {
private String name;
private Semaphore semaphore;
public SemaphoreWorker(Semaphore semaphore) {
    this.semaphore = semaphore;
}
@Override
public void run() {
    try {
        log(&quot;is waiting for a permit!&quot;);
       semaphore.acquire();
        log(&quot;acquired a permit!&quot;);
        log(&quot;executed!&quot;);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        log(&quot;released a permit!&quot;);
        semaphore.release();
    }
}
private void log(String msg){
    if (name == null) {
        name = Thread.currentThread().getName();
    }
    System.out.println(name + &quot; &quot; + msg);
}
}
```
这段代码是比较典型的Semaphore示例其逻辑是线程试图获得工作允许得到许可则进行任务然后释放许可这时等待许可的其他线程就可获得许可进入工作状态直到全部处理结束。编译运行我们就能看到Semaphore的允许机制对工作线程的限制。
但是从具体节奏来看其实并不符合我们前面场景的需求因为本例中Semaphore的用法实际是保证一直有5个人可以试图乘车如果有1个人出发了立即就有排队的人获得许可而这并不完全符合我们前面的要求。
那么我再修改一下演示个非典型的Semaphore用法。
```
import java.util.concurrent.Semaphore;
public class AbnormalSemaphoreSample {
public static void main(String[] args) throws InterruptedException {
    Semaphore semaphore = new Semaphore(0);
    for (int i = 0; i &lt; 10; i++) {
        Thread t = new Thread(new MyWorker(semaphore));
        t.start();
    }
    System.out.println(&quot;Action...GO!&quot;);
    semaphore.release(5);
    System.out.println(&quot;Wait for permits off&quot;);
    while (semaphore.availablePermits()!=0) {
        Thread.sleep(100L);
    }
    System.out.println(&quot;Action...GO again!&quot;);
    semaphore.release(5);
}
}
class MyWorker implements Runnable {
private Semaphore semaphore;
public MyWorker(Semaphore semaphore) {
    this.semaphore = semaphore;
}
@Override
public void run() {
    try {
        semaphore.acquire();
        System.out.println(&quot;Executed!&quot;);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}
}
```
注意上面的代码更侧重的是演示Semaphore的功能以及局限性其实有很多线程编程中的反实践比如使用了sleep来协调任务执行而且使用轮询调用availalePermits来检测信号量获取情况这都是很低效并且脆弱的通常只是用在测试或者诊断场景。
总的来说我们可以看出Semaphore就是个**计数器****其基本逻辑基于acquire/release**,并没有太复杂的同步逻辑。
如果Semaphore的数值被初始化为1那么一个线程就可以通过acquire进入互斥状态本质上和互斥锁是非常相似的。但是区别也非常明显比如互斥锁是有持有者的而对于Semaphore这种计数器结构虽然有类似功能但其实不存在真正意义的持有者除非我们进行扩展包装。
下面来看看CountDownLatch和CyclicBarrier它们的行为有一定的相似度经常会被考察二者有什么区别我来简单总结一下。
<li>
CountDownLatch是不可以重置的所以无法重用而CyclicBarrier则没有这种限制可以重用。
</li>
<li>
CountDownLatch的基本操作组合是countDown/await。调用await的线程阻塞等待countDown足够的次数不管你是在一个线程还是多个线程里countDown只要次数足够即可。所以就像Brain Goetz说过的CountDownLatch操作的是事件。
</li>
<li>
CyclicBarrier的基本操作组合则就是await当所有的伙伴parties都调用了await才会继续进行任务并自动进行重置。**注意**正常情况下CyclicBarrier的重置都是自动发生的如果我们调用reset方法但还有线程在等待就会导致等待线程被打扰抛出BrokenBarrierException异常。CyclicBarrier侧重点是线程而不是调用事件它的典型应用场景是用来等待并发线程结束。
</li>
如果用CountDownLatch去实现上面的排队场景该怎么做呢假设有10个人排队我们将其分成5个人一批通过CountDownLatch来协调批次你可以试试下面的示例代码。
```
import java.util.concurrent.CountDownLatch;
public class LatchSample {
public static void main(String[] args) throws InterruptedException {
    CountDownLatch latch = new CountDownLatch(6);
          for (int i = 0; i &lt; 5; i++) {
               Thread t = new Thread(new FirstBatchWorker(latch));
               t.start();
    }
    for (int i = 0; i &lt; 5; i++) {
           Thread t = new Thread(new SecondBatchWorker(latch));
           t.start();
    }
          // 注意这里也是演示目的的逻辑,并不是推荐的协调方式
    while ( latch.getCount() != 1 ){
           Thread.sleep(100L);
    }
    System.out.println(&quot;Wait for first batch finish&quot;);
    latch.countDown();
}
}
class FirstBatchWorker implements Runnable {
private CountDownLatch latch;
public FirstBatchWorker(CountDownLatch latch) {
    this.latch = latch;
}
@Override
public void run() {
        System.out.println(&quot;First batch executed!&quot;);
        latch.countDown();
}
}
class SecondBatchWorker implements Runnable {
private CountDownLatch latch;
public SecondBatchWorker(CountDownLatch latch) {
    this.latch = latch;
}
@Override
public void run() {
    try {
        latch.await();
        System.out.println(&quot;Second batch executed!&quot;);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}
}
```
CountDownLatch的调度方式相对简单后一批次的线程进行await等待前一批countDown足够多次。这个例子也从侧面体现出了它的局限性虽然它也能够支持10个人排队的情况但是因为不能重用如果要支持更多人排队就不能依赖一个CountDownLatch进行了。其编译运行输出如下
<img src="https://static001.geekbang.org/resource/image/46/b9/46c88c7d8e0507465bddb677e4eac5b9.png" alt="">
在实际应用中的条件依赖往往没有这么别扭CountDownLatch用于线程间等待操作结束是非常简单普遍的用法。通过countDown/await组合进行通信是很高效的通常不建议使用例子里那个循环等待方式。
如果用CyclicBarrier来表达这个场景呢我们知道CyclicBarrier其实反映的是线程并行运行时的协调在下面的示例里从逻辑上5个工作线程其实更像是代表了5个可以就绪的空车而不再是5个乘客对比前面CountDownLatch的例子更有助于我们区别它们的抽象模型请看下面的示例代码
```
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierSample {
public static void main(String[] args) throws InterruptedException {
    CyclicBarrier barrier = new CyclicBarrier(5, new Runnable() {
        @Override
        public void run() {
            System.out.println(&quot;Action...GO again!&quot;);
        }
    });
    for (int i = 0; i &lt; 5; i++) {
        Thread t = new Thread(new CyclicWorker(barrier));
        t.start();
    }
}
static class CyclicWorker implements Runnable {
    private CyclicBarrier barrier;
    public CyclicWorker(CyclicBarrier barrier) {
        this.barrier = barrier;
    }
    @Override
    public void run() {
        try {
            for (int i=0; i&lt;3 ; i++){
                System.out.println(&quot;Executed!&quot;);
                barrier.await();
            }
        } catch (BrokenBarrierException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
  }
}
}
```
为了让输出更能表达运行时序我使用了CyclicBarrier特有的barrierAction当屏障被触发时Java会自动调度该动作。因为CyclicBarrier会**自动**进行重置,所以这个逻辑其实可以非常自然的支持更多排队人数。其编译输出如下:
<img src="https://static001.geekbang.org/resource/image/ef/9f/eff56d3219ce5493ecacc70a168b2b9f.png" alt="">
Java并发类库还提供了[Phaser](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/Phaser.html)功能与CountDownLatch很接近但是它允许线程动态地注册到Phaser上面而CountDownLatch显然是不能动态设置的。Phaser的设计初衷是实现多个线程类似步骤、阶段场景的协调线程注册等待屏障条件触发进而协调彼此间行动具体请参考这个[例子](http://www.baeldung.com/java-phaser)。
接下来我来梳理下并发包里提供的线程安全Map、List和Set。首先请参考下面的类图。
<img src="https://static001.geekbang.org/resource/image/35/57/35390aa8a6e6f9c92fda086a1b95b457.png" alt="">
你可以看到总体上种类和结构还是比较简单的如果我们的应用侧重于Map放入或者获取的速度而不在乎顺序大多推荐使用ConcurrentHashMap反之则使用ConcurrentSkipListMap如果我们需要对大量数据进行非常频繁地修改ConcurrentSkipListMap也可能表现出优势。
我在前面的专栏谈到了普通无顺序场景选择HashMap有顺序场景则可以选择类似TreeMap等但是为什么并发容器里面没有ConcurrentTreeMap呢
这是因为TreeMap要实现高效的线程安全是非常困难的它的实现基于复杂的红黑树。为保证访问效率当我们插入或删除节点时会移动节点进行平衡操作这导致在并发场景中难以进行合理粒度的同步。而SkipList结构则要相对简单很多通过层次结构提高访问速度虽然不够紧凑空间使用有一定提高O(nlogn)但是在增删元素时线程安全的开销要好很多。为了方便你理解SkipList的内部结构我画了一个示意图。
<img src="https://static001.geekbang.org/resource/image/63/7b/63b94b5b1d002bb191c75d2c48af767b.png" alt="">
关于两个CopyOnWrite容器其实CopyOnWriteArraySet是通过包装了CopyOnWriteArrayList来实现的所以在学习时我们可以专注于理解一种。
首先CopyOnWrite到底是什么意思呢它的原理是任何修改操作如add、set、remove都会拷贝原数组修改后替换原来的数组通过这种防御性的方式实现另类的线程安全。请看下面的代码片段我进行注释的地方可以清晰地理解其逻辑。
```
public boolean add(E e) {
synchronized (lock) {
    Object[] elements = getArray();
    int len = elements.length;
          // 拷贝
    Object[] newElements = Arrays.copyOf(elements, len + 1);
    newElements[len] = e;
          // 替换
    setArray(newElements);
    return true;
           }
}
final void setArray(Object[] a) {
array = a;
}
```
所以这种数据结构,相对比较适合读多写少的操作,不然修改的开销还是非常明显的。
今天我对Java并发包进行了总结并且结合实例分析了各种同步结构和部分线程安全容器希望对你有所帮助。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗留给你的思考题是你使用过类似CountDownLatch的同步结构解决实际问题吗谈谈你的使用场景和心得。
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,271 @@
<audio id="audio" title="第20讲 | 并发包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么区别" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f2/56/f25da87db2759f1e04211886f6321e56.mp3"></audio>
在上一讲中我分析了Java并发包中的部分内容今天我来介绍一下线程安全队列。Java标准库提供了非常多的线程安全队列很容易混淆。
今天我要问你的问题是并发包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么区别
## 典型回答
有时候我们把并发包下面的所有容器都习惯叫作并发容器但是严格来讲类似ConcurrentLinkedQueue这种“Concurrent*”容器,才是真正代表并发。
关于问题中它们的区别:
<li>
Concurrent类型基于lock-free在常见的多线程访问场景一般可以提供较高吞吐量。
</li>
<li>
而LinkedBlockingQueue内部则是基于锁并提供了BlockingQueue的等待性方法。
</li>
不知道你有没有注意到java.util.concurrent包提供的容器Queue、List、Set、Map从命名上可以大概区分为Concurrent*、CopyOnWrite**和Blocking**等三类,同样是线程安全容器,可以简单认为:
<li>
Concurrent类型没有类似CopyOnWrite之类容器相对较重的修改开销。
</li>
<li>
但是凡事都是有代价的Concurrent往往提供了较低的遍历一致性。你可以这样理解所谓的弱一致性例如当利用迭代器遍历时如果容器发生修改迭代器仍然可以继续进行遍历。
</li>
<li>
与弱一致性对应的就是我介绍过的同步容器常见的行为“fail-fast”也就是检测到容器在遍历过程中发生了修改则抛出ConcurrentModificationException不再继续遍历。
</li>
<li>
弱一致性的另外一个体现是size等操作准确性是有限的未必是100%准确。
</li>
<li>
与此同时,读取的性能具有一定的不确定性。
</li>
## 考点分析
今天的问题是又是一个引子,考察你是否了解并发包内部不同容器实现的设计目的和实现区别。
队列是非常重要的数据结构我们日常开发中很多线程间数据传递都要依赖于它Executor框架提供的各种线程池同样无法离开队列。面试官可以从不同角度考察比如
<li>
哪些队列是有界的,哪些是无界的?(很多同学反馈了这个问题)
</li>
<li>
针对特定场景需求,如何选择合适的队列实现?
</li>
<li>
从源码的角度,常见的线程安全队列是如何实现的,并进行了哪些改进以提高性能表现?
</li>
为了能更好地理解这一讲需要你掌握一些基本的队列本身和数据结构方面知识如果这方面知识比较薄弱《数据结构与算法分析》是一本比较全面的参考书专栏还是尽量专注于Java领域的特性。
## 知识扩展
**线程安全队列一览**
我在[专栏第8讲](http://time.geekbang.org/column/article/7810)中介绍过常见的集合中如LinkedList是个Deque只不过不是线程安全的。下面这张图是Java并发类库提供的各种各样的**线程安全**队列实现,注意,图中并未将非线程安全部分包含进来。
<img src="https://static001.geekbang.org/resource/image/79/79/791750d6fe7ef88ecb3897e1d029f079.png" alt="" />
我们可以从不同的角度进行分类,从基本的数据结构的角度分析,有两个特别的[Deque](https://docs.oracle.com/javase/9/docs/api/java/util/Deque.html)实现ConcurrentLinkedDeque和LinkedBlockingDeque。Deque的侧重点是支持对队列头尾都进行插入和删除所以提供了特定的方法如:
<li>
尾部插入时需要的[addLast(e)](https://docs.oracle.com/javase/9/docs/api/java/util/Deque.html#addLast-E-)、[offerLast(e)](https://docs.oracle.com/javase/9/docs/api/java/util/Deque.html#offerLast-E-)。
</li>
<li>
尾部删除所需要的[removeLast()](https://docs.oracle.com/javase/9/docs/api/java/util/Deque.html#removeLast--)、[pollLast()](https://docs.oracle.com/javase/9/docs/api/java/util/Deque.html#pollLast--)。
</li>
从上面这些角度能够理解ConcurrentLinkedDeque和LinkedBlockingQueue的主要功能区别也就足够日常开发的需要了。但是如果我们深入一些通常会更加关注下面这些方面。
从行为特征来看绝大部分Queue都是实现了BlockingQueue接口。在常规队列操作基础上Blocking意味着其提供了特定的等待性操作获取时take等待元素进队或者插入时put等待队列出现空位。
```
/**
* 获取并移除队列头结点,如果必要,其会等待直到队列出现元素
*/
E take() throws InterruptedException;
/**
* 插入元素,如果队列已满,则等待直到队列出现空闲空间
  …
*/
void put(E e) throws InterruptedException;
```
另一个BlockingQueue经常被考察的点就是是否有界Bounded、Unbounded这一点也往往会影响我们在应用开发中的选择我这里简单总结一下。
- ArrayBlockingQueue是最典型的的有界队列其内部以final的数组保存数据数组的大小就决定了队列的边界所以我们在创建ArrayBlockingQueue时都要指定容量
```
public ArrayBlockingQueue(int capacity, boolean fair)
```
<li>
LinkedBlockingQueue容易被误解为无边界但其实其行为和内部代码都是基于有界的逻辑实现的只不过如果我们没有在创建队列时就指定容量那么其容量限制就自动被设置为Integer.MAX_VALUE成为了无界队列。
</li>
<li>
SynchronousQueue这是一个非常奇葩的队列实现每个删除操作都要等待插入操作反之每个插入操作也都要等待删除动作。那么这个队列的容量是多少呢是1吗其实不是的其内部容量是0。
</li>
<li>
PriorityBlockingQueue是无边界的优先队列虽然严格意义上来讲其大小总归是要受系统资源影响。
</li>
<li>
DelayedQueue和LinkedTransferQueue同样是无边界的队列。对于无边界的队列有一个自然的结果就是put操作永远也不会发生其他BlockingQueue的那种等待情况。
</li>
如果我们分析不同队列的底层实现BlockingQueue基本都是基于锁实现一起来看看典型的LinkedBlockingQueue。
```
/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();
/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();
/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();
/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();
```
我在介绍ReentrantLock的条件变量用法的时候分析过ArrayBlockingQueue不知道你有没有注意到其条件变量与LinkedBlockingQueue版本的实现是有区别的。notEmpty、notFull都是同一个再入锁的条件变量而LinkedBlockingQueue则改进了锁操作的粒度头、尾操作使用不同的锁所以在通用场景下它的吞吐量相对要更好一些。
下面的take方法与ArrayBlockingQueue中的实现也是有不同的由于其内部结构是链表需要自己维护元素数量值请参考下面的代码。
```
public E take() throws InterruptedException {
   final E x;
   final int c;
   final AtomicInteger count = this.count;
   final ReentrantLock takeLock = this.takeLock;
   takeLock.lockInterruptibly();
   try {
       while (count.get() == 0) {
           notEmpty.await();
       }
       x = dequeue();
       c = count.getAndDecrement();
       if (c &gt; 1)
           notEmpty.signal();
   } finally {
       takeLock.unlock();
   }
   if (c == capacity)
       signalNotFull();
   return x;
}
```
类似ConcurrentLinkedQueue等则是基于CAS的无锁技术不需要在每个操作时使用锁所以扩展性表现要更加优异。
相对比较另类的SynchronousQueue在Java 6中其实现发生了非常大的变化利用CAS替换掉了原本基于锁的逻辑同步开销比较小。它是Executors.newCachedThreadPool()的默认队列。
**队列使用场景与典型用例**
在实际开发中我提到过Queue被广泛使用在生产者-消费者场景比如利用BlockingQueue来实现由于其提供的等待机制我们可以少操心很多协调工作你可以参考下面样例代码
```
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class ConsumerProducer {
   public static final String EXIT_MSG  = &quot;Good bye!&quot;;
   public static void main(String[] args) {
// 使用较小的队列,以更好地在输出中展示其影响
       BlockingQueue&lt;String&gt; queue = new ArrayBlockingQueue&lt;&gt;(3);
       Producer producer = new Producer(queue);
       Consumer consumer = new Consumer(queue);
       new Thread(producer).start();
       new Thread(consumer).start();
   }
   static class Producer implements Runnable {
       private BlockingQueue&lt;String&gt; queue;
       public Producer(BlockingQueue&lt;String&gt; q) {
           this.queue = q;
       }
       @Override
       public void run() {
           for (int i = 0; i &lt; 20; i++) {
               try{
                   Thread.sleep(5L);
                   String msg = &quot;Message&quot; + i;
                   System.out.println(&quot;Produced new item: &quot; + msg);
                   queue.put(msg);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
           try {
               System.out.println(&quot;Time to say good bye!&quot;);
               queue.put(EXIT_MSG);
           } catch (InterruptedException e) {
               e.printStackTrace();
           }
       }
   }
   static class Consumer implements Runnable{
       private BlockingQueue&lt;String&gt; queue;
       public Consumer(BlockingQueue&lt;String&gt; q){
           this.queue=q;
       }
       @Override
       public void run() {
           try{
               String msg;
               while(!EXIT_MSG.equalsIgnoreCase( (msg = queue.take()))){
                   System.out.println(&quot;Consumed item: &quot; + msg);
                   Thread.sleep(10L);
               }
               System.out.println(&quot;Got exit message, bye!&quot;);
           }catch(InterruptedException e) {
               e.printStackTrace();
           }
       }
   }
}
```
上面是一个典型的生产者-消费者样例如果使用非Blocking的队列那么我们就要自己去实现轮询、条件判断如检查poll返回值是否null等逻辑如果没有特别的场景要求Blocking实现起来代码更加简单、直观。
前面介绍了各种队列实现,在日常的应用开发中,如何进行选择呢?
以LinkedBlockingQueue、ArrayBlockingQueue和SynchronousQueue为例我们一起来分析一下根据需求可以从很多方面考量
<li>
考虑应用场景中对队列边界的要求。ArrayBlockingQueue是有明确的容量限制的而LinkedBlockingQueue则取决于我们是否在创建时指定SynchronousQueue则干脆不能缓存任何元素。
</li>
<li>
从空间利用角度数组结构的ArrayBlockingQueue要比LinkedBlockingQueue紧凑因为其不需要创建所谓节点但是其初始分配阶段就需要一段连续的空间所以初始内存需求更大。
</li>
<li>
通用场景中LinkedBlockingQueue的吞吐量一般优于ArrayBlockingQueue因为它实现了更加细粒度的锁操作。
</li>
<li>
ArrayBlockingQueue实现比较简单性能更好预测属于表现稳定的“选手”。
</li>
<li>
如果我们需要实现的是两个线程之间接力性handoff的场景按照[专栏上一讲](http://time.geekbang.org/column/article/9373)的例子你可能会选择CountDownLatch但是[SynchronousQueue](http://www.baeldung.com/java-synchronous-queue)也是完美符合这种场景的,而且线程间协调和数据传输统一起来,代码更加规范。
</li>
<li>
可能令人意外的是很多时候SynchronousQueue的性能表现往往大大超过其他实现尤其是在队列元素较小的场景。
</li>
今天我分析了Java中让人眼花缭乱的各种线程安全队列试图从几个角度让每个队列的特点更加明确进而希望减少你在日常工作中使用时的困扰。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗? 今天的内容侧重于Java自身的角度面试官也可能从算法的角度来考察所以今天留给你的思考题是指定某种结构比如栈用它实现一个BlockingQueue实现思路是怎样的呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,250 @@
<audio id="audio" title="第21讲 | Java并发类库提供的线程池有哪几种 分别有什么特点?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a9/ac/a9ffa6a060a1d4f7c331df245c42f6ac.mp3"></audio>
我在[专栏第17讲](http://time.geekbang.org/column/article/9103)中介绍过线程是不能够重复启动的,创建或销毁线程存在一定的开销,所以利用线程池技术来提高系统资源利用效率,并简化线程管理,已经是非常成熟的选择。
今天我要问你的问题是Java并发类库提供的线程池有哪几种 分别有什么特点?
## 典型回答
通常开发者都是利用Executors提供的通用线程池创建方法去创建不同配置的线程池主要区别在于不同的ExecutorService类型或者不同的初始参数。
Executors目前提供了5种不同的线程池创建配置
<li>
newCachedThreadPool()它是一种用来处理大量短时间工作任务的线程池具有几个鲜明特点它会试图缓存线程并重用当无缓存线程可用时就会创建新的工作线程如果线程闲置的时间超过60秒则被终止并移出缓存长时间闲置时这种线程池不会消耗什么资源。其内部使用SynchronousQueue作为工作队列。
</li>
<li>
newFixedThreadPool(int nThreads)重用指定数目nThreads的线程其背后使用的是无界的工作队列任何时候最多有nThreads个工作线程是活动的。这意味着如果任务数量超过了活动队列数目将在工作队列中等待空闲线程出现如果有工作线程退出将会有新的工作线程被创建以补足指定的数目nThreads。
</li>
<li>
newSingleThreadExecutor()它的特点在于工作线程数目被限制为1操作一个无界的工作队列所以它保证了所有任务的都是被顺序执行最多会有一个任务处于活动状态并且不允许使用者改动线程池实例因此可以避免其改变线程数目。
</li>
<li>
newSingleThreadScheduledExecutor()和newScheduledThreadPool(int corePoolSize)创建的是个ScheduledExecutorService可以进行定时或周期性的工作调度区别在于单一工作线程还是多个工作线程。
</li>
<li>
newWorkStealingPool(int parallelism)这是一个经常被人忽略的线程池Java 8才加入这个创建方法其内部会构建[ForkJoinPool](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/ForkJoinPool.html),利用[Work-Stealing](https://en.wikipedia.org/wiki/Work_stealing)算法,并行地处理任务,不保证处理顺序。
</li>
## 考点分析
Java并发包中的Executor框架无疑是并发编程中的重点今天的题目考察的是对几种标准线程池的了解我提供的是一个针对最常见的应用方式的回答。
在大多数应用场景下使用Executors提供的5个静态工厂方法就足够了但是仍然可能需要直接利用ThreadPoolExecutor等构造函数创建这就要求你对线程构造方式有进一步的了解你需要明白线程池的设计和结构。
另外线程池这个定义就是个容易让人误解的术语因为ExecutorService除了通常意义上“池”的功能还提供了更全面的线程管理、任务提交等方法。
Executor框架可不仅仅是线程池我觉得至少下面几点值得深入学习
<li>
掌握Executor框架的主要内容至少要了解组成与职责掌握基本开发用例中的使用。
</li>
<li>
对线程池和相关并发工具类型的理解,甚至是源码层面的掌握。
</li>
<li>
实践中有哪些常见问题,基本的诊断思路是怎样的。
</li>
<li>
如何根据自身应用特点合理使用线程池。
</li>
## 知识扩展
首先我们来看看Executor框架的基本组成请参考下面的类图。
<img src="https://static001.geekbang.org/resource/image/fc/5b/fc70c37867c7fbfb672fa3e37fe14b5b.png" alt="">
我们从整体上把握一下各个类型的主要设计目的:
- Executor是一个基础的接口其初衷是将任务提交和任务执行细节解耦这一点可以体会其定义的唯一方法。
```
void execute(Runnable command);
```
Executor的设计是源于Java早期线程API使用的教训开发者在实现应用逻辑时被太多线程创建、调度等不相关细节所打扰。就像我们进行HTTP通信如果还需要自己操作TCP握手开发效率低下质量也难以保证。
- ExecutorService则更加完善不仅提供service的管理功能比如shutdown等方法也提供了更加全面的提交任务机制如返回[Future](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/Future.html)而不是void的submit方法。
```
&lt;T&gt; Future&lt;T&gt; submit(Callable&lt;T&gt; task);
```
注意,这个例子输入的可是[Callable](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/Callable.html)它解决了Runnable无法返回结果的困扰。
<li>
Java标准类库提供了几种基础实现比如[ThreadPoolExecutor](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/ThreadPoolExecutor.html)、[ScheduledThreadPoolExecutor](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/ScheduledThreadPoolExecutor.html)、[ForkJoinPool](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/ForkJoinPool.html)。这些线程池的设计特点在于其高度的可调节性和灵活性,以尽量满足复杂多变的实际应用场景,我会进一步分析其构建部分的源码,剖析这种灵活性的源头。
</li>
<li>
Executors则从简化使用的角度为我们提供了各种方便的静态工厂方法。
</li>
下面我就从源码角度分析线程池的设计与实现我将主要围绕最基础的ThreadPoolExecutor源码。ScheduledThreadPoolExecutor是ThreadPoolExecutor的扩展主要是增加了调度逻辑如想深入了解你可以参考相关[教程](https://www.journaldev.com/2340/java-scheduler-scheduledexecutorservice-scheduledthreadpoolexecutor-example)。而ForkJoinPool则是为ForkJoinTask定制的线程池与通常意义的线程池有所不同。
这部分内容比较晦涩,罗列概念也不利于你去理解,所以我会配合一些示意图来说明。在现实应用中,理解应用与线程池的交互和线程池的内部工作过程,你可以参考下图。
<img src="https://static001.geekbang.org/resource/image/18/65/18b64aee22c67f488171a73133e4d465.png" alt="">
简单理解一下:
- 工作队列负责存储用户提交的各个任务这个工作队列可以是容量为0的SynchronousQueue使用newCachedThreadPool也可以是像固定大小线程池newFixedThreadPool那样使用LinkedBlockingQueue。
```
private final BlockingQueue&lt;Runnable&gt; workQueue;
```
- 内部的“线程池”这是指保持工作线程的集合线程池需要在运行过程中管理线程创建、销毁。例如对于带缓存的线程池当任务压力较大时线程池会创建新的工作线程当业务压力退去线程池会在闲置一段时间默认60秒后结束线程。
```
private final HashSet&lt;Worker&gt; workers = new HashSet&lt;&gt;();
```
线程池的工作线程被抽象为静态内部类Worker基于[AQS](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/locks/AbstractQueuedSynchronizer.html)实现。
<li>
ThreadFactory提供上面所需要的创建线程逻辑。
</li>
<li>
如果任务提交时被拒绝比如线程池已经处于SHUTDOWN状态需要为其提供处理逻辑Java标准库提供了类似[ThreadPoolExecutor.AbortPolicy](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/ThreadPoolExecutor.AbortPolicy.html)等默认实现,也可以按照实际需求自定义。
</li>
从上面的分析,就可以看出线程池的几个基本组成部分,一起都体现在线程池的构造函数中,从字面我们就可以大概猜测到其用意:
<li>
corePoolSize所谓的核心线程数可以大致理解为长期驻留的线程数目除非设置了allowCoreThreadTimeOut。对于不同的线程池这个值可能会有很大区别比如newFixedThreadPool会将其设置为nThreads而对于newCachedThreadPool则是为0。
</li>
<li>
maximumPoolSize顾名思义就是线程不够时能够创建的最大线程数。同样进行对比对于newFixedThreadPool当然就是nThreads因为其要求是固定大小而newCachedThreadPool则是Integer.MAX_VALUE。
</li>
<li>
keepAliveTime和TimeUnit这两个参数指定了额外的线程能够闲置多久显然有些线程池不需要它。
</li>
<li>
workQueue工作队列必须是BlockingQueue。
</li>
通过配置不同的参数,我们就可以创建出行为大相径庭的线程池,这就是线程池高度灵活性的基础。
```
public ThreadPoolExecutor(int corePoolSize,
                      int maximumPoolSize,
                      long keepAliveTime,
                      TimeUnit unit,
                      BlockingQueue&lt;Runnable&gt; workQueue,
                      ThreadFactory threadFactory,
                      RejectedExecutionHandler handler)
```
进一步分析,线程池既然有生命周期,它的状态是如何表征的呢?
这里有一个非常有意思的设计ctl变量被赋予了双重角色通过高低位的不同既表示线程池状态又表示工作线程数目这是一个典型的高效优化。试想实际系统中虽然我们可以指定线程极限为Integer.MAX_VALUE但是因为资源限制这只是个理论值所以完全可以将空闲位赋予其他意义。
```
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
// 真正决定了工作线程数的理论上限
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int COUNT_MASK = (1 &lt;&lt; COUNT_BITS) - 1;
// 线程池状态,存储在数字的高位
private static final int RUNNING = -1 &lt;&lt; COUNT_BITS;
// Packing and unpacking ctl
private static int runStateOf(int c) { return c &amp; ~COUNT_MASK; }
private static int workerCountOf(int c) { return c &amp; COUNT_MASK; }
private static int ctlOf(int rs, int wc) { return rs | wc; }
```
为了让你能对线程生命周期有个更加清晰的印象我这里画了一个简单的状态流转图对线程池的可能状态和其内部方法之间进行了对应如果有不理解的方法请参考Javadoc。**注意**实际Java代码中并不存在所谓Idle状态我添加它仅仅是便于理解。
<img src="https://static001.geekbang.org/resource/image/c5/a1/c50ce5f2ff4ae723c6267185699ccda1.png" alt="">
前面都是对线程池属性和构建等方面的分析下面我选择典型的execute方法来看看其是如何工作的具体逻辑请参考我添加的注释配合代码更加容易理解。
```
public void execute(Runnable command) {
int c = ctl.get();
// 检查工作线程数目低于corePoolSize则添加Worker
if (workerCountOf(c) &lt; corePoolSize) {
    if (addWorker(command, true))
        return;
    c = ctl.get();
}
// isRunning就是检查线程池是否被shutdown
// 工作队列可能是有界的offer是比较友好的入队方式
if (isRunning(c) &amp;&amp; workQueue.offer(command)) {
    int recheck = ctl.get();
// 再次进行防御性检查
    if (! isRunning(recheck) &amp;&amp; remove(command))
        reject(command);
    else if (workerCountOf(recheck) == 0)
        addWorker(null, false);
}
// 尝试添加一个worker如果失败意味着已经饱和或者被shutdown了
else if (!addWorker(command, false))
    reject(command);
}
```
**线程池实践**
线程池虽然为提供了非常强大、方便的功能,但是也不是银弹,使用不当同样会导致问题。我这里介绍些典型情况,经过前面的分析,很多方面可以自然的推导出来。
<li>
避免任务堆积。前面我说过newFixedThreadPool是创建指定数目的线程但是其工作队列是无界的如果工作线程数目太少导致处理跟不上入队的速度这就很有可能占用大量系统内存甚至是出现OOM。诊断时你可以使用jmap之类的工具查看是否有大量的任务对象入队。
</li>
<li>
避免过度扩展线程。我们通常在处理大量短时任务时使用缓存的线程池比如在最新的HTTP/2 client API中目前的默认实现就是如此。我们在创建线程池的时候并不能准确预计任务压力有多大、数据特征是什么样子大部分请求是1K 、100K还是1M以上所以很难明确设定一个线程数目。
</li>
<li>
另外如果线程数目不断增长可以使用jstack等工具检查也需要警惕另外一种可能性就是线程泄漏这种情况往往是因为任务逻辑有问题导致工作线程迟迟不能被释放。建议你排查下线程栈很有可能多个线程都是卡在近似的代码处。
</li>
<li>
避免死锁等同步问题,对于死锁的场景和排查,你可以复习[专栏第18讲](http://time.geekbang.org/column/article/9266)。
</li>
<li>
尽量避免在使用线程池时操作ThreadLocal同样是[专栏第17讲](http://time.geekbang.org/column/article/9103)已经分析过的,通过今天的线程池学习,应该更能理解其原因,工作线程的生命周期通常都会超过任务的生命周期。
</li>
**线程池大小的选择策略**
上面我已经介绍过,线程池大小不合适,太多或太少,都会导致麻烦,所以我们需要去考虑一个合适的线程池大小。虽然不能完全确定,但是有一些相对普适的规则和思路。
<li>
如果我们的任务主要是进行计算那么就意味着CPU的处理能力是稀缺的资源我们能够通过大量增加线程数提高计算能力吗往往是不能的如果线程太多反倒可能导致大量的上下文切换开销。所以这种情况下通常建议按照CPU核的数目N或者N+1。
</li>
<li>
如果是需要较多等待的任务例如I/O操作比较多可以参考Brain Goetz推荐的计算方法
</li>
```
线程数 = CPU核数 × 目标CPU利用率 ×1 + 平均等待时间/平均工作时间)
```
这些时间并不能精准预计,需要根据采样或者概要分析等方式进行计算,然后在实际中验证和调整。
- 上面是仅仅考虑了CPU等限制实际还可能受各种系统资源限制影响例如我最近就在Mac OS X上遇到了大负载时[ephemeral端口受限](http://danielmendel.github.io/blog/2013/04/07/benchmarkers-beware-the-ephemeral-port-limit/)的情况。当然,我是通过扩大可用端口范围解决的,如果我们不能调整资源的容量,那么就只能限制工作线程的数目了。这里的资源可以是文件句柄、内存等。
另外,在实际工作中,不要把解决问题的思路全部指望到调整线程池上,很多时候架构上的改变更能解决问题,比如利用背压机制的[Reactive Stream](http://www.reactive-streams.org/)、合理的拆分等。
今天我从Java创建的几种线程池开始对Executor框架的主要组成、线程池结构与生命周期等方面进行了讲解和分析希望对你有所帮助。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是从逻辑上理解线程池创建和生命周期。请谈一谈如果利用newSingleThreadExecutor()创建一个线程池corePoolSize、maxPoolSize等都是什么数值ThreadFactory可能在线程池生命周期中被使用多少次怎么验证自己的判断
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,269 @@
<audio id="audio" title="第22讲 | AtomicInteger底层实现原理是什么如何在自己的产品代码中应用CAS操作" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1d/ae/1d3d56bddfd62fbcb2f9d0246e01d9ae.mp3"></audio>
在今天这一讲中,我来分析一下并发包内部的组成,一起来看看各种同步结构、线程池等,是基于什么原理来设计和实现的。
今天我要问你的问题是AtomicInteger底层实现原理是什么如何在自己的产品代码中应用CAS操作
## 典型回答
AtomicIntger是对int类型的一个封装提供原子性的访问和更新操作其原子性操作的实现是基于CAS[compare-and-swap](https://en.wikipedia.org/wiki/Compare-and-swap))技术。
所谓CAS表征的是一些列操作的集合获取当前数值进行一些运算利用CAS指令试图进行更新。如果当前数值未变代表没有其他线程进行并发修改则成功更新。否则可能出现不同的选择要么进行重试要么就返回一个成功或者失败的结果。
从AtomicInteger的内部属性可以看出它依赖于Unsafe提供的一些底层能力进行底层操作以volatile的value字段记录数值以保证可见性。
```
private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();
private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, &quot;value&quot;);
private volatile int value;
```
具体的原子操作细节可以参考任意一个原子更新方法比如下面的getAndIncrement。
Unsafe会利用value字段的内存地址偏移直接完成操作。
```
public final int getAndIncrement() {
   return U.getAndAddInt(this, VALUE, 1);
}
```
因为getAndIncrement需要返归数值所以需要添加失败重试逻辑。
```
public final int getAndAddInt(Object o, long offset, int delta) {
   int v;
   do {
       v = getIntVolatile(o, offset);
   } while (!weakCompareAndSetInt(o, offset, v, v + delta));
   return v;
}
```
而类似compareAndSet这种返回boolean类型的函数因为其返回值表现的就是成功与否所以不需要重试。
```
public final boolean compareAndSet(int expectedValue, int newValue)
```
CAS是Java并发中所谓lock-free机制的基础。
## 考点分析
今天的问题有点偏向于Java并发机制的底层了虽然我们在开发中未必会涉及CAS的实现层面但是理解其机制掌握如何在Java中运用该技术还是十分有必要的尤其是这也是个并发编程的面试热点。
有的同学反馈面试官会问CAS更加底层是如何实现的这依赖于CPU提供的特定指令具体根据体系结构的不同还存在着明显区别。比如x86 CPU提供cmpxchg指令而在精简指令集的体系架构中则通常是靠一对儿指令如“load and reserve”和“store conditional”实现的在大多数处理器上CAS都是个非常轻量级的操作这也是其优势所在。
大部分情况下掌握到这个程度也就够用了我认为没有必要让每个Java工程师都去了解到指令级别我们进行抽象、分工就是为了让不同层面的开发者在开发中可以尽量屏蔽不相关的细节。
如果我作为面试官,很有可能深入考察这些方向:
<li>
在什么场景下可以采用CAS技术调用Unsafe毕竟不是大多数场景的最好选择有没有更加推荐的方式呢毕竟我们掌握一个技术cool不是目的更不是为了应付面试我们还是希望能在实际产品中有价值。
</li>
<li>
对ReentrantLock、CyclicBarrier等并发结构底层的实现技术的理解。
</li>
## 知识扩展
关于CAS的使用你可以设想这样一个场景在数据库产品中为保证索引的一致性一个常见的选择是保证只有一个线程能够排他性地修改一个索引分区如何在数据库抽象层面实现呢
可以考虑为索引分区对象添加一个逻辑上的锁例如以当前独占的线程ID作为锁的数值然后通过原子操作设置lock数值来实现加锁和释放锁伪代码如下
```
public class AtomicBTreePartition {
private volatile long lock;
public void acquireLock(){}
public void releaseeLock(){}
}
```
那么在Java代码中我们怎么实现锁操作呢Unsafe似乎不是个好的选择例如我就注意到类似Cassandra等产品因为Java 9中移除了Unsafe.moniterEnter()/moniterExit()导致无法平滑升级到新的JDK版本。目前Java提供了两种公共API可以实现这种CAS操作比如使用java.util.concurrent.atomic.AtomicLongFieldUpdater它是基于反射机制创建我们需要保证类型和字段名称正确。
```
private static final AtomicLongFieldUpdater&lt;AtomicBTreePartition&gt; lockFieldUpdater =
       AtomicLongFieldUpdater.newUpdater(AtomicBTreePartition.class, &quot;lock&quot;);
private void acquireLock(){
   long t = Thread.currentThread().getId();
   while (!lockFieldUpdater.compareAndSet(this, 0L, t)){
       // 等待一会儿,数据库操作可能比较慢
        …
   }
}
```
[Atomic包](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/atomic/package-summary.html)提供了最常用的原子性数据类型,甚至是引用、数组等相关原子类型和更新操作工具,是很多线程安全程序的首选。
我在专栏第七讲中曾介绍使用原子数据类型和Atomic*FieldUpdater创建更加紧凑的计数器实现以替代AtomicLong。优化永远是针对特定需求、特定目的我这里的侧重点是介绍可能的思路具体还是要看需求。如果仅仅创建一两个对象其实完全没有必要进行前面的优化但是如果对象成千上万或者更多就要考虑紧凑性的影响了。而atomic包提供的[LongAdder](https://docs.oracle.com/javase/9/docs/api/java/util/concurrent/atomic/LongAdder.html)在高度竞争环境下可能就是比AtomicLong更佳的选择尽管它的本质是空间换时间。
回归正题如果是Java 9以后我们完全可以采用另外一种方式实现也就是Variable Handle API这是源自于[JEP 193](http://openjdk.java.net/jeps/193),提供了各种粒度的原子或者有序性的操作等。我将前面的代码修改为如下实现:
```
private static final VarHandle HANDLE = MethodHandles.lookup().findStaticVarHandle
       (AtomicBTreePartition.class, &quot;lock&quot;);
private void acquireLock(){
   long t = Thread.currentThread().getId();
   while (!HANDLE.compareAndSet(this, 0L, t)){
       // 等待一会儿,数据库操作可能比较慢
       …
   }
}
```
过程非常直观首先获取相应的变量句柄然后直接调用其提供的CAS方法。
一般来说我们进行的类似CAS操作可以并且推荐使用Variable Handle API去实现其提供了精细粒度的公共底层API。我这里强调公共是因为其API不会像内部API那样发生不可预测的修改这一点提供了对于未来产品维护和升级的基础保障坦白说很多额外工作量都是源于我们使用了Hack而非Solution的方式解决问题。
CAS也并不是没有副作用试想其常用的失败重试机制隐含着一个假设即竞争情况是短暂的。大多数应用场景中确实大部分重试只会发生一次就获得了成功但是总是有意外情况所以在有需要的时候还是要考虑限制自旋的次数以免过度消耗CPU。
另外一个就是著名的[ABA](https://en.wikipedia.org/wiki/ABA_problem)问题这是通常只在lock-free算法下暴露的问题。我前面说过CAS是在更新时比较前值如果对方只是恰好相同例如期间发生了 A -&gt; B -&gt; A的更新仅仅判断数值是A可能导致不合理的修改操作。针对这种情况Java提供了AtomicStampedReference工具类通过为引用建立类似版本号stamp的方式来保证CAS的正确性具体用法请参考这里的[介绍](http://tutorials.jenkov.com/java-util-concurrent/atomicstampedreference.html)。
前面介绍了CAS的场景与实现幸运的是大多数情况下Java开发者并不需要直接利用CAS代码去实现线程安全容器等更多是通过并发包等间接享受到lock-free机制在扩展性上的好处。
下面我来介绍一下AbstractQueuedSynchronizerAQS其是Java并发包中实现各种同步结构和部分其他组成单元如线程池中的Worker的基础。
学习AQS如果上来就去看它的一系列方法下图所示很有可能把自己看晕这种似懂非懂的状态也没有太大的实践意义。
<img src="https://static001.geekbang.org/resource/image/e3/36/e3b4b7fe5a94a88ca2feb04d734b9c36.png" alt="">
我建议的思路是尽量简化一下理解为什么需要AQS如何使用AQS**至少**要做什么再进一步结合JDK源代码中的实践理解AQS的原理与应用。
[Doug Lea](https://en.wikipedia.org/wiki/Doug_Lea)曾经介绍过AQS的设计初衷。从原理上一种同步结构往往是可以利用其他的结构实现的例如我在专栏第19讲中提到过可以使用Semaphore实现互斥锁。但是对某种同步结构的倾向会导致复杂、晦涩的实现逻辑所以他选择了将基础的同步相关操作抽象在AbstractQueuedSynchronizer中利用AQS为我们构建同步结构提供了范本。
AQS内部数据和方法可以简单拆分为
- 一个volatile的整数成员表征状态同时提供了setState和getState方法
```
private volatile int state;
```
<li>
一个先入先出FIFO的等待线程队列以实现多线程间竞争和等待这是AQS机制的核心之一。
</li>
<li>
各种基于CAS的基础操作方法以及各种期望具体同步结构去实现的acquire/release方法。
</li>
利用AQS实现一个同步结构至少要实现两个基本类型的方法分别是acquire操作获取资源的独占权还有就是release操作释放对某个资源的独占。
以ReentrantLock为例它内部通过扩展AQS实现了Sync类型以AQS的state来反映锁的持有情况。
```
private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer { …}
```
下面是ReentrantLock对应acquire和release操作如果是CountDownLatch则可以看作是await()/countDown(),具体实现也有区别。
```
public void lock() {
   sync.acquire(1);
}
public void unlock() {
   sync.release(1);
}
```
排除掉一些细节整体地分析acquire方法逻辑其直接实现是在AQS内部调用了tryAcquire和acquireQueued这是两个需要搞清楚的基本部分。
```
public final void acquire(int arg) {
   if (!tryAcquire(arg) &amp;&amp;
       acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
       selfInterrupt();
}
```
首先我们来看看tryAcquire。在ReentrantLock中tryAcquire逻辑实现在NonfairSync和FairSync中分别提供了进一步的非公平或公平性方法而AQS内部tryAcquire仅仅是个接近未实现的方法直接抛异常这是留个实现者自己定义的操作。
我们可以看到公平性在ReentrantLock构建时如何指定的具体如下
```
public ReentrantLock() {
       sync = new NonfairSync(); // 默认是非公平的
   }
   public ReentrantLock(boolean fair) {
       sync = fair ? new FairSync() : new NonfairSync();
   }
```
以非公平的tryAcquire为例其内部实现了如何配合状态与CAS获取锁注意对比公平版本的tryAcquire它在锁无人占有时并不检查是否有其他等待者这里体现了非公平的语义。
```
final boolean nonfairTryAcquire(int acquires) {
   final Thread current = Thread.currentThread();
   int c = getState();// 获取当前AQS内部状态量
   if (c == 0) { // 0表示无人占有则直接用CAS修改状态位
    if (compareAndSetState(0, acquires)) {// 不检查排队情况,直接争抢
        setExclusiveOwnerThread(current);  //并设置当前线程独占锁
        return true;
    }
   } else if (current == getExclusiveOwnerThread()) { //即使状态不是0也可能当前线程是锁持有者因为这是再入锁
    int nextc = c + acquires;
    if (nextc &lt; 0) // overflow
        throw new Error(&quot;Maximum lock count exceeded&quot;);
    setState(nextc);
    return true;
}
return false;
}
```
接下来我再来分析acquireQueued如果前面的tryAcquire失败代表着锁争抢失败进入排队竞争阶段。这里就是我们所说的利用FIFO队列实现线程间对锁的竞争的部分算是是AQS的核心逻辑。
当前线程会被包装成为一个排他模式的节点EXCLUSIVE通过addWaiter方法添加到队列中。acquireQueued的逻辑简要来说就是如果当前节点的前面是头节点则试图获取锁一切顺利则成为新的头节点否则有必要则等待具体处理逻辑请参考我添加的注释。
```
final boolean acquireQueued(final Node node, int arg) {
     boolean interrupted = false;
     try {
    for (;;) {// 循环
        final Node p = node.predecessor();// 获取前一个节点
        if (p == head &amp;&amp; tryAcquire(arg)) { // 如果前一个节点是头结点表示当前节点合适去tryAcquire
            setHead(node); // acquire成功则设置新的头节点
            p.next = null; // 将前面节点对当前节点的引用清空
            return interrupted;
        }
        if (shouldParkAfterFailedAcquire(p, node)) // 检查是否失败后需要park
            interrupted |= parkAndCheckInterrupt();
    }
      } catch (Throwable t) {
    cancelAcquire(node);// 出现异常,取消
    if (interrupted)
           selfInterrupt();
    throw t;
     }
}
```
到这里线程试图获取锁的过程基本展现出来了tryAcquire是按照特定场景需要开发者去实现的部分而线程间竞争则是AQS通过Waiter队列与acquireQueued提供的在release方法中同样会对队列进行对应操作。
今天我介绍了Atomic数据类型的底层技术CAS并通过实例演示了如何在产品代码中利用CAS最后介绍了并发包的基础技术AQS希望对你有所帮助。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗今天布置一个源码阅读作业AQS中Node的waitStatus有什么作用
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,248 @@
<audio id="audio" title="第23讲 | 请介绍类加载过程,什么是双亲委派模型?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7e/ca/7e181bb3f54444690dbc5bd2c28b1aca.mp3"></audio>
Java通过引入字节码和JVM机制提供了强大的跨平台能力理解Java的类加载机制是深入Java开发的必要条件也是个面试考察热点。
今天我要问你的问题是,请介绍类加载过程,什么是双亲委派模型?
## 典型回答
一般来说我们把Java的类加载过程分为三个主要步骤加载、链接、初始化具体行为在[Java虚拟机规范](https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html)里有非常详细的定义。
首先是加载阶段Loading它是Java将字节码数据从不同的数据源读取到JVM中并映射为JVM认可的数据结构Class对象这里的数据源可能是各种各样的形态如jar文件、class文件甚至是网络数据源等如果输入数据不是ClassFile的结构则会抛出ClassFormatError。
加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程。
第二阶段是链接Linking这是核心的步骤简单说是把原始的类定义信息平滑地转化入JVM运行的过程中。这里可进一步细分为三个步骤
<li>
验证Verification这是虚拟机安全的重要保障JVM需要核验字节信息是符合Java虚拟机规范的否则就被认为是VerifyError这样就防止了恶意信息或者不合规的信息危害JVM的运行验证阶段有可能触发更多class的加载。
</li>
<li>
准备Preparation创建类或接口中的静态变量并初始化静态变量的初始值。但这里的“初始化”和下面的显式初始化阶段是有区别的侧重点在于分配所需要的内存空间不会去执行更进一步的JVM指令。
</li>
<li>
解析Resolution在这一步会将常量池中的符号引用symbolic reference替换为直接引用。在[Java虚拟机规范](https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html#jvms-5.4.3)中,详细介绍了类、接口、方法和字段等各个方面的解析。
</li>
最后是初始化阶段initialization这一步真正去执行类初始化的代码逻辑包括静态字段赋值的动作以及执行类定义中的静态初始化块内的逻辑编译器在编译阶段就会把这部分逻辑整理好父类型的初始化逻辑优先于当前类型的逻辑。
再来谈谈双亲委派模型简单说就是当类加载器Class-Loader试图加载某个类型的时候除非父加载器找不到相应类型否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载Java类型。
## 考点分析
今天的问题是关于JVM类加载方面的基础问题我前面给出的回答参考了Java虚拟机规范中的主要条款。如果你在面试中回答这个问题在这个基础上还可以举例说明。
我们来看一个经典的延伸问题,准备阶段谈到静态变量,那么对于常量和不同静态变量有什么区别?
需要明确的是,没有人能够精确的理解和记忆所有信息,如果碰到这种问题,有直接答案当然最好;没有的话,就说说自己的思路。
我们定义下面这样的类型,分别提供了普通静态变量、静态常量,常量又考虑到原始类型和引用类型可能有区别。
```
public class CLPreparation {
public static int a = 100;
public static final int INT_CONSTANT = 1000;
public static final Integer INTEGER_CONSTANT = Integer.valueOf(10000);
}
```
编译并反编译一下:
```
Javac CLPreparation.java
Javap v CLPreparation.class
```
可以在字节码中看到这样的额外初始化逻辑:
```
0: bipush     100
     2: putstatic #2               // Field a:I
     5: sipush     10000
     8: invokestatic  #3 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
    11: putstatic #4                  // Field INTEGER_CONSTANT:Ljava/lang/Integer;
```
这能让我们更清楚普通原始类型静态变量和引用类型即使是常量是需要额外调用putstatic等JVM指令的这些是在显式初始化阶段执行而不是准备阶段调用而原始类型常量则不需要这样的步骤。
关于类加载过程的更多细节有非常多的优秀资料进行介绍你可以参考大名鼎鼎的《深入理解Java虚拟机》一本非常好的入门书籍。我的建议是不要仅看教程最好能够想出代码实例去验证自己对某个方面的理解和判断这样不仅能加深理解还能够在未来的应用开发中使用到。
其实,类加载机制的范围实在太大,我从开发和部署的不同角度,各选取了一个典型扩展问题供你参考:
<li>
如果要真正理解双亲委派模型需要理解Java中类加载器的架构和职责至少要懂具体有哪些内建的类加载器这些是我上面的回答里没有提到的以及如何自定义类加载器
</li>
<li>
从应用角度解决某些类加载问题例如我的Java程序启动较慢有没有办法尽量减小Java类加载的开销
</li>
另外需要注意的是在Java 9中Jigsaw项目为Java提供了原生的模块化支持内建的类加载器结构和机制发生了明显变化。我会对此进行讲解希望能够避免一些未来升级中可能发生的问题。
## 知识扩展
首先从架构角度一起来看看Java 8以前各种类加载器的结构下面是三种Oracle JDK内建的类加载器。
- 启动类加载器Bootstrap Class-Loader加载 jre/lib下面的jar文件如rt.jar。它是个超级公民即使是在开启了Security Manager的时候JDK仍赋予了它加载的程序AllPermission。
对于做底层开发的工程师有的时候可能不得不去试图修改JDK的基础代码也就是通常意义上的核心类库我们可以使用下面的命令行参数。
```
# 指定新的bootclasspath替换java.*包的内部实现
java -Xbootclasspath:&lt;your_boot_classpath&gt; your_App
# a意味着append将指定目录添加到bootclasspath后面
java -Xbootclasspath/a:&lt;your_dir&gt; your_App
# p意味着prepend将指定目录添加到bootclasspath前面
java -Xbootclasspath/p:&lt;your_dir&gt; your_App
```
用法其实很易懂,例如,使用最常见的 “/p”既然是前置就有机会替换个别基础类的实现。
我们一般可以使用下面方法获取父加载器但是在通常的JDK/JRE实现中扩展类加载器getParent()都只能返回null。
```
public final ClassLoader getParent()
```
- 扩展类加载器Extension or Ext Class-Loader负责加载我们放到jre/lib/ext/目录下面的jar包这就是所谓的extension机制。该目录也可以通过设置 “java.ext.dirs”来覆盖。
```
java -Djava.ext.dirs=your_ext_dir HelloWorld
```
- 应用类加载器Application or App Class-Loader就是加载我们最熟悉的classpath的内容。这里有一个容易混淆的概念系统System类加载器通常来说其默认就是JDK内建的应用类加载器但是它同样是可能修改的比如
```
java -Djava.system.class.loader=com.yourcorp.YourClassLoader HelloWorld
```
如果我们指定了这个参数JDK内建的应用类加载器就会成为定制加载器的父亲这种方式通常用在类似需要改变双亲委派模式的场景。
具体请参考下图:
<img src="https://static001.geekbang.org/resource/image/35/a1/35a3bc241d779ddcc357639547917ca1.png" alt="" />
至于前面被问到的双亲委派模型,参考这个结构图更容易理解。试想,如果不同类加载器都自己加载需要的某个类型,那么就会出现多次重复加载,完全是种浪费。
通常类加载机制有三个基本特征:
<li>
双亲委派模型。但不是所有类加载都遵守这个模型有的时候启动类加载器所加载的类型是可能要加载用户代码的比如JDK内部的ServiceProvider/[ServiceLoader](https://docs.oracle.com/javase/9/docs/api/java/util/ServiceLoader.html)机制用户可以在标准API框架上提供自己的实现JDK也需要提供些默认的参考实现。 例如Java 中JNDI、JDBC、文件系统、Cipher等很多方面都是利用的这种机制这种情况就不会用双亲委派模型去加载而是利用所谓的上下文加载器。
</li>
<li>
可见性,子类加载器可以访问父加载器加载的类型,但是反过来是不允许的,不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑。
</li>
<li>
单一性,由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器“邻居”间,同一类型仍然可以被加载多次,因为互相并不可见。
</li>
在JDK 9中由于Jigsaw项目引入了Java平台模块化系统JPMSJava SE的源代码被划分为一系列模块。
<img src="https://static001.geekbang.org/resource/image/15/79/15138305829ed15f45dd53ec38bd8379.png" alt="" />
类加载器,类文件容器等都发生了非常大的变化,我这里总结一下:
- 前面提到的-Xbootclasspath参数不可用了。API已经被划分到具体的模块所以上文中利用“-Xbootclasspath/p”替换某个Java核心类型代码实际上变成了对相应的模块进行的修补可以采用下面的解决方案
首先确认要修改的类文件已经编译好并按照对应模块假设是java.base结构存放 然后,给模块打补丁:
```
java --patch-module java.base=your_patch yourApp
```
<li>
扩展类加载器被重命名为平台类加载器Platform Class-Loader而且extension机制则被移除。也就意味着如果我们指定java.ext.dirs环境变量或者lib/ext目录存在JVM将直接返回**错误**建议解决办法就是将其放入classpath里。
</li>
<li>
部分不需要AllPermission的Java基础模块被降级到平台类加载器中相应的权限也被更精细粒度地限制起来。
</li>
<li>
rt.jar和tools.jar同样是被移除了JDK的核心类库以及相关资源被存储在jimage文件中并通过新的JRT文件系统访问而不是原有的JAR文件系统。虽然看起来很惊人但幸好对于大部分软件的兼容性影响其实是有限的更直接地影响是IDE等软件通常只要升级到新版本就可以了。
</li>
<li>
增加了Layer的抽象 JVM启动默认创建BootLayer开发者也可以自己去定义和实例化Layer可以更加方便的实现类似容器一般的逻辑抽象。
</li>
结合了Layer目前的JVM内部结构就变成了下面的层次内建类加载器都在BootLayer中其他Layer内部有自定义的类加载器不同版本模块可以同时工作在不同的Layer。
<img src="https://static001.geekbang.org/resource/image/20/00/20a6a22ae11c1be3e08c6fa0bc8a8c00.png" alt="" />
谈到类加载器,绕不过的一个话题是自定义类加载器,常见的场景有:
<li>
实现类似进程内隔离,类加载器实际上用作不同的命名空间,以提供类似容器、模块化的效果。例如,两个模块依赖于某个类库的不同版本,如果分别被不同的容器加载,就可以互不干扰。这个方面的集大成者是[Java EE](http://www.oracle.com/technetwork/java/javaee/overview/index.html)和[OSGI](https://en.wikipedia.org/wiki/OSGi)、[JPMS](https://en.wikipedia.org/wiki/Java_Platform_Module_System)等框架。
</li>
<li>
应用需要从不同的数据源获取类定义信息,例如网络数据源,而不是本地文件系统。
</li>
<li>
或者是需要自己操纵字节码,动态修改或者生成类型。
</li>
我们可以总体上简单理解自定义类加载过程:
<li>
通过指定名称,找到其二进制实现,这里往往就是自定义类加载器会“定制”的部分,例如,在特定数据源根据名字获取字节码,或者修改或生成字节码。
</li>
<li>
然后创建Class对象并完成类加载过程。二进制信息到Class对象的转换通常就依赖[defineClass](https://docs.oracle.com/javase/9/docs/api/java/lang/ClassLoader.html#defineClass-java.lang.String-byte:A-int-int-)我们无需自己实现它是final方法。有了Class对象后续完成加载过程就顺理成章了。
</li>
具体实现我建议参考这个[用例](http://www.baeldung.com/java-classloaders)。
我在[专栏第1讲](http://time.geekbang.org/column/article/6845)中就提到了由于字节码是平台无关抽象而不是机器码所以Java需要类加载和解释、编译这些都导致Java启动变慢。谈了这么多类加载有没有什么通用办法不需要代码和其他工作量就可以降低类加载的开销呢
这个,可以有。
<li>
在第1讲中提到的AOT相当于直接编译成机器码降低的其实主要是解释和编译开销。但是其目前还是个试验特性支持的平台也有限比如JDK 9仅支持Linux x64所以局限性太大先暂且不谈。
</li>
<li>
还有就是较少人知道的AppCDSApplication Class-Data SharingCDS在Java 5中被引进但仅限于Bootstrap Class-loader在8u40中实现了AppCDS支持其他的类加载器在目前2018年初发布的JDK 10中已经开源。
</li>
简单来说AppCDS基本原理和工作过程是
首先JVM将类信息加载 解析成为元数据并根据是否需要修改将其分类为Read-Only部分和Read-Write部分。然后将这些元数据直接存储在文件系统中作为所谓的Shared Archive。命令很简单
```
Java -Xshare:dump -XX:+UseAppCDS -XX:SharedArchiveFile=&lt;jsa&gt;  \
        -XX:SharedClassListFile=&lt;classlist&gt; -XX:SharedArchiveConfigFile=&lt;config_file&gt;
```
第二在应用程序启动时指定归档文件并开启AppCDS。
```
Java -Xshare:on -XX:+UseAppCDS -XX:SharedArchiveFile=&lt;jsa&gt; yourApp
```
通过上面的命令JVM会通过内存映射技术直接映射到相应的地址空间免除了类加载、解析等各种开销。
AppCDS改善启动速度非常明显传统的Java EE应用一般可以提高20%~30%以上实验中使用Spark KMeans负载20个slave可以提高11%的启动速度。
与此同时降低内存footprint因为同一环境的Java进程间可以共享部分数据结构。前面谈到的两个实验平均可以减少10%以上的内存消耗。
当然,也不是没有局限性,如果恰好大量使用了运行时动态类加载,它的帮助就有限了。
今天我梳理了一下类加载的过程并针对Java新版中类加载机制发生的变化进行了相对全面的总结最后介绍了一个改善类加载速度的特性希望对你有所帮助。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是谈谈什么是Jar Hell问题你有遇到过类似情况吗如何解决呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,212 @@
<audio id="audio" title="第24讲 | 有哪些方法可以在运行时动态生成一个Java类" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/29/de/2908eb214392b82719cb07031940e8de.mp3"></audio>
在开始今天的学习前,我建议你先复习一下[专栏第6讲](http://time.geekbang.org/column/article/7489)有关动态代理的内容。作为Java基础模块中的内容考虑到不同基础的同学以及一个循序渐进的学习过程我当时并没有在源码层面介绍动态代理的实现技术仅进行了相应的技术比较。但是有了[上一讲](http://time.geekbang.org/column/article/9946)的类加载的学习基础后,我想是时候该进行深入分析了。
今天我要问你的问题是有哪些方法可以在运行时动态生成一个Java类
## 典型回答
我们可以从常见的Java类来源分析通常的开发过程是开发者编写Java代码调用javac编译成class文件然后通过类加载机制载入JVM就成为应用运行时可以使用的Java类了。
从上面过程得到启发其中一个直接的方式是从源码入手可以利用Java程序生成一段源码然后保存到文件等下面就只需要解决编译问题了。
有一种笨办法直接用ProcessBuilder之类启动javac进程并指定上面生成的文件作为输入进行编译。最后再利用类加载器在运行时加载即可。
前面的方法本质上还是在当前程序进程之外编译的那么还有没有不这么low的办法呢
你可以考虑使用Java Compiler API这是JDK提供的标准API里面提供了与javac对等的编译器功能具体请参考[java.compiler](https://docs.oracle.com/javase/9/docs/api/javax/tools/package-summary.html)相关文档。
进一步思考我们一直围绕Java源码编译成为JVM可以理解的字节码换句话说只要是符合JVM规范的字节码不管它是如何生成的是不是都可以被JVM加载呢我们能不能直接生成相应的字节码然后交给类加载器去加载呢
当然也可以不过直接去写字节码难度太大通常我们可以利用Java字节码操纵工具和类库来实现比如在[专栏第6讲](http://time.geekbang.org/column/article/7489)中提到的[ASM](https://asm.ow2.io/)、[Javassist](http://www.javassist.org/)、cglib等。
## 考点分析
虽然曾经被视为黑魔法,但在当前复杂多变的开发环境中,在运行时动态生成逻辑并不是什么罕见的场景。重新审视我们谈到的动态代理,本质上不就是在特定的时机,去修改已有类型实现,或者创建新的类型。
明白了基本思路后,我还是围绕类加载机制进行展开,面试过程中面试官很可能从技术原理或实践的角度考察:
<li>
字节码和类加载到底是怎么无缝进行转换的?发生在整个类加载过程的哪一步?
</li>
<li>
如何利用字节码操纵技术,实现基本的动态代理逻辑?
</li>
<li>
除了动态代理,字节码操纵技术还有那些应用场景?
</li>
## 知识扩展
首先我们来理解一下类从字节码到Class对象的转换在类加载过程中这一步是通过下面的方法提供的功能或者defineClass的其他本地对等实现。
```
protected final Class&lt;?&gt; defineClass(String name, byte[] b, int off, int len,
                                 ProtectionDomain protectionDomain)
protected final Class&lt;?&gt; defineClass(String name, java.nio.ByteBuffer b,
                                 ProtectionDomain protectionDomain)
```
我这里只选取了最基础的两个典型的defineClass实现Java重载了几个不同的方法。
可以看出只要能够生成出规范的字节码不管是作为byte数组的形式还是放到ByteBuffer里都可以平滑地完成字节码到Java对象的转换过程。
JDK提供的defineClass方法最终都是本地代码实现的。
```
static native Class&lt;?&gt; defineClass1(ClassLoader loader, String name, byte[] b, int off, int len,
                                ProtectionDomain pd, String source);
static native Class&lt;?&gt; defineClass2(ClassLoader loader, String name, java.nio.ByteBuffer b,
                                int off, int len, ProtectionDomain pd,
                                String source);
```
更进一步我们来看看JDK dynamic proxy的[实现代码](http://hg.openjdk.java.net/jdk/jdk/file/29169633327c/src/java.base/share/classes/java/lang/reflect/Proxy.java)。你会发现对应逻辑是实现在ProxyBuilder这个静态内部类中ProxyGenerator生成字节码并以byte数组的形式保存然后通过调用Unsafe提供的defineClass入口。
```
byte[] proxyClassFile = ProxyGenerator.generateProxyClass(
    proxyName, interfaces.toArray(EMPTY_CLASS_ARRAY), accessFlags);
try {
Class&lt;?&gt; pc = UNSAFE.defineClass(proxyName, proxyClassFile,
                                 0, proxyClassFile.length,
                                loader, null);
reverseProxyCache.sub(pc).putIfAbsent(loader, Boolean.TRUE);
return pc;
} catch (ClassFormatError e) {
// 如果出现ClassFormatError很可能是输入参数有问题比如ProxyGenerator有bug
}
```
前面理顺了二进制的字节码信息到Class对象的转换过程似乎我们还没有分析如何生成自己需要的字节码接下来一起来看看相关的字节码操纵逻辑。
JDK内部动态代理的逻辑可以参考[java.lang.reflect.ProxyGenerator](http://hg.openjdk.java.net/jdk/jdk/file/29169633327c/src/java.base/share/classes/java/lang/reflect/ProxyGenerator.java)的内部实现。我觉得可以认为这是种另类的字节码操纵技术,其利用了[DataOutputStrem](https://docs.oracle.com/javase/9/docs/api/java/io/DataOutputStream.html)提供的能力配合hard-coded的各种JVM指令实现方法生成所需的字节码数组。你可以参考下面的示例代码。
```
private void codeLocalLoadStore(int lvar, int opcode, int opcode_0,
                            DataOutputStream out)
throws IOException
{
assert lvar &gt;= 0 &amp;&amp; lvar &lt;= 0xFFFF;
// 根据变量数值以不同格式dump操作码
   if (lvar &lt;= 3) {
    out.writeByte(opcode_0 + lvar);
} else if (lvar &lt;= 0xFF) {
    out.writeByte(opcode);
    out.writeByte(lvar &amp; 0xFF);
} else {
    // 使用宽指令修饰符如果变量索引不能用无符号byte
    out.writeByte(opc_wide);
    out.writeByte(opcode);
    out.writeShort(lvar &amp; 0xFFFF);
}
}
```
这种实现方式的好处是没有太多依赖关系,简单实用,但是前提是你需要懂各种[JVM指令](https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5),知道怎么处理那些偏移地址等,实际门槛非常高,所以并不适合大多数的普通开发场景。
幸好Java社区专家提供了各种从底层到更高抽象水平的字节码操作类库我们不需要什么都自己从头做。JDK内部就集成了ASM类库虽然并未作为公共API暴露出来但是它广泛应用在如[java.lang.instrumentation](https://docs.oracle.com/javase/9/docs/api/java/lang/instrument/package-summary.html) API底层实现或者[Lambda Call Site](https://docs.oracle.com/javase/9/docs/api/java/lang/invoke/CallSite.html)生成的内部逻辑中这些代码的实现我就不在这里展开了如果你确实有兴趣或有需要可以参考类似LamdaForm的字节码生成逻辑[java.lang.invoke.InvokerBytecodeGenerator](http://hg.openjdk.java.net/jdk/jdk/file/29169633327c/src/java.base/share/classes/java/lang/invoke/InvokerBytecodeGenerator.java)[](http://hg.openjdk.java.net/jdk/jdk/file/29169633327c/src/java.base/share/classes/java/lang/invoke/InvokerBytecodeGenerator.java)
从相对实用的角度思考一下,实现一个简单的动态代理,都要做什么?如何使用字节码操纵技术,走通这个过程呢?
对于一个普通的Java动态代理其实现过程可以简化成为
<li>
提供一个基础的接口作为被调用类型com.mycorp.HelloImpl和代理类之间的统一入口如com.mycorp.Hello。
</li>
<li>
实现[InvocationHandler](https://docs.oracle.com/javase/9/docs/api/java/lang/reflect/InvocationHandler.html)对代理对象方法的调用会被分派到其invoke方法来真正实现动作。
</li>
<li>
通过Proxy类调用其newProxyInstance方法生成一个实现了相应基础接口的代理类实例可以看下面的方法签名。
</li>
```
public static Object newProxyInstance(ClassLoader loader,
                                  Class&lt;?&gt;[] interfaces,
                                  InvocationHandler h)
```
我们分析一下,动态代码生成是具体发生在什么阶段呢?
不错就是在newProxyInstance生成代理类实例的时候。我选取了JDK自己采用的ASM作为示例一起来看看用ASM实现的简要过程请参考下面的示例代码片段。
第一步生成对应的类其实和我们去写Java代码很类似只不过改为用ASM方法和指定参数代替了我们书写的源码。
```
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
cw.visit(V1_8,                      // 指定Java版本
    ACC_PUBLIC,              // 说明是public类型
       &quot;com/mycorp/HelloProxy&quot;, // 指定包和类的名称
    null,                    // 签名null表示不是泛型
    &quot;java/lang/Object&quot;,              // 指定父类
    new String[]{ &quot;com/mycorp/Hello&quot; }); // 指定需要实现的接口
```
更进一步,我们可以按照需要为代理对象实例,生成需要的方法和逻辑。
```
MethodVisitor mv = cw.visitMethod(
    ACC_PUBLIC,             // 声明公共方法
    &quot;sayHello&quot;,              // 方法名称
    &quot;()Ljava/lang/Object;&quot;, // 描述符
    null,                    // 签名null表示不是泛型
    null);                      // 可能抛出的异常,如果有,则指定字符串数组
mv.visitCode();
// 省略代码逻辑实现细节
cw.visitEnd();                      // 结束类字节码生成
```
上面的代码虽然有些晦涩但总体还是能多少理解其用意不同的visitX方法提供了创建类型创建各种方法等逻辑。ASM API广泛的使用了[Visitor](https://en.wikipedia.org/wiki/Visitor_pattern)模式,如果你熟悉这个模式,就会知道它所针对的场景是将算法和对象结构解耦,非常适合字节码操纵的场合,因为我们大部分情况都是依赖于特定结构修改或者添加新的方法、变量或者类型等。
按照前面的分析字节码操作最后大都应该是生成byte数组ClassWriter提供了一个简便的方法。
```
cw.toByteArray();
```
然后就可以进入我们熟知的类加载过程了我就不再赘述了如果你对ASM的具体用法感兴趣可以参考这个[教程](http://www.baeldung.com/java-asm)。
最后一个问题,字节码操纵技术,除了动态代理,还可以应用在什么地方?
这个技术似乎离我们日常开发遥远,但其实已经深入到各个方面,也许很多你现在正在使用的框架、工具就应用该技术,下面是我能想到的几个常见领域。
<li>
各种Mock框架
</li>
<li>
ORM框架
</li>
<li>
IOC容器
</li>
<li>
部分Profiler工具或者运行时诊断工具等
</li>
<li>
生成形式化代码的工具
</li>
甚至可以认为,字节码操纵技术是工具和基础框架必不可少的部分,大大减少了开发者的负担。
今天我们探讨了更加深入的类加载和字节码操作方面技术。为了理解底层的原理,我选取的例子是比较偏底层的、能力全面的类库,如果实际项目中需要进行基础的字节码操作,可以考虑使用更加高层次视角的类库,例如[Byte Buddy](http://bytebuddy.net/#/)等。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗?试想,假如我们有这样一个需求,需要添加某个功能,例如对某类型资源如网络通信的消耗进行统计,重点要求是,不开启时必须是**零开销,而不是低开销,**可以利用我们今天谈到的或者相关的技术实现吗?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,118 @@
<audio id="audio" title="第25讲 | 谈谈JVM内存区域的划分哪些区域可能发生OutOfMemoryError?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/24/56/247480f61e8f67524a4e6b1a368e8156.mp3"></audio>
今天我将从内存管理的角度进一步探索Java虚拟机JVM。垃圾收集机制为我们打理了很多繁琐的工作大大提高了开发的效率但是垃圾收集也不是万能的懂得JVM内部的内存结构、工作机制是设计高扩展性应用和诊断运行时问题的基础也是Java工程师进阶的必备能力。
今天我要问你的问题是谈谈JVM内存区域的划分哪些区域可能发生OutOfMemoryError
## 典型回答
通常可以把JVM内存区域分为下面几个方面其中有的区域是以线程为单位而有的区域则是整个JVM进程唯一的。
首先,**程序计数器**PCProgram Counter Register。在JVM规范中每个线程都有它自己的程序计数器并且任何时间一个线程都只有一个方法在执行也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址或者如果是在执行本地方法则是未指定值undefined
第二,**Java虚拟机栈**Java Virtual Machine Stack早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈其内部保存一个个的栈帧Stack Frame对应着一次次的Java方法调用。
前面谈程序计数器时提到了当前方法同理在一个时间点对应的只会有一个活动的栈帧通常叫作当前帧方法所在的类叫作当前类。如果在该方法中调用了其他方法对应的新的栈帧会被创建出来成为新的当前帧一直到它返回结果或者执行结束。JVM直接对Java栈的操作只有两个就是对栈帧的压栈和出栈。
栈帧中存储着局部变量表、操作数operand栈、动态链接、方法正常退出或者异常退出的定义等。
第三,**堆**Heap它是Java内存管理的核心区域用来放置Java对象实例几乎所有创建的Java对象实例都是被直接分配在堆上。堆被所有的线程共享在虚拟机启动时我们指定的“Xmx”之类参数就是用来指定最大堆空间等指标。
理所当然,堆也是垃圾收集器重点照顾的区域,所以堆内空间还会被不同的垃圾收集器进行进一步的细分,最有名的就是新生代、老年代的划分。
第四,**方法区**Method Area。这也是所有线程共享的一块内存区域用于存储所谓的元Meta数据例如类结构信息以及对应的运行时常量池、字段、方法代码等。
由于早期的Hotspot JVM实现很多人习惯于将方法区称为永久代Permanent Generation。Oracle JDK 8中将永久代移除同时增加了元数据区Metaspace
第五,**运行时常量池**Run-Time Constant Pool这是方法区的一部分。如果仔细分析过反编译的类文件结构你能看到版本号、字段、方法、超类、接口等各种信息还有一项信息就是常量池。Java的常量池可以存放各种常量信息不管是编译期生成的各种字面量还是需要在运行时决定的符号引用所以它比一般语言的符号表存储的信息更加宽泛。
第六,**本地方法栈**Native Method Stack。它和Java虚拟机栈是非常相似的支持对本地方法的调用也是每个线程都会创建一个。在Oracle Hotspot JVM中本地方法栈和Java虚拟机栈是在同一块儿区域这完全取决于技术实现的决定并未在规范中强制。
## 考点分析
这是个JVM领域的基础题目我给出的答案依据的是[JVM规范](https://docs.oracle.com/javase/specs/jvms/se9/html/jvms-2.html#jvms-2.5)中运行时数据区定义,这也和大多数书籍和资料解读的角度类似。
JVM内部的概念庞杂对于初学者比较晦涩我的建议是在工作之余还是要去阅读经典书籍比如我推荐过多次的《深入理解Java虚拟机》。
今天这一讲作为Java虚拟机内存管理的开篇我会侧重于
<li>
分析广义上的JVM内存结构或者说Java进程内存结构。
</li>
<li>
谈到Java内存模型不可避免的要涉及OutOfMemoryOOM问题那么在Java里面存在哪些种OOM的可能性分别对应哪个内存区域的异常状况呢
</li>
注意具体JVM的内存结构其实取决于其实现不同厂商的JVM或者同一厂商发布的不同版本都有可能存在一定差异。我在下面的分析中还会介绍Oracle Hotspot JVM的部分设计变化。
## 知识扩展
首先为了让你有个更加直观、清晰的印象我画了一个简单的内存结构图里面展示了我前面提到的堆、线程栈等区域并从数量上说明了什么是线程私有例如程序计数器、Java栈等以及什么是Java进程唯一。另外还额外划分出了直接内存等区域。<br>
<img src="https://static001.geekbang.org/resource/image/36/bc/360b8f453e016cb641208a6a8fb589bc.png" alt="">
这张图反映了实际中Java进程内存占用与规范中定义的JVM运行时数据区之间的差别它可以看作是运行时数据区的一个超集。毕竟理论上的视角和现实中的视角是有区别的规范侧重的是通用的、无差别的部分而对于应用开发者来说只要是Java进程在运行时会占用都会影响到我们的工程实践。
我这里简要介绍两点区别:
<li>
直接内存Direct Memory区域它就是我在[专栏第12讲](http://time.geekbang.org/column/article/8393)中谈到的Direct Buffer所直接分配的内存也是个容易出现问题的地方。尽管在JVM工程师的眼中并不认为它是JVM内部内存的一部分也并未体现JVM内存模型中。
</li>
<li>
JVM本身是个本地程序还需要其他的内存去完成各种基本任务比如JIT Compiler在运行时对热点方法进行编译就会将编译后的方法储存在Code Cache里面GC等功能需要运行在本地线程之中类似部分都需要占用内存空间。这些是实现JVM JIT等功能的需要但规范中并不涉及。
</li>
如果深入到JVM的实现细节你会发现一些结论似乎有些模棱两可比如
- Java对象是不是都创建在堆上的呢
我注意到有一些观点,认为通过[逃逸分析](https://en.wikipedia.org/wiki/Escape_analysis)JVM会在栈上分配那些不会逃逸的对象这在理论上是可行的但是取决于JVM设计者的选择。据我所知Oracle Hotspot JVM中并未这么做这一点在逃逸分析相关的[文档](https://docs.oracle.com/javase/8/docs/technotes/guides/vm/performance-enhancements-7.html#escapeAnalysis)里已经说明,所以可以明确所有的对象实例都是创建在堆上。
- 目前很多书籍还是基于JDK 7以前的版本JDK已经发生了很大变化Intern字符串的缓存和静态变量曾经都被分配在永久代上而永久代已经被元数据区取代。但是Intern字符串缓存和静态变量并不是被转移到元数据区而是直接在堆上分配所以这一点同样符合前面一点的结论对象实例都是分配在堆上。
接下来我们来看看什么是OOM问题它可能在哪些内存区域发生
首先OOM如果通俗点儿说就是JVM内存不够用了javadoc中对[OutOfMemoryError](https://docs.oracle.com/javase/9/docs/api/java/lang/OutOfMemoryError.html)的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。
这里面隐含着一层意思是在抛出OutOfMemoryError之前通常垃圾收集器会被触发尽其所能去清理出空间例如
<li>
我在[专栏第4讲](http://time.geekbang.org/column/article/6970)的引用机制分析中已经提到了JVM会去尝试回收软引用指向的对象等。
</li>
<li>
在[java.nio.BIts.reserveMemory()](http://hg.openjdk.java.net/jdk/jdk/file/9f62267e79df/src/java.base/share/classes/java/nio/Bits.java) 方法中我们能清楚的看到System.gc()会被调用以清理空间这也是为什么在大量使用NIO的Direct Buffer之类时通常建议不要加下面的参数毕竟是个最后的尝试有可能避免一定的内存不足问题。
</li>
```
-XX:+DisableExplicitGC
```
当然也不是在任何情况下垃圾收集器都会被触发的比如我们去分配一个超大对象类似一个超大数组超过堆的最大值JVM可以判断出垃圾收集并不能解决这个问题所以直接抛出OutOfMemoryError。
从我前面分析的数据区的角度除了程序计数器其他区域都有可能会因为可能的空间不足发生OutOfMemoryError简单总结如下
<li>
堆内存不足是最常见的OOM原因之一抛出的错误信息是“java.lang.OutOfMemoryError:Java heap space”原因可能千奇百怪例如可能存在内存泄漏问题也很有可能就是堆的大小不合理比如我们要处理比较可观的数据量但是没有显式指定JVM堆大小或者指定数值偏小或者出现JVM处理引用不及时导致堆积起来内存无法释放等。
</li>
<li>
而对于Java虚拟机栈和本地方法栈这里要稍微复杂一点。如果我们写一段程序不断的进行递归调用而且没有退出条件就会导致不断地进行压栈。类似这种情况JVM实际会抛出StackOverFlowError当然如果JVM试图去扩展栈空间的的时候失败则会抛出OutOfMemoryError。
</li>
<li>
对于老版本的Oracle JDK因为永久代的大小是有限的并且JVM对永久代垃圾回收常量池回收、卸载不再需要的类型非常不积极所以当我们不断添加新类型的时候永久代出现OutOfMemoryError也非常多见尤其是在运行时存在大量动态类型生成的场合类似Intern字符串缓存占用太多空间也会导致OOM问题。对应的异常信息会标记出来和永久代相关“java.lang.OutOfMemoryError: PermGen space”。
</li>
<li>
随着元数据区的引入方法区内存已经不再那么窘迫所以相应的OOM有所改观出现OOM异常信息则变成了“java.lang.OutOfMemoryError: Metaspace”。
</li>
<li>
直接内存不足也会导致OOM这个已经[专栏第11讲](http://time.geekbang.org/column/article/8369)介绍过。
</li>
今天是JVM内存部分的第一讲算是我们先进行了热身准备我介绍了主要的内存区域以及在不同版本Hotspot JVM内部的变化并且分析了各区域是否可能产生OutOfMemoryError以及OOME发生的典型情况。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是我在试图分配一个100M bytes大数组的时候发生了OOME但是GC日志显示明明堆上还有远不止100M的空间你觉得可能问题的原因是什么想要弄清楚这个问题还需要什么信息呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,229 @@
<audio id="audio" title="第26讲 | 如何监控和诊断JVM堆内和堆外内存使用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/80/fe/80d76003a3db0d8ae71532c8a3eeedfe.mp3"></audio>
上一讲我介绍了JVM内存区域的划分总结了相关的一些概念今天我将结合JVM参数、工具等方面进一步分析JVM内存结构包括外部资料相对较少的堆外部分。
今天我要问你的问题是如何监控和诊断JVM堆内和堆外内存使用
## 典型回答
了解JVM内存的方法有很多具体能力范围也有区别简单总结如下
- 可以使用综合性的图形化工具如JConsole、VisualVM注意从Oracle JDK 9开始VisualVM已经不再包含在JDK安装包中等。这些工具具体使用起来相对比较直观直接连接到Java进程然后就可以在图形化界面里掌握内存使用情况。
以JConsole为例其内存页面可以显示常见的**堆内存**和**各种堆外部分**使用状态。
<li>
也可以使用命令行工具进行运行时查询如jstat和jmap等工具都提供了一些选项可以查看堆、方法区等使用数据。
</li>
<li>
或者也可以使用jmap等提供的命令生成堆转储Heap Dump文件然后利用jhat或Eclipse MAT等堆转储分析工具进行详细分析。
</li>
<li>
如果你使用的是Tomcat、Weblogic等Java EE服务器这些服务器同样提供了内存管理相关的功能。
</li>
<li>
另外从某种程度上来说GC日志等输出同样包含着丰富的信息。
</li>
这里有一个相对特殊的部分就是是堆外内存中的直接内存前面的工具基本不适用可以使用JDK自带的Native Memory TrackingNMT特性它会从JVM本地内存分配的角度进行解读。
## 考点分析
今天选取的问题是Java内存管理相关的基础实践对于普通的内存问题掌握上面我给出的典型工具和方法就足够了。这个问题也可以理解为考察两个基本方面能力第一你是否真的理解了JVM的内部结构第二具体到特定内存区域应该使用什么工具或者特性去定位可以用什么参数调整。
对于JConsole等工具的使用细节我在专栏里不再赘述如果你还没有接触过你可以参考[JConsole官方教程](https://docs.oracle.com/javase/7/docs/technotes/guides/management/jconsole.html)。我这里特别推荐[Java Mission Control](http://www.oracle.com/technetwork/java/javaseproducts/mission-control/java-mission-control-1998576.html)JMC这是一个非常强大的工具不仅仅能够使用[JMX](https://en.wikipedia.org/wiki/Java_Management_Extensions)进行普通的管理、监控任务,还可以配合[Java Flight Recorder](https://docs.oracle.com/javacomponents/jmc-5-4/jfr-runtime-guide/about.htm#JFRUH171)JFR技术以非常低的开销收集和分析JVM底层的Profiling和事件等信息。目前 Oracle已经将其开源如果你有兴趣请可以查看OpenJDK的[Mission Control](http://openjdk.java.net/projects/jmc/)项目。
关于内存监控与诊断我会在知识扩展部分结合JVM参数和特性尽量从庞杂的概念和JVM参数选项中梳理出相对清晰的框架
<li>
细化对各部分内存区域的理解,堆内结构是怎样的?如何通过参数调整?
</li>
<li>
堆外内存到底包括哪些部分?具体大小受哪些因素影响?
</li>
## 知识扩展
今天的分析我会结合相关JVM参数和工具进行对比以加深你对内存区域更细粒度的理解。
首先,堆内部是什么结构?
对于堆内存我在上一讲介绍了最常见的新生代和老年代的划分其内部结构随着JVM的发展和新GC方式的引入可以有不同角度的理解下图就是年代视角的堆结构示意图。<br />
<img src="https://static001.geekbang.org/resource/image/72/89/721e97abc93449fbdb4c071f7b3b5289.png" alt="" />
你可以看到按照通常的GC年代方式划分Java堆内分为
1.新生代
新生代是大部分对象创建和销毁的区域在通常的Java应用中绝大部分对象生命周期都是很短暂的。其内部又分为Eden区域作为对象初始分配的区域两个Survivor有时候也叫from、to区域被用来放置从Minor GC中保留下来的对象。
<li>
JVM会随意选取一个Survivor区域作为“to”然后会在GC过程中进行区域间拷贝也就是将Eden中存活下来的对象和from区域的对象拷贝到这个“to”区域。这种设计主要是为了防止内存的碎片化并进一步清理无用对象。
</li>
<li>
<p>从内存模型而不是垃圾收集的角度对Eden区域继续进行划分Hotspot JVM还有一个概念叫做Thread Local Allocation BufferTLAB据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计。这是JVM为每个线程分配的一个私有缓存区域否则多线程同时分配内存时为避免操作同一地址可能需要使用加锁等机制进而影响分配速度你可以参考下面的示意图。从图中可以看出TLAB仍然在堆上它是分配在Eden区域内的。其内部结构比较直观易懂start、end就是起始地址top指针则表示已经分配到哪里了。所以我们分配新对象JVM就会移动top当top和end相遇时即表示该缓存已满JVM会试图再从Eden里分配一块儿。<br />
<img src="https://static001.geekbang.org/resource/image/f5/bd/f546839e98ea5d43b595235849b0f2bd.png" alt="" /></p>
</li>
2.老年代
放置长生命周期的对象通常都是从Survivor区域拷贝过来的对象。当然也有特殊情况我们知道普通的对象会被分配在TLAB上如果对象较大JVM会试图直接分配在Eden其他位置上如果对象太大完全无法在新生代找到足够长的连续空闲空间JVM就会直接分配到老年代。
3.永久代
这部分就是早期Hotspot JVM的方法区实现方式了储存Java类元数据、常量池、Intern字符串缓存在JDK 8之后就不存在永久代这块儿了。
那么我们如何利用JVM参数直接影响堆和内部区域的大小呢我来简单总结一下
- 最大堆体积
```
-Xmx value
```
- 初始的最小堆体积
```
-Xms value
```
- 老年代和新生代的比例
```
-XX:NewRatio=value
```
默认情况下这个数值是2意味着老年代是新生代的2倍大换句话说新生代是堆大小的1/3。
- 当然,也可以不用比例的方式调整新生代的大小,直接指定下面的参数,设定具体的内存大小数值。
```
-XX:NewSize=value
```
- Eden和Survivor的大小是按照比例设置的如果SurvivorRatio是8那么Survivor区域就是Eden的1/8大小也就是新生代的1/10因为YoungGen=Eden + 2*SurvivorJVM参数格式是
```
-XX:SurvivorRatio=value
```
- TLAB当然也可以调整JVM实现了复杂的适应策略如果你有兴趣可以参考这篇[说明](https://blogs.oracle.com/jonthecollector/the-real-thing)。
不知道你有没有注意到我在年代视角的堆结构示意图也就是第一张图中还标记出了Virtual区域这是块儿什么区域呢
在JVM内部如果Xms小于Xmx堆的大小并不会直接扩展到其上限也就是说保留的空间reserved大于实际能够使用的空间committed。当内存需求不断增长的时候JVM会逐渐扩展新生代等区域的大小所以Virtual区域代表的就是暂时不可用uncommitted的空间。
第二分析完堆内空间我们一起来看看JVM堆外内存到底包括什么
在JMC或JConsole的内存管理界面会统计部分非堆内存但提供的信息相对有限下图就是JMC活动内存池的截图。<br />
<img src="https://static001.geekbang.org/resource/image/fa/2e/fa491795ffe21c1f49982de8b7810c2e.png" alt="" />
接下来我会依赖NMT特性对JVM进行分析它所提供的详细分类信息非常有助于理解JVM内部实现。
首先来做些准备工作开启NMT并选择summary模式
```
-XX:NativeMemoryTracking=summary
```
为了方便获取和对比NMT输出选择在应用退出时打印NMT统计信息
```
-XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics
```
然后执行一个简单的在标准输出打印HelloWorld的程序就可以得到下面的输出<br />
<img src="https://static001.geekbang.org/resource/image/55/bb/55f1c7f0550adbbcc885c97a4dd426bb.png" alt="" />
我来仔细分析一下NMT所表征的JVM本地内存使用
<li>
第一部分非常明显是Java堆我已经分析过使用什么参数调整不再赘述。
</li>
<li>
第二部分是Class内存占用它所统计的就是Java类元数据所占用的空间JVM可以通过类似下面的参数调整其大小
</li>
```
-XX:MaxMetaspaceSize=value
```
对于本例因为HelloWorld没有什么用户类库所以其内存占用主要是启动类加载器Bootstrap加载的核心类库。你可以使用下面的小技巧调整启动类加载器元数据区这主要是为了对比以加深理解也许只有在hack JDK时才有实际意义。
```
-XX:InitialBootClassLoaderMetaspaceSize=30720
```
<li>下面是Thread这里既包括Java线程如程序主线程、Cleaner线程等也包括GC等本地线程。你有没有注意到即使是一个HelloWorld程序这个线程数量竟然还有25。似乎有很多浪费设想我们要用Java作为Serverless运行时每个function是非常短暂的如何降低线程数量呢<br />
如果你充分理解了专栏讲解的内容对JVM内部有了充分理解思路就很清晰了<br />
JDK 9的默认GC是G1虽然它在较大堆场景表现良好但本身就会比传统的Parallel GC或者Serial GC之类复杂太多所以要么降低其并行线程数目要么直接切换GC类型<br />
JIT编译默认是开启了TieredCompilation的将其关闭那么JIT也会变得简单相应本地线程也会减少。<br />
我们来对比一下,这是默认参数情况的输出:<br />
<img src="https://static001.geekbang.org/resource/image/97/42/97d060b306e44af3a8443f932a0a4d42.png" alt="" /></li>
下面是替换了默认GC并关闭TieredCompilation的命令行<br />
<img src="https://static001.geekbang.org/resource/image/b0/3b/b07d6da56f588cbfadbb7b381346213b.png" alt="" />
得到的统计信息如下线程数目从25降到了17消耗的内存也下降了大概1/3。<br />
<img src="https://static001.geekbang.org/resource/image/59/27/593735623f6917695602095fd249d527.png" alt="" />
- 接下来是Code统计信息显然这是CodeCache相关内存也就是JIT compiler存储编译热点方法等信息的地方JVM提供了一系列参数可以限制其初始值和最大值等例如
```
-XX:InitialCodeCacheSize=value
```
```
-XX:ReservedCodeCacheSize=value
```
你可以设置下列JVM参数也可以只设置其中一个进一步判断不同参数对CodeCache大小的影响。<br />
<img src="https://static001.geekbang.org/resource/image/94/70/945740c37433f783d2d877c67dcc1170.png" alt="" /><br />
<img src="https://static001.geekbang.org/resource/image/82/cd/82d1fbc9ca09698c01ccff18fb97c8cd.png" alt="" />
很明显CodeCache空间下降非常大这是因为我们关闭了复杂的TieredCompilation而且还限制了其初始大小。
- 下面就是GC部分了就像我前面介绍的G1等垃圾收集器其本身的设施和数据结构就非常复杂和庞大例如Remembered Set通常都会占用20%~30%的堆空间。如果我把GC明确修改为相对简单的Serial GC会有什么效果呢
使用命令:
```
-XX:+UseSerialGC
```
<img src="https://static001.geekbang.org/resource/image/6e/33/6eeee6624c7dc6be54bfce5e93064233.png" alt="" />
可见不仅总线程数大大降低25 → 13而且GC设施本身的内存开销就少了非常多。据我所知AWS Lambda中Java运行时就是使用的Serial GC可以大大降低单个function的启动和运行开销。
<li>
Compiler部分就是JIT的开销显然关闭TieredCompilation会降低内存使用。
</li>
<li>
其他一些部分占比都非常低,通常也不会出现内存使用问题,请参考[官方文档](https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescr022.html#BABCBGFA)。唯一的例外就是InternalJDK 11以后在Other部分部分其统计信息**包含着Direct Buffer的直接内存**这其实是堆外内存中比较敏感的部分很多堆外内存OOM就发生在这里请参考专栏第12讲的处理步骤。原则上Direct Buffer是不推荐频繁创建或销毁的如果你怀疑直接内存区域有问题通常可以通过类似instrument构造函数等手段排查可能的问题。
</li>
JVM内部结构就介绍到这里主要目的是为了加深理解很多方面只有在定制或调优JVM运行时才能真正涉及随着微服务和Serverless等技术的兴起JDK确实存在着为新特征的工作负载进行定制的需求。
今天我结合JVM参数和特性系统地分析了JVM堆内和堆外内存结构相信你一定对JVM内存结构有了比较深入的了解在定制Java运行时或者处理OOM等问题的时候思路也会更加清晰。JVM问题千奇百怪如果你能快速将问题缩小大致就能清楚问题可能出在哪里例如如果定位到问题可能是堆内存泄漏往往就已经有非常清晰的[思路和工具](https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/memleaks004.html#CIHIEEFH)可以去解决了。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是如果用程序的方式而不是工具对Java内存使用进行监控有哪些技术可以做到?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,167 @@
<audio id="audio" title="第27讲 | Java常见的垃圾收集器有哪些" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ff/3e/ff66e195a633cc76f36cbae501f7983e.mp3"></audio>
垃圾收集机制是Java的招牌能力极大地提高了开发效率。如今垃圾收集几乎成为现代语言的标配即使经过如此长时间的发展 Java的垃圾收集机制仍然在不断的演进中不同大小的设备、不同特征的应用场景对垃圾收集提出了新的挑战这当然也是面试的热点。
今天我要问你的问题是Java常见的垃圾收集器有哪些
## 典型回答
实际上垃圾收集器GCGarbage Collector是和具体JVM实现紧密相关的不同厂商IBM、Oracle不同版本的JVM提供的选择也不同。接下来我来谈谈最主流的Oracle JDK。
<li>Serial GC它是最古老的垃圾收集器“Serial”体现在其收集工作是单线程的并且在进行垃圾收集过程中会进入臭名昭著的“Stop-The-World”状态。当然其单线程设计也意味着精简的GC实现无需维护复杂的数据结构初始化也简单所以一直是Client模式下JVM的默认选项。<br>
从年代的角度通常将其老年代实现单独称作Serial Old它采用了标记-整理Mark-Compact算法区别于新生代的复制算法。<br>
Serial GC的对应JVM参数是</li>
```
-XX:+UseSerialGC
```
- ParNew GC很明显是个新生代GC实现它实际是Serial GC的多线程版本最常见的应用场景是配合老年代的CMS GC工作下面是对应参数
```
-XX:+UseConcMarkSweepGC -XX:+UseParNewGC
```
<li>
CMSConcurrent Mark Sweep GC基于标记-清除Mark-Sweep算法设计目标是尽量减少停顿时间这一点对于Web等反应时间敏感的应用非常重要一直到今天仍然有很多系统使用CMS GC。但是CMS采用的标记-清除算法存在着内存碎片化问题所以难以避免在长时间运行等情况下发生full GC导致恶劣的停顿。另外既然强调了并发ConcurrentCMS会占用更多CPU资源并和用户线程争抢。
</li>
<li>
<p>Parallel GC在早期JDK 8等版本中它是server模式JVM的默认GC选择也被称作是吞吐量优先的GC。它的算法和Serial GC比较相似尽管实现要复杂的多其特点是新生代和老年代GC都是并行进行的在常见的服务器环境中更加高效。<br>
开启选项是:</p>
</li>
```
-XX:+UseParallelGC
```
另外Parallel GC引入了开发者友好的配置项我们可以直接设置暂停时间或吞吐量等目标JVM会自动进行适应性调整例如下面参数
```
-XX:MaxGCPauseMillis=value
-XX:GCTimeRatio=N // GC时间和用户时间比例 = 1 / (N+1)
```
<li>G1 GC这是一种兼顾吞吐量和停顿时间的GC实现是Oracle JDK 9以后的默认GC选项。G1可以直观的设定停顿时间的目标相比于CMS GCG1未必能做到CMS在最好情况下的延时停顿但是最差情况要好很多。<br>
G1 GC仍然存在着年代的概念但是其内存结构并不是简单的条带式划分而是类似棋盘的一个个region。Region之间是复制算法但整体上实际可看作是标记-整理Mark-Compact算法可以有效地避免内存碎片尤其是当Java堆非常大的时候G1的优势更加明显。<br>
G1吞吐量和停顿表现都非常不错并且仍然在不断地完善与此同时CMS已经在JDK 9中被标记为废弃deprecated所以G1 GC值得你深入掌握。</li>
## 考点分析
今天的问题是考察你对GC的了解GC是Java程序员的面试常见题目但是并不是每个人都有机会或者必要对JVM、GC进行深入了解我前面的总结是为不熟悉这部分内容的同学提供一个整体的印象。
对于垃圾收集,面试官可以循序渐进从理论、实践各种角度深入,也未必是要求面试者什么都懂。但如果你懂得原理,一定会成为面试中的加分项。在今天的讲解中,我侧重介绍比较通用、基础性的部分:
<li>
垃圾收集的算法有哪些?如何判断一个对象是否可以回收?
</li>
<li>
垃圾收集器工作的基本流程。
</li>
另外Java一直处于非常迅速的发展之中在最新的JDK实现中还有多种新的GC我会在最后补充除了前面提到的垃圾收集器看看还有哪些值得关注的选择。
## 知识扩展
**垃圾收集的原理和基础概念**
第一自动垃圾收集的前提是清楚哪些内存可以被释放。这一点可以结合我前面对Java类加载和内存结构的分析来思考一下。
主要就是两个方面最主要部分就是对象实例都是存储在堆上的还有就是方法区中的元数据等信息例如类型不再使用卸载该Java类似乎是很合理的。
对于对象实例收集,主要是两种基本算法,[引用计数](https://zh.wikipedia.org/wiki/%E5%BC%95%E7%94%A8%E8%AE%A1%E6%95%B0)和可达性分析。
<li>
<p>引用计数算法顾名思义就是为对象添加一个引用计数用于记录对象被引用的情况如果计数为0即表示对象可回收。这是很多语言的资源回收选择例如因人工智能而更加火热的Python它更是同时支持引用计数和垃圾收集机制。具体哪种最优是要看场景的业界有大规模实践中仅保留引用计数机制以提高吞吐量的尝试。<br>
Java并没有选择引用计数是因为其存在一个基本的难题也就是很难处理循环引用关系。</p>
</li>
<li>
另外就是Java选择的可达性分析Java的各种引用关系在某种程度上将可达性问题还进一步复杂化具体请参考[专栏第4讲](http://time.geekbang.org/column/article/6970),这种类型的垃圾收集通常叫作追踪性垃圾收集([Tracing Garbage Collection](https://en.wikipedia.org/wiki/Tracing_garbage_collection))。其原理简单来说,就是将对象及其引用关系看作一个图,选定活动的对象作为 GC Roots然后跟踪引用链条如果一个对象和GC Roots之间不可达也就是不存在引用链条那么即可认为是可回收对象。JVM会把虚拟机栈和本地方法栈中正在引用的对象、静态属性引用的对象和常量作为GC Roots。
</li>
方法区无用元数据的回收比较复杂我简单梳理一下。还记得我对类加载器的分类吧一般来说初始化类加载器加载的类型是不会进行类卸载unload而普通的类型的卸载往往是要求相应自定义类加载器本身被回收所以大量使用动态类型的场合需要防止元数据区或者早期的永久代不会OOM。在8u40以后的JDK中下面参数已经是默认的
```
-XX:+ClassUnloadingWithConcurrentMark
```
第二,常见的垃圾收集算法,我认为总体上有个了解,理解相应的原理和优缺点,就已经足够了,其主要分为三类:
<li>
<p>复制Copying算法我前面讲到的新生代GC基本都是基于复制算法过程就如[专栏上一讲](http://time.geekbang.org/column/article/10325)所介绍的将活着的对象复制到to区域拷贝过程中将对象顺序放置就可以避免内存碎片化。<br>
这么做的代价是既然要进行复制既要提前预留内存空间有一定的浪费另外对于G1这种分拆成为大量region的GC复制而不是移动意味着GC需要维护region之间对象引用关系这个开销也不小不管是内存占用或者时间开销。</p>
</li>
<li>
标记-清除Mark-Sweep算法首先进行标记工作标识出所有要回收的对象然后进行清除。这么做除了标记、清除过程效率有限另外就是不可避免的出现碎片化问题这就导致其不适合特别大的堆否则一旦出现Full GC暂停时间可能根本无法接受。
</li>
<li>
标记-整理Mark-Compact类似于标记-清除,但为避免内存碎片化,它会在清理过程中将对象移动,以确保移动后的对象占用连续的内存空间。
</li>
注意这些只是基本的算法思路实际GC实现过程要复杂的多目前还在发展中的前沿GC都是复合算法并且并行和并发兼备。
如果对这方面的算法有兴趣可以参考一本比较有意思的书《垃圾回收的算法与实现》虽然其内容并不是围绕Java垃圾收集但是对通用算法讲解比较形象。
**垃圾收集过程的理解**
我在[专栏上一讲](http://time.geekbang.org/column/article/10325)对堆结构进行了比较详细的划分在垃圾收集的过程对应到Eden、Survivor、Tenured等区域会发生什么变化呢
这实际上取决于具体的GC方式先来熟悉一下通常的垃圾收集流程我画了一系列示意图希望能有助于你理解清楚这个过程。
第一Java应用不断创建对象通常都是分配在Eden区域当其空间占用达到一定阈值时触发minor GC。仍然被引用的对象绿色方块存活下来被复制到JVM选择的Survivor区域而没有被引用的对象黄色方块则被回收。注意我给存活对象标记了“数字1”这是为了表明对象的存活时间。<br>
<img src="https://static001.geekbang.org/resource/image/44/6d/44d4a92e8e20f46e6646eae53442256d.png" alt="">
第二, 经过一次Minor GCEden就会空闲下来直到再次达到Minor GC触发条件这时候另外一个Survivor区域则会成为to区域Eden区域的存活对象和From区域对象都会被复制到to区域并且存活的年龄计数会被加1。<br>
<img src="https://static001.geekbang.org/resource/image/3b/48/3be4ac4834e2790a8211252f2bebfd48.png" alt="">
第三, 类似第二步的过程会发生很多次直到有对象年龄计数达到阈值这时候就会发生所谓的晋升Promotion过程如下图所示超过阈值的对象会被晋升到老年代。这个阈值是可以通过参数指定
```
-XX:MaxTenuringThreshold=&lt;N&gt;
```
<img src="https://static001.geekbang.org/resource/image/db/8d/dbcb15c99b368773145b358734e10e8d.png" alt="">
后面就是老年代GC具体取决于选择的GC选项对应不同的算法。下面是一个简单标记-整理算法过程示意图,老年代中的无用对象被清除后, GC会将对象进行整理以防止内存碎片化。
<img src="https://static001.geekbang.org/resource/image/39/25/399a0c23d1d57e08a2603fb76f328e25.png" alt="">
通常我们把老年代GC叫作Major GC将对整个堆进行的清理叫作Full GC但是这个也没有那么绝对因为不同的老年代GC算法其实表现差异很大例如CMS“concurrent”就体现在清理工作是与工作线程一起并发运行的。
**GC的新发展**
GC仍然处于飞速发展之中目前的默认选项G1 GC在不断的进行改进很多我们原来认为的缺点例如串行的Full GC、Card Table扫描的低效等都已经被大幅改进例如 JDK 10以后Full GC已经是并行运行在很多场景下其表现还略优于Parallel GC的并行Full GC实现。
即使是Serial GC虽然比较古老但是简单的设计和实现未必就是过时的它本身的开销不管是GC相关数据结构的开销还是线程的开销都是非常小的所以随着云计算的兴起在Serverless等新的应用场景下Serial GC找到了新的舞台。
比较不幸的是CMS GC因为其算法的理论缺陷等原因虽然现在还有非常大的用户群体但是已经被标记为废弃如果没有组织主动承担CMS的维护很有可能会在未来版本移除。
如果你有关注目前尚处于开发中的JDK 11你会发现JDK又增加了两种全新的GC方式分别是
<li>
[Epsilon GC](http://openjdk.java.net/jeps/318)简单说就是个不做垃圾收集的GC似乎有点奇怪有的情况下例如在进行性能测试的时候可能需要明确判断GC本身产生了多大的开销这就是其典型应用场景。
</li>
<li>
[](http://openjdk.java.net/jeps/333)[ZGC](http://openjdk.java.net/jeps/333)这是Oracle开源出来的一个超级GC实现具备令人惊讶的扩展能力比如支持T bytes级别的堆大小并且保证绝大部分情况下延迟都不会超过10 ms。虽然目前还处于实验阶段仅支持Linux 64位的平台但其已经表现出的能力和潜力都非常令人期待。
</li>
当然其他厂商也提供了各种独具一格的GC实现例如比较有名的低延迟GC[Zing](https://www.infoq.com/articles/azul_gc_in_detail)和[Shenandoah](https://wiki.openjdk.java.net/display/shenandoah/Main)等,有兴趣请参考我提供的链接。
今天作为GC系列的第一讲我从整体上梳理了目前的主流GC实现包括基本原理和算法并结合我前面介绍过的内存结构对简要的垃圾收集过程进行了介绍希望能够对你的相关实践有所帮助。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗今天谈了一堆的理论思考一个实践中的问题你通常使用什么参数去打开GC日志呢还会额外添加哪些选项
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,238 @@
<audio id="audio" title="第28讲 | 谈谈你的GC调优思路?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cf/a9/cf882e87cfc59a054534e4d0bb15c1a9.mp3"></audio>
我发现目前不少外部资料对G1的介绍大多还停留在JDK 7或更早期的实现很多结论已经存在较大偏差甚至一些过去的GC选项已经不再推荐使用。所以今天我会选取新版JDK中的默认G1 GC作为重点进行详解并且我会从调优实践的角度分析典型场景和调优思路。下面我们一起来更新下这方面的知识。
今天我要问你的问题是谈谈你的GC调优思路
## 典型回答
谈到调优,这一定是针对特定场景、特定目的的事情, 对于GC调优来说首先就需要清楚调优的目标是什么从性能的角度看通常关注三个方面内存占用footprint、延时latency和吞吐量throughput大多数情况下调优会侧重于其中一个或者两个方面的目标很少有情况可以兼顾三个不同的角度。当然除了上面通常的三个方面也可能需要考虑其他GC相关的场景例如OOM也可能与不合理的GC相关参数有关或者应用启动速度方面的需求GC也会是个考虑的方面。
基本的调优思路可以总结为:
<li>
理解应用需求和问题确定调优目标。假设我们开发了一个应用服务但发现偶尔会出现性能抖动出现较长的服务停顿。评估用户可接受的响应时间和业务量将目标简化为希望GC暂停尽量控制在200ms以内并且保证一定标准的吞吐量。
</li>
<li>
掌握JVM和GC的状态定位具体的问题确定真的有GC调优的必要。具体有很多方法比如通过jstat等工具查看GC等相关状态可以开启GC日志或者是利用操作系统提供的诊断工具等。例如通过追踪GC日志就可以查找是不是GC在特定时间发生了长时间的暂停进而导致了应用响应不及时。
</li>
<li>
这里需要思考选择的GC类型是否符合我们的应用特征如果是具体问题表现在哪里是Minor GC过长还是Mixed GC等出现异常停顿情况如果不是考虑切换到什么类型如CMS和G1都是更侧重于低延迟的GC选项。
</li>
<li>
通过分析确定具体调整的参数或者软硬件配置。
</li>
<li>
验证是否达到调优目标,如果达到目标,即可以考虑结束调优;否则,重复完成分析、调整、验证这个过程。
</li>
## 考点分析
今天考察的GC调优问题是JVM调优的一个基础方面很多JVM调优需求最终都会落实在GC调优上或者与其相关我提供的是一个常见的思路。
真正快速定位和解决具体问题还是需要对JVM和GC知识的掌握以及实际调优经验的总结有的时候甚至是源自经验积累的直觉判断。面试官可能会继续问项目中遇到的真实问题如果你能清楚、简要地介绍其上下文然后将诊断思路和调优实践过程表述出来会是个很好的加分项。
专栏虽然无法提供具体的项目经验,但是可以帮助你掌握常见的调优思路和手段,这不管是面试还是在实际工作中都是很有帮助的。另外,我会还会从下面不同角度进行补充:
<li>
[上一讲](http://time.geekbang.org/column/article/10513)中我已经谈到涉及具体的GC类型JVM的实际表现要更加复杂。目前G1已经成为新版JDK的默认选择所以值得你去深入理解。
</li>
<li>
因为G1 GC一直处在快速发展之中我会侧重它的演进变化尤其是行为和配置相关的变化。并且同样是因为JVM的快速发展即使是收集GC日志等方面也发生了较大改进这也是为什么我在上一讲留给你的思考题是有关日志相关选项看完讲解相信你会很惊讶。
</li>
<li>
从GC调优实践的角度理解通用问题的调优思路和手段。
</li>
## 知识扩展
首先先来整体了解一下G1 GC的内部结构和主要机制。
从内存区域的角度G1同样存在着年代的概念但是与我前面介绍的内存结构很不一样其内部是类似棋盘状的一个个region组成请参考下面的示意图。<br />
<img src="https://static001.geekbang.org/resource/image/a6/f1/a662fda0de8af087c37c40a86a9cf3f1.png" alt="" />
region的大小是一致的数值是在1M到32M字节之间的一个2的幂值数JVM会尽量划分2048个左右、同等大小的region这点可以从源码[heapRegionBounds.hpp](http://hg.openjdk.java.net/jdk/jdk/file/fa2f93f99dbc/src/hotspot/share/gc/g1/heapRegionBounds.hpp)中看到。当然这个数字既可以手动调整G1也会根据堆大小自动进行调整。
在G1实现中年代是个逻辑概念具体体现在一部分region是作为Eden一部分作为Survivor除了意料之中的Old regionG1会将超过region 50%大小的对象在应用中通常是byte或char数组归类为Humongous对象并放置在相应的region中。逻辑上Humongous region算是老年代的一部分因为复制这样的大对象是很昂贵的操作并不适合新生代GC的复制算法。
你可以思考下region设计有什么副作用
例如region大小和大对象很难保证一致这会导致空间的浪费。不知道你有没有注意到我的示意图中有的区域是Humongous颜色但没有用名称标记这是为了表示特别大的对象是可能占用超过一个region的。并且region太小不合适会令你在分配大对象时更难找到连续空间这是一个长久存在的情况请参考[OpenJDK社区的讨论](http://mail.openjdk.java.net/pipermail/hotspot-gc-use/2017-November/002726.html)。这本质也可以看作是JVM的bug尽管解决办法也非常简单直接设置较大的region大小参数如下
```
-XX:G1HeapRegionSize=&lt;N, 例如16&gt;M
```
从GC算法的角度G1选择的是复合算法可以简化理解为
<li>
在新生代G1采用的仍然是并行的复制算法所以同样会发生Stop-The-World的暂停。
</li>
<li>
在老年代大部分情况下都是并发标记而整理Compact则是和新生代GC时捎带进行并且不是整体性的整理而是增量进行的。
</li>
我在[上一讲](http://time.geekbang.org/column/article/10513)曾经介绍过习惯上人们喜欢把新生代GCYoung GC叫作Minor GC老年代GC叫作Major GC区别于整体性的Full GC。但是现代GC中这种概念已经不再准确对于G1来说
<li>
Minor GC仍然存在虽然具体过程会有区别会涉及Remembered Set等相关处理。
</li>
<li>
老年代回收则是依靠Mixed GC。并发标记结束后JVM就有足够的信息进行垃圾收集Mixed GC不仅同时会清理Eden、Survivor区域而且还会清理部分Old区域。可以通过设置下面的参数指定触发阈值并且设定最多被包含在一次Mixed GC中的region比例。
</li>
```
XX:G1MixedGCLiveThresholdPercent
XX:G1OldCSetRegionThresholdPercent
```
从G1内部运行的角度下面的示意图描述了G1正常运行时的状态流转变化当然在发生逃逸失败等情况下就会触发Full GC。<br />
<img src="https://static001.geekbang.org/resource/image/47/ec/47dddbd91ad0e0adbd164632eb9facec.png" alt="" />
G1相关概念非常多有一个重点就是Remembered Set用于记录和维护region之间对象的引用关系。为什么需要这么做呢试想新生代GC是复制算法也就是说类似对象从Eden或者Survivor到to区域的“移动”其实是“复制”本质上是一个新的对象。在这个过程中需要必须保证老年代到新生代的跨区引用仍然有效。下面的示意图说明了相关设计。<br />
<img src="https://static001.geekbang.org/resource/image/eb/d3/eb50bb2b270478bc6f525aa615d4a3d3.png" alt="" />
G1的很多开销都是源自Remembered Set例如它通常约占用Heap大小的20%或更高这可是非常可观的比例。并且我们进行对象复制的时候因为需要扫描和更改Card Table的信息这个速度影响了复制的速度进而影响暂停时间。
描述G1内部的资料很多我就不重复了如果你想了解更多内部结构和算法等我建议参考一些具体的[介绍](https://www.infoq.com/articles/G1-One-Garbage-Collector-To-Rule-Them-All)书籍方面我推荐Charlie Hunt等撰写的《Java Performance Companion》。
接下来我介绍下大家可能还不了解的G1行为变化它们在一定程度上解决了专栏其他讲中提到的部分困扰如类型卸载不及时的问题。
<li>
<p>上面提到了Humongous对象的分配和回收这是很多内存问题的来源Humongous region作为老年代的一部分通常认为它会在并发标记结束后才进行回收但是在新版G1中Humongous对象回收采取了更加激进的策略。<br />
我们知道G1记录了老年代region间对象引用Humongous对象数量有限所以能够快速的知道是否有老年代对象引用它。如果没有能够阻止它被回收的唯一可能就是新生代是否有对象引用了它但这个信息是可以在Young GC时就知道的所以完全可以在Young GC中就进行Humongous对象的回收不用像其他老年代对象那样等待并发标记结束。</p>
</li>
<li>
我在[专栏第5讲](http://time.geekbang.org/column/article/7349)提到了在8u20以后字符串排重的特性在垃圾收集过程中G1会把新创建的字符串对象放入队列中然后在Young GC之后并发地不会STW将内部数据char数组JDK 9以后是byte数组一致的字符串进行排重也就是将其引用同一个数组。你可以使用下面参数激活
</li>
```
-XX:+UseStringDeduplication
```
注意这种排重虽然可以节省不少内存空间但这种并发操作会占用一些CPU资源也会导致Young GC稍微变慢。
- 类型卸载是个长期困扰一些Java应用的问题在[专栏第25讲](http://time.geekbang.org/column/article/10192)中,我介绍了一个类只有当加载它的自定义类加载器被回收后,才能被卸载。元数据区替换了永久代之后有所改善,但还是可能出现问题。
G1的类型卸载有什么改进吗很多资料中都谈到G1只有在发生Full GC时才进行类型卸载但这显然不是我们想要的。你可以加上下面的参数查看类型卸载
```
-XX:+TraceClassUnloading
```
幸好现代的G1已经不是如此了8u40以后G1增加并默认开启下面的选项
```
-XX:+ClassUnloadingWithConcurrentMark
```
也就是说在并发标记阶段结束后JVM即进行类型卸载。
- 我们知道老年代对象回收基本要等待并发标记结束。这意味着如果并发标记结束不及时导致堆已满但老年代空间还没完成回收就会触发Full GC所以触发并发标记的时机很重要。早期的G1调优中通常会设置下面参数但是很难给出一个普适的数值往往要根据实际运行结果调整
```
-XX:InitiatingHeapOccupancyPercent
```
在JDK 9之后的G1实现中这种调整需求会少很多因为JVM只会将该参数作为初始值会在运行时进行采样获取统计数据然后据此动态调整并发标记启动时机。对应的JVM参数如下默认已经开启
```
-XX:+G1UseAdaptiveIHOP
```
- 在现有的资料中大多指出G1的Full GC是最差劲的单线程串行GC。其实如果采用的是最新的JDK你会发现Full GC也是并行进行的了在通用场景中的表现还优于Parallel GC的Full GC实现。
当然还有很多其他的改变比如更快的Card Table扫描等这里不再展开介绍因为它们并不带来行为的变化基本不影响调优选择。
前面介绍了G1的内部机制并且穿插了部分调优建议下面从整体上给出一些调优的建议。
首先,**建议尽量升级到较新的JDK版本**从上面介绍的改进就可以看到很多人们常常讨论的问题其实升级JDK就可以解决了。
第二掌握GC调优信息收集途径。掌握尽量全面、详细、准确的信息是各种调优的基础不仅仅是GC调优。我们来看看打开GC日志这似乎是很简单的事情可是你确定真的掌握了吗
除了常用的两个选项,
```
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
```
还有一些非常有用的日志选项,很多特定问题的诊断都是要依赖这些选项:
```
-XX:+PrintAdaptiveSizePolicy // 打印G1 Ergonomics相关信息
```
我们知道GC内部一些行为是适应性的触发的利用PrintAdaptiveSizePolicy我们就可以知道为什么JVM做出了一些可能我们不希望发生的动作。例如G1调优的一个基本建议就是避免进行大量的Humongous对象分配如果Ergonomics信息说明发生了这一点那么就可以考虑要么增大堆的大小要么直接将region大小提高。
如果是怀疑出现引用清理不及时的情况,则可以打开下面选项,掌握到底是哪里出现了堆积。
```
-XX:+PrintReferenceGC
```
另外,建议开启选项下面的选项进行并行引用处理。
```
-XX:+ParallelRefProcEnabled
```
需要注意的一点是JDK 9中JVM和GC日志机构进行了重构其实我前面提到的**PrintGCDetails已经被标记为废弃**,而**PrintGCDateStamps已经被移除**指定它会导致JVM无法启动。可以使用下面的命令查询新的配置参数。
```
java -Xlog:help
```
最后,来看一些通用实践,理解了我前面介绍的内部结构和机制,很多结论就一目了然了,例如:
- 如果发现Young GC非常耗时这很可能就是因为新生代太大了我们可以考虑减小新生代的最小比例。
```
-XX:G1NewSizePercent
```
降低其最大值同样对降低Young GC延迟有帮助。
```
-XX:G1MaxNewSizePercent
```
如果我们直接为G1设置较小的延迟目标值也会起到减小新生代的效果虽然会影响吞吐量。
- 如果是Mixed GC延迟较长我们应该怎么做呢
还记得前面说的部分Old region会被包含进Mixed GC减少一次处理的region个数就是个直接的选择之一。<br />
我在上面已经介绍了G1OldCSetRegionThresholdPercent控制其最大值还可以利用下面参数提高Mixed GC的个数当前默认值是8Mixed GC数量增多意味着每次被包含的region减少。
```
-XX:G1MixedGCCountTarget
```
今天的内容算是抛砖引玉,更多内容你可以参考[G1调优指南](https://docs.oracle.com/javase/9/gctuning/garbage-first-garbage-collector-tuning.htm#JSGCT-GUID-4914A8D4-DE41-4250-B68E-816B58D4E278)等远不是几句话可以囊括的。需要注意的是也要避免过度调优G1对大堆非常友好其运行机制也需要浪费一定的空间有时候稍微多给堆一些空间比进行苛刻的调优更加实用。
今天我梳理了基本的GC调优思路并对G1内部结构以及最新的行为变化进行了详解。总的来说G1的调优相对简单、直观因为可以直接设定暂停时间等目标并且其内部引入了各种智能的自适应机制希望这一切的努力能够让你在日常应用开发时更加高效。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是定位Full GC发生的原因有哪些方式
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,170 @@
<audio id="audio" title="第29讲 | Java内存模型中的happen-before是什么" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2b/50/2b5c3c193cee079546f047b798b02a50.mp3"></audio>
Java语言在设计之初就引入了线程的概念以充分利用现代处理器的计算能力这既带来了强大、灵活的多线程机制也带来了线程安全等令人混淆的问题而Java内存模型Java Memory ModelJMM为我们提供了一个在纷乱之中达成一致的指导准则。
今天我要问你的问题是Java内存模型中的happen-before是什么
## 典型回答
Happen-before关系是Java内存模型中保证多线程操作可见性的机制也是对早期语言规范中含糊的可见性概念的一个精确定义。
它的具体表现形式包括但远不止是我们直觉中的synchronized、volatile、lock操作顺序等方面例如
<li>
线程内执行的每个操作都保证happen-before后面的操作这就保证了基本的程序顺序规则这是开发者在书写程序时的基本约定。
</li>
<li>
对于volatile变量对它的写操作保证happen-before在随后对该变量的读取操作。
</li>
<li>
对于一个锁的解锁操作保证happen-before加锁操作。
</li>
<li>
对象构建完成保证happen-before于finalizer的开始动作。
</li>
<li>
甚至是类似线程内部操作的完成保证happen-before其他Thread.join()的线程等。
</li>
这些happen-before关系是存在着传递性的如果满足a happen-before b和b happen-before c那么a happen-before c也成立。
前面我一直用happen-before而不是简单说前后是因为它不仅仅是对执行时间的保证也包括对内存读、写操作顺序的保证。仅仅是时钟顺序上的先后并不能保证线程交互的可见性。
## 考点分析
今天的问题是一个常见的考察Java内存模型基本概念的问题我前面给出的回答尽量选择了和日常开发相关的规则。
JMM是面试的热点可以看作是深入理解Java并发编程、编译器和JVM内部机制的必要条件但这同时也是个容易让初学者无所适从的主题。对于学习JMM我有一些个人建议
<li>
明确目的克制住技术的诱惑。除非你是编译器或者JVM工程师否则我建议不要一头扎进各种CPU体系结构纠结于不同的缓存、流水线、执行单元等。这些东西虽然很酷但其复杂性是超乎想象的很可能会无谓增加学习难度也未必有实践价值。
</li>
<li>
克制住对“秘籍”的诱惑。有些时候,某些编程方式看起来能起到特定效果,但分不清是实现差异导致的“表现”,还是“规范”要求的行为,就不要依赖于这种“表现”去编程,尽量遵循语言规范进行,这样我们的应用行为才能更加可靠、可预计。
</li>
在这一讲中,兼顾面试和编程实践,我会结合例子梳理下面两点:
<li>
为什么需要JMM它试图解决什么问题
</li>
<li>
JMM是如何解决可见性等各种问题的类似volatile体现在具体用例中有什么效果
</li>
注意专栏中Java内存模型就是特指JSR-133中重新定义的JMM规范。在特定的上下文里也许会与JVMJava内存结构等混淆并不存在绝对的对错但一定要清楚面试官的本意有的面试官也会特意考察是否清楚这两种概念的区别。
## 知识扩展
**为什么需要JMM它试图解决什么问题**
Java是最早尝试提供内存模型的语言这是简化多线程编程、保证程序可移植性的一个飞跃。早期类似C、C++等语言并不存在内存模型的概念C++ 11中也引入了标准内存模型其行为依赖于处理器本身的[内存一致性模型](https://en.wikipedia.org/wiki/Memory_ordering)但不同的处理器可能差异很大所以一段C++程序在处理器A上运行正常并不能保证其在处理器B上也是一致的。
即使如此最初的Java语言规范仍然是存在着缺陷的当时的目标是希望Java程序可以充分利用现代硬件的计算能力同时保持“书写一次到处执行”的能力。
但是显然问题的复杂度被低估了随着Java被运行在越来越多的平台上人们发现过于泛泛的内存模型定义存在很多模棱两可之处对synchronized或volatile等类似指令重排序时的行为并没有提供清晰规范。这里说的指令重排序既可以是[编译器优化行为](https://en.wikipedia.org/wiki/Instruction_scheduling),也可能是源自于现代处理器的[乱序执行](https://en.wikipedia.org/wiki/Out-of-order_execution)等。
换句话说:
<li>
既不能保证一些多线程程序的正确性例如最著名的就是双检锁Double-Checked LockingDCL的失效问题具体可以参考我在[第14讲](http://time.geekbang.org/column/article/8624)对单例模式的说明双检锁可能导致未完整初始化的对象被访问理论上这叫并发编程中的安全发布Safe Publication失败。
</li>
<li>
也不能保证同一段程序在不同的处理器架构上表现一致,例如有的处理器支持缓存一致性,有的不支持,各自都有自己的内存排序模型。
</li>
所以Java迫切需要一个完善的JMM能够让普通Java开发者和编译器、JVM工程师能够**清晰地**达成共识。换句话说,可以相对简单并准确地判断出,多线程程序什么样的执行序列是符合规范的。
所以:
<li>
对于编译器、JVM开发者关注点可能是如何使用类似[内存屏障](https://en.wikipedia.org/wiki/Memory_barrier)Memory-Barrier之类技术保证执行结果符合JMM的推断。
</li>
<li>
对于Java应用开发者则可能更加关注volatile、synchronized等语义如何利用类似happen-before的规则写出可靠的多线程应用而不是利用一些“秘籍”去糊弄编译器、JVM。
</li>
我画了一个简单的角色层次图不同工程师分工合作其实所处的层面是有区别的。JMM为Java工程师隔离了不同处理器内存排序的区别这也是为什么我通常不建议过早深入处理器体系结构某种意义上来说这样本就违背了JMM的初衷。<br />
<img src="https://static001.geekbang.org/resource/image/5d/e5/5d74ad650fa5d1cdf80df3b3062357e5.png" alt="" />
**JMM是怎么解决可见性等问题的呢**
在这里,我有必要简要介绍一下典型的问题场景。
我在[第25讲](http://time.geekbang.org/column/article/10192)里介绍了JVM内部的运行时数据区但是真正程序执行实际是要跑在具体的处理器内核上。你可以简单理解为把本地变量等数据从内存加载到缓存、寄存器然后运算结束写回主内存。你可以从下面示意图看这两种模型的对应。<br />
<img src="https://static001.geekbang.org/resource/image/ff/61/ff8afc2561e8891bc74a0112905fed61.png" alt="" />
看上去很美好但是当多线程共享变量时情况就复杂了。试想如果处理器对某个共享变量进行了修改可能只是体现在该内核的缓存里这是个本地状态而运行在其他内核上的线程可能还是加载的旧状态这很可能导致一致性的问题。从理论上来说多线程共享引入了复杂的数据依赖性不管编译器、处理器怎么做重排序都必须尊重数据依赖性的要求否则就打破了正确性这就是JMM所要解决的问题。
JMM内部的实现通常是依赖于所谓的内存屏障通过禁止某些重排序的方式提供内存可见性保证也就是实现了各种happen-before规则。与此同时更多复杂度在于需要尽量确保各种编译器、各种体系结构的处理器都能够提供一致的行为。
我以volatile为例看看如何利用内存屏障实现JMM定义的可见性
对于一个volatile变量
<li>
对该变量的写操作**之后**,编译器会插入一个**写屏障**。
</li>
<li>
对该变量的读操作**之前**,编译器会插入一个**读屏障**。
</li>
内存屏障能够在类似变量读、写操作之后保证其他线程对volatile变量的修改对当前线程可见或者本地修改对其他线程提供可见性。换句话说线程写入写屏障会通过类似强迫刷出处理器缓存的方式让其他线程能够拿到最新数值。
如果你对更多内存屏障的细节感兴趣或者想了解不同体系结构的处理器模型建议参考JSR-133[相关文档](http://gee.cs.oswego.edu/dl/jmm/cookbook.html)我个人认为这些都是和特定硬件相关的内存屏障之类只是实现JMM规范的技术手段并不是规范的要求。
**从应用开发者的角度JMM提供的可见性体现在类似volatile上具体行为是什么样呢**
我这里循序渐进的举两个例子。
首先前几天有同学问我一个问题请看下面的代码片段希望达到的效果是当condition被赋值为false时线程A能够从循环中退出。
```
// Thread A
while (condition) {
}
// Thread B
condition = false;
```
这里就需要condition被定义为volatile变量不然其数值变化往往并不能被线程A感知进而无法退出。当然也可以在while中添加能够直接或间接起到类似效果的代码。
第二我想举Brian Goetz提供的一个经典用例使用volatile作为守卫对象实现某种程度上轻量级的同步请看代码片段
```
Map configOptions;
char[] configText;
volatile boolean initialized = false;
// Thread A
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// Thread B
while (!initialized)
 sleep();
// use configOptions
```
JSR-133重新定义的JMM模型能够保证线程B获取的configOptions是更新后的数值。
也就是说volatile变量的可见性发生了增强能够起到守护其上下文的作用。线程A对volatile变量的赋值会强制将该变量自己和当时其他变量的状态都刷出缓存为线程B提供可见性。当然这也是以一定的性能开销作为代价的但毕竟带来了更加简单的多线程行为。
我们经常会说volatile比synchronized之类更加轻量但轻量也仅仅是相对的volatile的读、写仍然要比普通的读写要开销更大所以如果你是在性能高度敏感的场景除非你确定需要它的语义不然慎用。
今天我从happen-before关系开始帮你理解了什么是Java内存模型。为了更方便理解我作了简化从不同工程师的角色划分等角度阐述了问题的由来以及JMM是如何通过类似内存屏障等技术实现的。最后我以volatile为例分析了可见性在多线程场景中的典型用例。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗今天留给你的思考题是给定一段代码如何验证所有符合JMM执行可能有什么工具可以辅助吗
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,160 @@
<audio id="audio" title="第30讲 | Java程序运行在Docker等容器环境有哪些新问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1a/36/1a527e67b279fe61bee7174b0e668436.mp3"></audio>
如今Docker等容器早已不是新生事物正在逐步成为日常开发、部署环境的一部分。Java能否无缝地运行在容器环境是否符合微服务、Serverless等新的软件架构和场景在一定程度上也会影响未来的技术栈选择。当然Java对Docker等容器环境的支持也在不断增强自然地Java在容器场景的实践也逐渐在面试中被涉及。我希望通过专栏今天这一讲能够帮你能做到胸有成竹。
今天我要问你的问题是Java程序运行在Docker等容器环境有哪些新问题
## 典型回答
对于Java来说Docker毕竟是一个较新的环境例如其内存、CPU等资源限制是通过CGroupControl Group实现的早期的JDK版本8u131之前并不能识别这些限制进而会导致一些基础问题
<li>
如果未配置合适的JVM堆和元数据区、直接内存等参数Java就有可能试图使用超过容器限制的内存最终被容器OOM kill或者自身发生OOM。
</li>
<li>
错误判断了可获取的CPU资源例如Docker限制了CPU的核数JVM就可能设置不合适的GC并行线程数等。
</li>
从应用打包、发布等角度出发JDK自身就比较大生成的镜像就更为臃肿当我们的镜像非常多的时候镜像的存储等开销就比较明显了。
如果考虑到微服务、Serverless等新的架构和场景Java自身的大小、内存占用、启动速度都存在一定局限性因为Java早期的优化大多是针对长时间运行的大型服务器端应用。
## 考点分析
今天的问题是个针对特定场景和知识点的问题,我给出的回答简单总结了目前业界实践中发现的一些问题。
如果我是面试官针对这种问题如果你确实没有太多Java在Docker环境的使用经验直接说不知道也算是可以接受的毕竟没有人能够掌握所有知识点嘛。
但我们要清楚有经验的面试官一般不会以纯粹偏僻的知识点作为面试考察的目的更多是考察思考问题的思路和解决问题的方法。所以如果有基础的话可以从操作系统、容器原理、JVM内部机制、软件开发实践等角度展示系统性分析新问题、新场景的能力。毕竟变化才是世界永远的主题能够在新变化中找出共性与关键是优秀工程师的必备能力。
今天我会围绕下面几个方面展开:
<li>
面试官可能会进一步问到有没有想过为什么类似Docker这种容器环境会有点“欺负”Java从JVM内部机制来说问题出现在哪里
</li>
<li>
我注意到有种论调说“没人在容器环境用Java”不去争论这个观点正确与否我会从工程实践出发梳理问题原因和相关解决方案并探讨下新场景下的最佳实践。
</li>
## 知识扩展
首先我们先来搞清楚Java在容器环境的局限性来源**Docker到底有什么特别**
虽然看起来Docker之类容器和虚拟机非常相似例如它也有自己的shell能独立安装软件包运行时与其他容器互不干扰。但是如果深入分析你会发现Docker并不是一种完全的**虚拟化**技术,而更是一种轻量级的**隔离**技术。<br />
<img src="https://static001.geekbang.org/resource/image/a0/fb/a069a294d32d7778f3410192221358fb.png" alt="" />
上面的示意图展示了Docker与虚拟机的区别。从技术角度基于namespaceDocker为每个容器提供了单独的命名空间对网络、PID、用户、IPC通信、文件系统挂载点等实现了隔离。对于CPU、内存、磁盘IO等计算资源则是通过CGroup进行管理。如果你想了解更多Docker的细节请参考相关[技术文档](https://medium.freecodecamp.org/a-beginner-friendly-introduction-to-containers-vms-and-docker-79a9e3e119b)。
Docker仅在类似Linux内核之上实现了有限的隔离和虚拟化并不是像传统虚拟化软件那样独立运行一个新的操作系统。如果是虚拟化的操作系统不管是Java还是其他程序只要调用的是同一个系统API都可以透明地获取所需的信息基本不需要额外的兼容性改变。
容器虽然省略了虚拟操作系统的开销实现了轻量级的目标但也带来了额外复杂性它限制对于应用不是透明的需要用户理解Docker的新行为。所以有专家曾经说过“幸运的是Docker没有完全隐藏底层信息但是不幸的也是Docker没有隐藏底层信息
对于Java平台来说这些未隐藏的底层信息带来了很多意外的困难主要体现在几个方面
第一容器环境对于计算资源的管理方式是全新的CGroup作为相对比较新的技术历史版本的Java显然并不能自然地理解相应的资源限制。
第二namespace对于容器内的应用细节增加了一些微妙的差异比如jcmd、jstack等工具会依赖于“/proc/<pid>/”下面提供的部分信息但是Docker的设计改变了这部分信息的原有结构我们需要对原有工具进行[修改](https://bugs.openjdk.java.net/browse/JDK-8179498)以适应这种变化。
**从JVM运行机制的角度为什么这些“沟通障碍”会导致OOM等问题呢**
你可以思考一下这个问题实际是反映了JVM如何根据系统资源内存、CPU等情况在启动时设置默认参数。
这就是所谓的[Ergonomics](https://docs.oracle.com/javase/10/gctuning/ergonomics.htm#JSGCT-GUID-DB4CAE94-2041-4A16-90EC-6AE3D91EC1F1)机制,例如:
<li>
JVM会大概根据检测到的内存大小设置最初启动时的堆大小为系统内存的1/64并将堆最大值设置为系统内存的1/4。
</li>
<li>
而JVM检测到系统的CPU核数则直接影响到了Parallel GC的并行线程数目和JIT complier线程数目甚至是我们应用中ForkJoinPool等机制的并行等级。
</li>
这些默认参数是根据通用场景选择的初始值。但是由于容器环境的差异Java的判断很可能是基于错误信息而做出的。这就类似我以为我住的是整栋别墅实际上却只有一个房间是给我住的。
更加严重的是JVM的一些原有诊断或备用机制也会受到影响。为保证服务的可用性一种常见的选择是依赖“-XX:OnOutOfMemoryError”功能通过调用处理脚本的形式来做一些补救措施比如自动重启服务等。但是这种机制是基于fork实现的当Java进程已经过度提交内存时fork新的进程往往已经不可能正常运行了。
根据前面的总结,似乎问题非常棘手,那我们在实践中,**如何解决这些问题呢?**
首先,如果你能够**升级到最新的JDK版本**,这个问题就迎刃而解了。
- 针对这种情况JDK 9中引入了一些实验性的参数以方便Docker和Java“沟通”例如针对内存限制可以使用下面的参数设置
```
-XX:+UnlockExperimentalVMOptions
-XX:+UseCGroupMemoryLimitForHeap
```
注意这两个参数是顺序敏感的并且只支持Linux环境。而对于CPU核心数限定Java已经被修正为可以正确理解“cpuset-cpus”等设置无需单独设置参数。
- 如果你可以切换到JDK 10或者更新的版本问题就更加简单了。Java对容器Docker的支持已经比较完善默认就会自适应各种资源限制和实现差异。前面提到的实验性参数“UseCGroupMemoryLimitForHeap”已经被标记为废弃。
与此同时新增了参数用以明确指定CPU核心的数目。
```
-XX:ActiveProcessorCount=N
```
如果实践中发现有问题,也可以使用“-XX:-UseContainerSupport”关闭Java的容器支持特性这可以作为一种防御性机制避免新特性破坏原有基础功能。当然也欢迎你向OpenJDK社区反馈问题。
- 幸运的是JDK 9中的实验性改进已经被移植到Oracle JDK 8u131之中你可以直接下载相应[镜像](https://store.docker.com/images/oracle-serverjre-8)并配置“UseCGroupMemoryLimitForHeap”后续很有可能还会进一步将JDK 10中相关的增强应用到JDK 8最新的更新中。
但是如果我暂时只能使用老版本的JDK怎么办
我这里有几个建议:
- 明确设置堆、元数据区等内存区域大小保证Java进程的总大小可控。
例如,我们可能在环境中,这样限制容器内存:
```
$ docker run -it --rm --name yourcontainer -p 8080:8080 -m 800M repo/your-java-container:openjdk
```
那么就可以额外配置下面的环境变量直接指定JVM堆大小。
```
-e JAVA_OPTIONS='-Xmx300m'
```
- 明确配置GC和JIT并行线程数目以避免二者占用过多计算资源。
```
-XX:ParallelGCThreads
-XX:CICompilerCount
```
除了我前面介绍的OOM等问题在很多场景中还发现Java在Docker环境中似乎会意外使用Swap。具体原因待查但很有可能也是因为Ergonomics机制失效导致的我建议配置下面参数明确告知JVM系统内存限额。
```
-XX:MaxRAM=`cat /sys/fs/cgroup/memory/memory.limit_in_bytes`
```
也可以指定Docker运行参数例如
```
--memory-swappiness=0
```
这是受操作系统[Swappiness](https://en.wikipedia.org/wiki/Swappiness)机制影响当内存消耗达到一定门限操作系统会试图将不活跃的进程换出Swap out上面的参数有显式关闭Swap的作用。所以可以看到Java在Docker中的使用从操作系统、内核到JVM自身机制需要综合运用我们所掌握的知识。
回顾我在专栏第25讲JVM内存区域的介绍JVM内存消耗远不止包括堆很多时候仅仅设置Xmx是不够的MaxRAM也有助于JVM合理分配其他内存区域。如果应用需要设置更多Java启动参数但又不确定什么数值合理可以试试一些社区提供的[工具](https://github.com/cloudfoundry/java-buildpack-memory-calculator),但要注意通用工具的局限性。
更进一步来说对于容器镜像大小的问题如果你使用的是JDK 9以后的版本完全可以使用jlink工具定制最小依赖的Java运行环境将JDK裁剪为几十M的大小这样运行起来并不困难。
今天我从Docker环境中Java可能出现的问题开始分析了为什么容器环境对应用并不透明以及这种偏差干扰了JVM的相关机制。最后我从实践出发介绍了主要问题的解决思路希望对你在实际开发时有所帮助。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是针对我提到的微服务和Serverless等场景Java表现出的不足有哪些方法可以改善Java的表现
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。