mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 14:13:46 +08:00
mod
This commit is contained in:
@@ -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 < 100000) {
|
||||
int former = sharedState++;
|
||||
int latter = sharedState;
|
||||
if (former != latter - 1) {
|
||||
System.out.printf("Observed data race, former is " +
|
||||
former + ", " + "latter is " + 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:\>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 <= 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中的哪些方法呢?分别解决什么问题?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
@@ -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的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。
|
||||
|
||||
现代的(Oracle)JDK中,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)/hpp,JVM同步相关的各种基础逻辑。
|
||||
</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, "can not rebias toward VM thread");
|
||||
BiasedLocking::revoke_at_safepoint(obj);
|
||||
}
|
||||
assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
|
||||
}
|
||||
|
||||
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->mark();
|
||||
if (mark->is_neutral()) {
|
||||
// 将目前的Mark Word复制到Displaced Header上
|
||||
lock->set_displaced_header(mark);
|
||||
// 利用CAS设置对象的Mark Word
|
||||
if (mark == obj()->cas_set_mark((markOop) lock, mark)) {
|
||||
TEVENT(slow_enter: release stacklock);
|
||||
return;
|
||||
}
|
||||
// 检查存在竞争
|
||||
} else if (mark->has_locker() &&
|
||||
THREAD->is_lock_owned((address)mark->locker())) {
|
||||
// 清除
|
||||
lock->set_displaced_header(NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置Displaced Header
|
||||
lock->set_displaced_header(markOopDesc::unused_mark());
|
||||
ObjectSynchronizer::inflate(THREAD,
|
||||
obj(),
|
||||
inflate_cause_monitor_enter)->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停顿(STW,stop-the-world)的时间。
|
||||
</li>
|
||||
<li>
|
||||
fast_exit或者slow_exit是对应的锁释放逻辑。
|
||||
</li>
|
||||
|
||||
前面分析了synchronized的底层实现,理解起来有一定难度,下面我们来看一些相对轻松的内容。 我在上一讲对比了synchronized和ReentrantLock,Java核心类库中还有其他一些特别的锁类型,具体请参考下面的图。
|
||||
|
||||
<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<String, String> m = new TreeMap<>();
|
||||
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("读锁锁定!");
|
||||
try {
|
||||
return m.get(key);
|
||||
} finally {
|
||||
r.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public String put(String key, String entry) {
|
||||
w.lock();
|
||||
System.out.println("写锁锁定!");
|
||||
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相关实现和内部运行机制,简单介绍了并发包中提供的其他显式锁,并结合样例代码介绍了其使用方法,希望对你有所帮助。
|
||||
|
||||
## 一课一练
|
||||
|
||||
关于今天我们讨论的你做到心中有数了吗?思考一个问题,你知道“自旋锁”是做什么的吗?它的使用场景是什么?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
@@ -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 = () -> {System.out.println("Hello World!");};
|
||||
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<ThreadLocal<?>> {
|
||||
/** The value associated with this ThreadLocal. */
|
||||
Object value;
|
||||
Entry(ThreadLocal<?> k, Object v) {
|
||||
super(k);
|
||||
value = v;
|
||||
}
|
||||
}
|
||||
// …
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当Key为null时,该条目就变成“废弃条目”,相关“value”的回收,往往依赖于几个关键点,即set、remove、rehash。
|
||||
|
||||
下面是set的示例,我进行了精简和注释:
|
||||
|
||||
```
|
||||
private void set(ThreadLocal<?> key, Object value) {
|
||||
Entry[] tab = table;
|
||||
int len = tab.length;
|
||||
int i = key.threadLocalHashCode & (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) && sz >= threshold)
|
||||
rehash();// 清理废弃条目,如果仍然超限,则扩容(加倍)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
具体的清理逻辑是实现在cleanSomeSlots和expungeStaleEntry之中,如果你有兴趣可以自行阅读。
|
||||
|
||||
结合[专栏第4讲](http://time.geekbang.org/column/article/6970)介绍的引用类型,我们会发现一个特别的地方,通常弱引用都会和引用队列配合清理机制使用,但是ThreadLocal是个例外,它并没有这么做。
|
||||
|
||||
这意味着,废弃项目的回收**依赖于显式地触发,否则就要等待线程结束**,进而回收相应ThreadLocalMap!这就是很多OOM的来源,所以通常都会建议,应用一定要自己负责remove,并且不要和线程池配合,因为worker线程往往是不会退出的。
|
||||
|
||||
今天,我介绍了线程基础,分析了生命周期中的状态和各种方法之间的对应关系,这也有助于我们更好地理解synchronized和锁的影响,并介绍了一些需要注意的操作,希望对你有所帮助。
|
||||
|
||||
## 一课一练
|
||||
|
||||
关于今天我们讨论的题目你做到心中有数了吗?今天我准备了一个有意思的问题,写一个最简单的打印HelloWorld的程序,说说看,运行这个应用,Java至少会创建几个线程呢?然后思考一下,如何明确验证你的结论,真实情况很可能令你大跌眼镜哦。
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
@@ -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() + " obtained: " + first);
|
||||
try {
|
||||
Thread.sleep(1000L);
|
||||
synchronized (second) {
|
||||
System.out.println(this.getName() + " obtained: " + second);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
public static void main(String[] args) throws InterruptedException {
|
||||
String lockA = "lockA";
|
||||
String lockB = "lockB";
|
||||
DeadLockSample t1 = new DeadLockSample("Thread1", lockA, lockB);
|
||||
DeadLockSample t2 = new DeadLockSample("Thread2", 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本身也会把类似的简单死锁抽取出来,直接打印出来。
|
||||
|
||||
在实际应用中,类死锁情况未必有如此清晰的输出,但是总体上可以理解为:
|
||||
|
||||
**区分线程状态 -> 查看等待目标 -> 对比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("Detected deadlock threads:");
|
||||
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参数和基本原则。
|
||||
|
||||
今天,我从样例程序出发,介绍了死锁产生原因,并帮你熟悉了排查死锁基本工具的使用和典型思路,最后结合实例介绍了实际场景中的死锁分析方法与预防措施,希望对你有所帮助。
|
||||
|
||||
## 一课一练
|
||||
|
||||
关于今天我们讨论的题目你做到心中有数了吗?今天的思考题是,有时候并不是阻塞导致的死锁,只是某个线程进入了死循环,导致其他线程一直等待,这种问题如何诊断呢?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
327
极客时间专栏/Java核心技术面试精讲/模块二 Java进阶/第19讲 | Java并发包提供了哪些并发工具类?.md
Normal file
327
极客时间专栏/Java核心技术面试精讲/模块二 Java进阶/第19讲 | Java并发包提供了哪些并发工具类?.md
Normal 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("Action...GO!");
|
||||
Semaphore semaphore = new Semaphore(5);
|
||||
for (int i = 0; i < 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("is waiting for a permit!");
|
||||
semaphore.acquire();
|
||||
log("acquired a permit!");
|
||||
log("executed!");
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
log("released a permit!");
|
||||
semaphore.release();
|
||||
}
|
||||
}
|
||||
private void log(String msg){
|
||||
if (name == null) {
|
||||
name = Thread.currentThread().getName();
|
||||
}
|
||||
System.out.println(name + " " + 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 < 10; i++) {
|
||||
Thread t = new Thread(new MyWorker(semaphore));
|
||||
t.start();
|
||||
}
|
||||
System.out.println("Action...GO!");
|
||||
semaphore.release(5);
|
||||
System.out.println("Wait for permits off");
|
||||
while (semaphore.availablePermits()!=0) {
|
||||
Thread.sleep(100L);
|
||||
}
|
||||
System.out.println("Action...GO again!");
|
||||
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("Executed!");
|
||||
} 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 < 5; i++) {
|
||||
Thread t = new Thread(new FirstBatchWorker(latch));
|
||||
t.start();
|
||||
}
|
||||
for (int i = 0; i < 5; i++) {
|
||||
Thread t = new Thread(new SecondBatchWorker(latch));
|
||||
t.start();
|
||||
}
|
||||
// 注意这里也是演示目的的逻辑,并不是推荐的协调方式
|
||||
while ( latch.getCount() != 1 ){
|
||||
Thread.sleep(100L);
|
||||
}
|
||||
System.out.println("Wait for first batch finish");
|
||||
latch.countDown();
|
||||
}
|
||||
}
|
||||
class FirstBatchWorker implements Runnable {
|
||||
private CountDownLatch latch;
|
||||
public FirstBatchWorker(CountDownLatch latch) {
|
||||
this.latch = latch;
|
||||
}
|
||||
@Override
|
||||
public void run() {
|
||||
System.out.println("First batch executed!");
|
||||
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("Second batch executed!");
|
||||
} 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("Action...GO again!");
|
||||
}
|
||||
});
|
||||
for (int i = 0; i < 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<3 ; i++){
|
||||
System.out.println("Executed!");
|
||||
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的同步结构解决实际问题吗?谈谈你的使用场景和心得。
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
@@ -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 > 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 = "Good bye!";
|
||||
public static void main(String[] args) {
|
||||
// 使用较小的队列,以更好地在输出中展示其影响
|
||||
BlockingQueue<String> queue = new ArrayBlockingQueue<>(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<String> queue;
|
||||
public Producer(BlockingQueue<String> q) {
|
||||
this.queue = q;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
for (int i = 0; i < 20; i++) {
|
||||
try{
|
||||
Thread.sleep(5L);
|
||||
String msg = "Message" + i;
|
||||
System.out.println("Produced new item: " + msg);
|
||||
queue.put(msg);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
System.out.println("Time to say good bye!");
|
||||
queue.put(EXIT_MSG);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static class Consumer implements Runnable{
|
||||
private BlockingQueue<String> queue;
|
||||
public Consumer(BlockingQueue<String> q){
|
||||
this.queue=q;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
try{
|
||||
String msg;
|
||||
while(!EXIT_MSG.equalsIgnoreCase( (msg = queue.take()))){
|
||||
System.out.println("Consumed item: " + msg);
|
||||
Thread.sleep(10L);
|
||||
}
|
||||
System.out.println("Got exit message, bye!");
|
||||
}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,实现思路是怎样的呢?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
@@ -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方法。
|
||||
|
||||
```
|
||||
<T> Future<T> submit(Callable<T> 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<Runnable> workQueue;
|
||||
|
||||
|
||||
```
|
||||
|
||||
- 内部的“线程池”,这是指保持工作线程的集合,线程池需要在运行过程中管理线程创建、销毁。例如,对于带缓存的线程池,当任务压力较大时,线程池会创建新的工作线程;当业务压力退去,线程池会在闲置一段时间(默认60秒)后结束线程。
|
||||
|
||||
```
|
||||
private final HashSet<Worker> workers = new HashSet<>();
|
||||
|
||||
```
|
||||
|
||||
线程池的工作线程被抽象为静态内部类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<Runnable> 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 << COUNT_BITS) - 1;
|
||||
// 线程池状态,存储在数字的高位
|
||||
private static final int RUNNING = -1 << COUNT_BITS;
|
||||
…
|
||||
// Packing and unpacking ctl
|
||||
private static int runStateOf(int c) { return c & ~COUNT_MASK; }
|
||||
private static int workerCountOf(int c) { return c & 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) < corePoolSize) {
|
||||
if (addWorker(command, true))
|
||||
return;
|
||||
c = ctl.get();
|
||||
}
|
||||
// isRunning就是检查线程池是否被shutdown
|
||||
// 工作队列可能是有界的,offer是比较友好的入队方式
|
||||
if (isRunning(c) && workQueue.offer(command)) {
|
||||
int recheck = ctl.get();
|
||||
// 再次进行防御性检查
|
||||
if (! isRunning(recheck) && 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可能在线程池生命周期中被使用多少次?怎么验证自己的判断?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
@@ -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, "value");
|
||||
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<AtomicBTreePartition> lockFieldUpdater =
|
||||
AtomicLongFieldUpdater.newUpdater(AtomicBTreePartition.class, "lock");
|
||||
|
||||
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, "lock");
|
||||
|
||||
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 -> B -> A的更新,仅仅判断数值是A,可能导致不合理的修改操作。针对这种情况,Java提供了AtomicStampedReference工具类,通过为引用建立类似版本号(stamp)的方式,来保证CAS的正确性,具体用法请参考这里的[介绍](http://tutorials.jenkov.com/java-util-concurrent/atomicstampedreference.html)。
|
||||
|
||||
前面介绍了CAS的场景与实现,幸运的是,大多数情况下,Java开发者并不需要直接利用CAS代码去实现线程安全容器等,更多是通过并发包等间接享受到lock-free机制在扩展性上的好处。
|
||||
|
||||
下面我来介绍一下AbstractQueuedSynchronizer(AQS),其是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) &&
|
||||
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 < 0) // overflow
|
||||
throw new Error("Maximum lock count exceeded");
|
||||
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 && 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有什么作用?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
248
极客时间专栏/Java核心技术面试精讲/模块二 Java进阶/第23讲 | 请介绍类加载过程,什么是双亲委派模型?.md
Normal file
248
极客时间专栏/Java核心技术面试精讲/模块二 Java进阶/第23讲 | 请介绍类加载过程,什么是双亲委派模型?.md
Normal 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:<your_boot_classpath> your_App
|
||||
|
||||
# a意味着append,将指定目录添加到bootclasspath后面
|
||||
java -Xbootclasspath/a:<your_dir> your_App
|
||||
|
||||
# p意味着prepend,将指定目录添加到bootclasspath前面
|
||||
java -Xbootclasspath/p:<your_dir> 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平台模块化系统(JPMS),Java 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>
|
||||
还有就是较少人知道的AppCDS(Application Class-Data Sharing),CDS在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=<jsa> \
|
||||
-XX:SharedClassListFile=<classlist> -XX:SharedArchiveConfigFile=<config_file>
|
||||
|
||||
```
|
||||
|
||||
第二,在应用程序启动时,指定归档文件,并开启AppCDS。
|
||||
|
||||
```
|
||||
Java -Xshare:on -XX:+UseAppCDS -XX:SharedArchiveFile=<jsa> yourApp
|
||||
|
||||
```
|
||||
|
||||
通过上面的命令,JVM会通过内存映射技术,直接映射到相应的地址空间,免除了类加载、解析等各种开销。
|
||||
|
||||
AppCDS改善启动速度非常明显,传统的Java EE应用,一般可以提高20%~30%以上;实验中使用Spark KMeans负载,20个slave,可以提高11%的启动速度。
|
||||
|
||||
与此同时,降低内存footprint,因为同一环境的Java进程间可以共享部分数据结构。前面谈到的两个实验,平均可以减少10%以上的内存消耗。
|
||||
|
||||
当然,也不是没有局限性,如果恰好大量使用了运行时动态类加载,它的帮助就有限了。
|
||||
|
||||
今天我梳理了一下类加载的过程,并针对Java新版中类加载机制发生的变化,进行了相对全面的总结,最后介绍了一个改善类加载速度的特性,希望对你有所帮助。
|
||||
|
||||
## 一课一练
|
||||
|
||||
关于今天我们讨论的题目你做到心中有数了吗?今天的思考题是,谈谈什么是Jar Hell问题?你有遇到过类似情况吗,如何解决呢?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
212
极客时间专栏/Java核心技术面试精讲/模块二 Java进阶/第24讲 | 有哪些方法可以在运行时动态生成一个Java类?.md
Normal file
212
极客时间专栏/Java核心技术面试精讲/模块二 Java进阶/第24讲 | 有哪些方法可以在运行时动态生成一个Java类?.md
Normal 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<?> defineClass(String name, byte[] b, int off, int len,
|
||||
ProtectionDomain protectionDomain)
|
||||
protected final Class<?> defineClass(String name, java.nio.ByteBuffer b,
|
||||
ProtectionDomain protectionDomain)
|
||||
|
||||
```
|
||||
|
||||
我这里只选取了最基础的两个典型的defineClass实现,Java重载了几个不同的方法。
|
||||
|
||||
可以看出,只要能够生成出规范的字节码,不管是作为byte数组的形式,还是放到ByteBuffer里,都可以平滑地完成字节码到Java对象的转换过程。
|
||||
|
||||
JDK提供的defineClass方法,最终都是本地代码实现的。
|
||||
|
||||
```
|
||||
static native Class<?> defineClass1(ClassLoader loader, String name, byte[] b, int off, int len,
|
||||
ProtectionDomain pd, String source);
|
||||
|
||||
static native Class<?> 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<?> 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 >= 0 && lvar <= 0xFFFF;
|
||||
// 根据变量数值,以不同格式,dump操作码
|
||||
if (lvar <= 3) {
|
||||
out.writeByte(opcode_0 + lvar);
|
||||
} else if (lvar <= 0xFF) {
|
||||
out.writeByte(opcode);
|
||||
out.writeByte(lvar & 0xFF);
|
||||
} else {
|
||||
// 使用宽指令修饰符,如果变量索引不能用无符号byte
|
||||
out.writeByte(opc_wide);
|
||||
out.writeByte(opcode);
|
||||
out.writeShort(lvar & 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<?>[] interfaces,
|
||||
InvocationHandler h)
|
||||
|
||||
```
|
||||
|
||||
我们分析一下,动态代码生成是具体发生在什么阶段呢?
|
||||
|
||||
不错,就是在newProxyInstance生成代理类实例的时候。我选取了JDK自己采用的ASM作为示例,一起来看看用ASM实现的简要过程,请参考下面的示例代码片段。
|
||||
|
||||
第一步,生成对应的类,其实和我们去写Java代码很类似,只不过改为用ASM方法和指定参数,代替了我们书写的源码。
|
||||
|
||||
```
|
||||
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
|
||||
|
||||
cw.visit(V1_8, // 指定Java版本
|
||||
ACC_PUBLIC, // 说明是public类型
|
||||
"com/mycorp/HelloProxy", // 指定包和类的名称
|
||||
null, // 签名,null表示不是泛型
|
||||
"java/lang/Object", // 指定父类
|
||||
new String[]{ "com/mycorp/Hello" }); // 指定需要实现的接口
|
||||
|
||||
```
|
||||
|
||||
更进一步,我们可以按照需要为代理对象实例,生成需要的方法和逻辑。
|
||||
|
||||
```
|
||||
MethodVisitor mv = cw.visitMethod(
|
||||
ACC_PUBLIC, // 声明公共方法
|
||||
"sayHello", // 方法名称
|
||||
"()Ljava/lang/Object;", // 描述符
|
||||
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/#/)等。
|
||||
|
||||
## 一课一练
|
||||
|
||||
关于今天我们讨论的题目你做到心中有数了吗?试想,假如我们有这样一个需求,需要添加某个功能,例如对某类型资源如网络通信的消耗进行统计,重点要求是,不开启时必须是**零开销,而不是低开销,**可以利用我们今天谈到的或者相关的技术实现吗?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
@@ -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进程唯一的。
|
||||
|
||||
首先,**程序计数器**(PC,Program 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内存模型,不可避免的要涉及OutOfMemory(OOM)问题,那么在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的空间,你觉得可能问题的原因是什么?想要弄清楚这个问题,还需要什么信息呢?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
229
极客时间专栏/Java核心技术面试精讲/模块二 Java进阶/第26讲 | 如何监控和诊断JVM堆内和堆外内存使用?.md
Normal file
229
极客时间专栏/Java核心技术面试精讲/模块二 Java进阶/第26讲 | 如何监控和诊断JVM堆内和堆外内存使用?.md
Normal 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 Tracking(NMT)特性,它会从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 Buffer(TLAB),据我所知所有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*Survivor,JVM参数格式是
|
||||
|
||||
```
|
||||
-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)。唯一的例外就是Internal(JDK 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内存使用进行监控,有哪些技术可以做到?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
167
极客时间专栏/Java核心技术面试精讲/模块二 Java进阶/第27讲 | Java常见的垃圾收集器有哪些?.md
Normal file
167
极客时间专栏/Java核心技术面试精讲/模块二 Java进阶/第27讲 | Java常见的垃圾收集器有哪些?.md
Normal 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常见的垃圾收集器有哪些?
|
||||
|
||||
## 典型回答
|
||||
|
||||
实际上,垃圾收集器(GC,Garbage 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>
|
||||
CMS(Concurrent Mark Sweep) GC,基于标记-清除(Mark-Sweep)算法,设计目标是尽量减少停顿时间,这一点对于Web等反应时间敏感的应用非常重要,一直到今天,仍然有很多系统使用CMS GC。但是,CMS采用的标记-清除算法,存在着内存碎片化问题,所以难以避免在长时间运行等情况下发生full GC,导致恶劣的停顿。另外,既然强调了并发(Concurrent),CMS会占用更多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 GC,G1未必能做到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 GC,Eden就会空闲下来,直到再次达到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=<N>
|
||||
|
||||
```
|
||||
|
||||
<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日志呢?还会额外添加哪些选项?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
238
极客时间专栏/Java核心技术面试精讲/模块二 Java进阶/第28讲 | 谈谈你的GC调优思路?.md
Normal file
238
极客时间专栏/Java核心技术面试精讲/模块二 Java进阶/第28讲 | 谈谈你的GC调优思路?.md
Normal 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 region,G1会将超过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=<N, 例如16>M
|
||||
|
||||
```
|
||||
|
||||
从GC算法的角度,G1选择的是复合算法,可以简化理解为:
|
||||
|
||||
<li>
|
||||
在新生代,G1采用的仍然是并行的复制算法,所以同样会发生Stop-The-World的暂停。
|
||||
</li>
|
||||
<li>
|
||||
在老年代,大部分情况下都是并发标记,而整理(Compact)则是和新生代GC时捎带进行,并且不是整体性的整理,而是增量进行的。
|
||||
</li>
|
||||
|
||||
我在[上一讲](http://time.geekbang.org/column/article/10513)曾经介绍过,习惯上人们喜欢把新生代GC(Young 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的个数,当前默认值是8,Mixed 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发生的原因,有哪些方式?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
@@ -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 Model,JMM)为我们提供了一个在纷乱之中达成一致的指导准则。
|
||||
|
||||
今天我要问你的问题是,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规范。在特定的上下文里,也许会与JVM(Java)内存结构等混淆,并不存在绝对的对错,但一定要清楚面试官的本意,有的面试官也会特意考察是否清楚这两种概念的区别。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
**为什么需要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 Locking,DCL)的失效问题,具体可以参考我在[第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执行可能?有什么工具可以辅助吗?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
@@ -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等资源限制是通过CGroup(Control 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与虚拟机的区别。从技术角度,基于namespace,Docker为每个容器提供了单独的命名空间,对网络、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的表现?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user