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

View File

@@ -0,0 +1,347 @@
<audio id="audio" title="第10讲 | 如何保证集合是线程安全的? ConcurrentHashMap如何实现高效地线程安全" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/91/b4/916d2e1f1b92355ce4323851e731ffb4.mp3"></audio>
我在之前两讲介绍了Java集合框架的典型容器类它们绝大部分都不是线程安全的仅有的线程安全实现比如Vector、Stack在性能方面也远不尽如人意。幸好Java语言提供了并发包java.util.concurrent为高度并发需求提供了更加全面的工具支持。
今天我要问你的问题是如何保证容器是线程安全的ConcurrentHashMap如何实现高效地线程安全
## 典型回答
Java提供了不同层面的线程安全支持。在传统集合框架内部除了Hashtable等同步容器还提供了所谓的同步包装器Synchronized Wrapper我们可以调用Collections工具类提供的包装方法来获取一个同步的包装容器如Collections.synchronizedMap但是它们都是利用非常粗粒度的同步方式在高并发情况下性能比较低下。
另外,更加普遍的选择是利用并发包提供的线程安全容器类,它提供了:
<li>
各种并发容器比如ConcurrentHashMap、CopyOnWriteArrayList。
</li>
<li>
各种线程安全队列Queue/Deque如ArrayBlockingQueue、SynchronousQueue。
</li>
<li>
各种有序容器的线程安全版本等。
</li>
具体保证线程安全的方式包括有从简单的synchronize方式到基于更加精细化的比如基于分离锁实现的ConcurrentHashMap等并发实现等。具体选择要看开发的场景需求总体来说并发包内提供的容器通用场景远优于早期的简单同步实现。
## 考点分析
谈到线程安全和并发可以说是Java面试中必考的考点我上面给出的回答是一个相对宽泛的总结而且ConcurrentHashMap等并发容器实现也在不断演进不能一概而论。
如果要深入思考并回答这个问题及其扩展方面,至少需要:
<li>
理解基本的线程安全工具。
</li>
<li>
理解传统集合框架并发编程中Map存在的问题清楚简单同步方式的不足。
</li>
<li>
梳理并发包内尤其是ConcurrentHashMap采取了哪些方法来提高并发表现。
</li>
<li>
最好能够掌握ConcurrentHashMap自身的演进目前的很多分析资料还是基于其早期版本。
</li>
今天我主要是延续专栏之前两讲的内容重点解读经常被同时考察的HashMap和ConcurrentHashMap。今天这一讲并不是对并发方面的全面梳理毕竟这也不是专栏一讲可以介绍完整的算是个开胃菜吧类似CAS等更加底层的机制后面会在Java进阶模块中的并发主题有更加系统的介绍。
## 知识扩展
1.为什么需要ConcurrentHashMap
Hashtable本身比较低效因为它的实现基本就是将put、get、size等各种方法加上“synchronized”。简单来说这就导致了所有并发操作都要竞争同一把锁一个线程在进行同步操作时其他线程只能等待大大降低了并发操作的效率。
前面已经提过HashMap不是线程安全的并发情况会导致类似CPU占用100%等一些问题那么能不能利用Collections提供的同步包装器来解决问题呢
看看下面的代码片段我们发现同步包装器只是利用输入Map构造了另一个同步版本所有操作虽然不再声明成为synchronized方法但是还是利用了“this”作为互斥的mutex没有真正意义上的改进
```
private static class SynchronizedMap&lt;K,V&gt;
implements Map&lt;K,V&gt;, Serializable {
private final Map&lt;K,V&gt; m; // Backing Map
final Object mutex; // Object on which to synchronize
// …
public int size() {
synchronized (mutex) {return m.size();}
}
// …
}
```
所以Hashtable或者同步包装版本都只是适合在非高度并发的场景下。
2.ConcurrentHashMap分析
我们再来看看ConcurrentHashMap是如何设计实现的为什么它能大大提高并发效率。
首先,我这里强调,**ConcurrentHashMap的设计实现其实一直在演化**比如在Java 8中就发生了非常大的变化Java 7其实也有不少更新所以我这里将比较分析结构、实现机制等方面对比不同版本的主要区别。
早期ConcurrentHashMap其实现是基于
<li>
分离锁也就是将内部进行分段Segment里面则是HashEntry的数组和HashMap类似哈希相同的条目也是以链表形式存放。
</li>
<li>
HashEntry内部使用volatile的value字段来保证可见性也利用了不可变对象的机制以改进利用Unsafe提供的底层能力比如volatile access去直接完成部分操作以最优化性能毕竟Unsafe中的很多操作都是JVM intrinsic优化过的。
</li>
你可以参考下面这个早期ConcurrentHashMap内部结构的示意图其核心是利用分段设计在进行并发操作的时候只需要锁定相应段这样就有效避免了类似Hashtable整体同步的问题大大提高了性能。
<img src="https://static001.geekbang.org/resource/image/d4/d9/d45bcf9a34da2ef1ef335532b0198bd9.png" alt="" />
在构造的时候Segment的数量由所谓的concurrencyLevel决定默认是16也可以在相应构造函数直接指定。注意Java需要它是2的幂数值如果输入是类似15这种非幂值会被自动调整到16之类2的幂数值。
具体情况我们一起看看一些Map基本操作的[源码](http://hg.openjdk.java.net/jdk7/jdk7/jdk/file/9b8c96f96a0f/src/share/classes/java/util/concurrent/ConcurrentHashMap.java)这是JDK 7比较新的get代码。针对具体的优化部分为方便理解我直接注释在代码段里get操作需要保证的是可见性所以并没有什么同步逻辑。
```
public V get(Object key) {
Segment&lt;K,V&gt; s; // manually integrate access methods to reduce overhead
HashEntry&lt;K,V&gt;[] tab;
int h = hash(key.hashCode());
//利用位操作替换普通数学运算
long u = (((h &gt;&gt;&gt; segmentShift) &amp; segmentMask) &lt;&lt; SSHIFT) + SBASE;
// 以Segment为单位进行定位
// 利用Unsafe直接进行volatile access
if ((s = (Segment&lt;K,V&gt;)UNSAFE.getObjectVolatile(segments, u)) != null &amp;&amp;
(tab = s.table) != null) {
//省略
}
return null;
}
```
而对于put操作首先是通过二次哈希避免哈希冲突然后以Unsafe调用方式直接获取相应的Segment然后进行线程安全的put操作
```
public V put(K key, V value) {
Segment&lt;K,V&gt; s;
if (value == null)
throw new NullPointerException();
// 二次哈希,以保证数据的分散性,避免哈希冲突
int hash = hash(key.hashCode());
int j = (hash &gt;&gt;&gt; segmentShift) &amp; segmentMask;
if ((s = (Segment&lt;K,V&gt;)UNSAFE.getObject // nonvolatile; recheck
(segments, (j &lt;&lt; SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
```
其核心逻辑实现在下面的内部方法中:
```
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// scanAndLockForPut会去查找是否有key相同Node
// 无论如何,确保获取锁
HashEntry&lt;K,V&gt; node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry&lt;K,V&gt;[] tab = table;
int index = (tab.length - 1) &amp; hash;
HashEntry&lt;K,V&gt; first = entryAt(tab, index);
for (HashEntry&lt;K,V&gt; e = first;;) {
if (e != null) {
K k;
// 更新已有value...
}
else {
// 放置HashEntry到特定位置如果超过阈值进行rehash
// ...
}
}
} finally {
unlock();
}
return oldValue;
}
```
所以,从上面的源码清晰的看出,在进行并发写操作时:
<li>
ConcurrentHashMap会获取再入锁以保证数据一致性Segment本身就是基于ReentrantLock的扩展实现所以在并发修改期间相应Segment是被锁定的。
</li>
<li>
在最初阶段进行重复性的扫描以确定相应key值是否已经在数组里面进而决定是更新还是放置操作你可以在代码里看到相应的注释。重复扫描、检测冲突是ConcurrentHashMap的常见技巧。
</li>
<li>
我在专栏上一讲介绍HashMap时提到了可能发生的扩容问题在ConcurrentHashMap中同样存在。不过有一个明显区别就是它进行的不是整体的扩容而是单独对Segment进行扩容细节就不介绍了。
</li>
另外一个Map的size方法同样需要关注它的实现涉及分离锁的一个副作用。
试想如果不进行同步简单的计算所有Segment的总值可能会因为并发put导致结果不准确但是直接锁定所有Segment进行计算就会变得非常昂贵。其实分离锁也限制了Map的初始化等操作。
所以ConcurrentHashMap的实现是通过重试机制RETRIES_BEFORE_LOCK指定重试次数2来试图获得可靠值。如果没有监控到发生变化通过对比Segment.modCount就直接返回否则获取锁进行操作。
下面我来对比一下,**在Java 8和之后的版本中ConcurrentHashMap发生了哪些变化呢**
<li>
总体结构上它的内部存储变得和我在专栏上一讲介绍的HashMap结构非常相似同样是大的桶bucket数组然后内部也是一个个所谓的链表结构bin同步的粒度要更细致一些。
</li>
<li>
其内部仍然有Segment定义但仅仅是为了保证序列化时的兼容性而已不再有任何结构上的用处。
</li>
<li>
因为不再使用Segment初始化操作大大简化修改为lazy-load形式这样可以有效避免初始开销解决了老版本很多人抱怨的这一点。
</li>
<li>
数据存储利用volatile来保证可见性。
</li>
<li>
使用CAS等操作在特定场景进行无锁并发操作。
</li>
<li>
使用Unsafe、LongAdder之类底层手段进行极端情况的优化。
</li>
先看看现在的数据存储内部实现我们可以发现Key是final的因为在生命周期中一个条目的Key发生变化是不可能的与此同时val则声明为volatile以保证可见性。
```
static class Node&lt;K,V&gt; implements Map.Entry&lt;K,V&gt; {
final int hash;
final K key;
volatile V val;
volatile Node&lt;K,V&gt; next;
// …
}
```
我这里就不再介绍get方法和构造函数了相对比较简单直接看并发的put是如何实现的。
```
final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node&lt;K,V&gt;[] tab = table;;) {
Node&lt;K,V&gt; f; int n, i, fh; K fk; V fv;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) &amp; hash)) == null) {
// 利用CAS去进行无锁线程安全操作如果bin是空的
if (casTabAt(tab, i, null, new Node&lt;K,V&gt;(hash, key, value)))
break;
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else if (onlyIfAbsent // 不加锁,进行检查
&amp;&amp; fh == hash
&amp;&amp; ((fk = f.key) == key || (fk != null &amp;&amp; key.equals(fk)))
&amp;&amp; (fv = f.val) != null)
return fv;
else {
V oldVal = null;
synchronized (f) {
// 细粒度的同步修改操作...
}
}
// Bin超过阈值进行树化
if (binCount != 0) {
if (binCount &gt;= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
```
初始化操作实现在initTable里面这是一个典型的CAS使用场景利用volatile的sizeCtl作为互斥手段如果发现竞争性的初始化就spin在那里等待条件恢复否则利用CAS设置排他标志。如果成功则进行初始化否则重试。
请参考下面代码:
```
private final Node&lt;K,V&gt;[] initTable() {
Node&lt;K,V&gt;[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 如果发现冲突进行spin等待
if ((sc = sizeCtl) &lt; 0)
Thread.yield();
// CAS成功返回true则进入真正的初始化逻辑
else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc &gt; 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings(&quot;unchecked&quot;)
Node&lt;K,V&gt;[] nt = (Node&lt;K,V&gt;[])new Node&lt;?,?&gt;[n];
table = tab = nt;
sc = n - (n &gt;&gt;&gt; 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
```
当bin为空时同样是没有必要锁定也是以CAS操作去放置。
你有没有注意到在同步逻辑上它使用的是synchronized而不是通常建议的ReentrantLock之类这是为什么呢现代JDK中synchronized已经被不断优化可以不再过分担心性能差异另外相比于ReentrantLock它可以减少内存消耗这是个非常大的优势。
与此同时更多细节实现通过使用Unsafe进行了优化例如tabAt就是直接利用getObjectAcquire避免间接调用的开销。
```
static final &lt;K,V&gt; Node&lt;K,V&gt; tabAt(Node&lt;K,V&gt;[] tab, int i) {
return (Node&lt;K,V&gt;)U.getObjectAcquire(tab, ((long)i &lt;&lt; ASHIFT) + ABASE);
}
```
再看看现在是如何实现size操作的。[阅读代码](http://hg.openjdk.java.net/jdk/jdk/file/12fc7bf488ec/src/java.base/share/classes/java/util/concurrent/ConcurrentHashMap.java)你会发现真正的逻辑是在sumCount方法中 那么sumCount做了什么呢
```
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i &lt; as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
```
我们发现虽然思路仍然和以前类似都是分而治之的进行计数然后求和处理但实现却基于一个奇怪的CounterCell。 难道它的数值,就更加准确吗?数据一致性是怎么保证的?
```
static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
```
其实对于CounterCell的操作是基于java.util.concurrent.atomic.LongAdder进行的是一种JVM利用空间换取更高效率的方法利用了[Striped64](http://hg.openjdk.java.net/jdk/jdk/file/12fc7bf488ec/src/java.base/share/classes/java/util/concurrent/atomic/Striped64.java)内部的复杂逻辑。这个东西非常小众大多数情况下建议还是使用AtomicLong足以满足绝大部分应用的性能需求。
今天我从线程安全问题开始概念性的总结了基本容器工具分析了早期同步容器的问题进而分析了Java 7和Java 8中ConcurrentHashMap是如何设计实现的希望ConcurrentHashMap的并发技巧对你在日常开发可以有所帮助。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗留一个道思考题给你在产品代码中有没有典型的场景需要使用类似ConcurrentHashMap这样的并发容器呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,298 @@
<audio id="audio" title="第11讲 | Java提供了哪些IO方式 NIO如何实现多路复用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4a/6e/4a43795e570bce5981ff28c1d2f39a6e.mp3"></audio>
IO一直是软件开发中的核心部分之一伴随着海量数据增长和分布式系统的发展IO扩展能力愈发重要。幸运的是Java平台IO机制经过不断完善虽然在某些方面仍有不足但已经在实践中证明了其构建高扩展性应用的能力。
今天我要问你的问题是,**Java提供了哪些IO方式 NIO如何实现多路复用**
## 典型回答
Java IO方式有很多种基于不同的IO抽象模型和交互方式可以进行简单区分。
第一传统的java.io包它基于流模型实现提供了我们最熟知的一些IO功能比如File抽象、输入输出流等。交互方式是同步、阻塞的方式也就是说在读取输入流或者写入输出流时在读、写动作完成之前线程会一直阻塞在那里它们之间的调用是可靠的线性顺序。
java.io包的好处是代码比较简单、直观缺点则是IO效率和扩展性存在局限性容易成为应用性能的瓶颈。
很多时候人们也把java.net下面提供的部分网络API比如Socket、ServerSocket、HttpURLConnection也归类到同步阻塞IO类库因为网络通信同样是IO行为。
第二在Java 1.4中引入了NIO框架java.nio包提供了Channel、Selector、Buffer等新的抽象可以构建多路复用的、同步非阻塞IO程序同时提供了更接近操作系统底层的高性能数据操作方式。
第三在Java 7中NIO有了进一步的改进也就是NIO 2引入了异步非阻塞IO方式也有很多人叫它AIOAsynchronous IO。异步IO操作基于事件和回调机制可以简单理解为应用操作直接返回而不会阻塞在那里当后台处理完成操作系统会通知相应线程进行后续工作。
## 考点分析
我上面列出的回答是基于一种常见分类方式即所谓的BIO、NIO、NIO 2AIO
在实际面试中从传统IO到NIO、NIO 2其中有很多地方可以扩展开来考察点涉及方方面面比如
<li>
基础API功能与设计 InputStream/OutputStream和Reader/Writer的关系和区别。
</li>
<li>
NIO、NIO 2的基本组成。
</li>
<li>
给定场景分别用不同模型实现分析BIO、NIO等模式的设计和实现原理。
</li>
<li>
NIO提供的高性能数据操作方式是基于什么原理如何使用
</li>
<li>
或者从开发者的角度来看你觉得NIO自身实现存在哪些问题有什么改进的想法吗
</li>
IO的内容比较多专栏一讲很难能够说清楚。IO不仅仅是多路复用NIO 2也不仅仅是异步IO尤其是数据操作部分会在专栏下一讲详细分析。
## 知识扩展
首先,需要澄清一些基本概念:
<li>
区分同步或异步synchronous/asynchronous。简单来说同步是一种可靠的有序运行机制当我们进行同步操作时后续的任务是等待当前调用返回才会进行下一步而异步则相反其他任务不需要等待当前调用返回通常依靠事件、回调等机制来实现任务间次序关系。
</li>
<li>
区分阻塞与非阻塞blocking/non-blocking。在进行阻塞操作时当前线程会处于阻塞状态无法从事其他任务只有当条件就绪才能继续比如ServerSocket新连接建立完毕或数据读取、写入操作完成而非阻塞则是不管IO操作是否结束直接返回相应操作在后台继续处理。
</li>
不能一概而论认为同步或阻塞就是低效,具体还要看应用和系统特征。
对于java.io我们都非常熟悉我这里就从总体上进行一下总结如果需要学习更加具体的操作你可以通过[教程](https://docs.oracle.com/javase/tutorial/essential/io/streams.html)等途径完成。总体上,我认为你至少需要理解一下内容。
<li>
IO不仅仅是对文件的操作网络编程中比如Socket通信都是典型的IO操作目标。
</li>
<li>
输入流、输出流InputStream/OutputStream是用于读取或写入字节的例如操作图片文件。
</li>
<li>
而Reader/Writer则是用于操作字符增加了字符编解码等功能适用于类似从文件中读取或者写入文本信息。本质上计算机操作的都是字节不管是网络通信还是文件读取Reader/Writer相当于构建了应用逻辑和原始数据之间的桥梁。
</li>
<li>
BufferedOutputStream等带缓冲区的实现可以避免频繁的磁盘读写进而提高IO处理效率。这种设计利用了缓冲区将批量数据进行一次操作但在使用中千万别忘了flush。
</li>
<li>
参考下面这张类图很多IO工具类都实现了Closeable接口因为需要进行资源的释放。比如打开FileInputStream它就会获取相应的文件描述符FileDescriptor需要利用try-with-resources、 try-finally等机制保证FileInputStream被明确关闭进而相应文件描述符也会失效否则将导致资源无法被释放。利用专栏前面的内容提到的Cleaner或finalize机制作为资源释放的最后把关也是必要的。
</li>
下面是我整理的一个简化版的类图,阐述了日常开发应用较多的类型和结构关系。
<img src="https://static001.geekbang.org/resource/image/43/8b/4338e26731db0df390896ab305506d8b.png" alt="">
**1. Java NIO概览**
首先熟悉一下NIO的主要组成部分
<li>
Buffer高效的数据容器除了布尔类型所有原始数据类型都有相应的Buffer实现。
</li>
<li>
Channel类似在Linux之类操作系统上看到的文件描述符是NIO中被用来支持批量式IO操作的一种抽象。
File或者Socket通常被认为是比较高层次的抽象而Channel则是更加操作系统底层的一种抽象这也使得NIO得以充分利用现代操作系统底层机制获得特定场景的性能优化例如DMADirect Memory Access等。不同层次的抽象是相互关联的我们可以通过Socket获取Channel反之亦然。
</li>
<li>
Selector是NIO实现多路复用的基础它提供了一种高效的机制可以检测到注册在Selector上的多个Channel中是否有Channel处于就绪状态进而实现了单线程对多Channel的高效管理。Selector同样是基于底层操作系统机制不同模式、不同版本都存在区别例如在最新的代码库里相关实现如下
</li>
>
Linux上依赖于[epoll](http://hg.openjdk.java.net/jdk/jdk/file/d8327f838b88/src/java.base/linux/classes/sun/nio/ch/EPollSelectorImpl.java)Windows上NIO2AIO模式则是依赖于[iocp](http://hg.openjdk.java.net/jdk/jdk/file/d8327f838b88/src/java.base/windows/classes/sun/nio/ch/Iocp.java)。
- Charset提供Unicode字符串定义NIO也提供了相应的编解码器等例如通过下面的方式进行字符串到ByteBuffer的转换
```
Charset.defaultCharset().encode(&quot;Hello world!&quot;));
```
**2. NIO能解决什么问题**
下面我通过一个典型场景来分析为什么需要NIO为什么需要多路复用。设想我们需要实现一个服务器应用只简单要求能够同时服务多个客户端请求即可。
使用java.io和java.net中的同步、阻塞式API可以简单实现。
```
public class DemoServer extends Thread {
private ServerSocket serverSocket;
public int getPort() {
return serverSocket.getLocalPort();
}
public void run() {
try {
serverSocket = new ServerSocket(0);
while (true) {
Socket socket = serverSocket.accept();
RequestHandler requestHandler = new RequestHandler(socket);
requestHandler.start();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (serverSocket != null) {
try {
serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
;
}
}
}
public static void main(String[] args) throws IOException {
DemoServer server = new DemoServer();
server.start();
try (Socket client = new Socket(InetAddress.getLocalHost(), server.getPort())) {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(client.getInputStream()));
bufferedReader.lines().forEach(s -&gt; System.out.println(s));
}
}
}
// 简化实现,不做读取,直接发送字符串
class RequestHandler extends Thread {
private Socket socket;
RequestHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try (PrintWriter out = new PrintWriter(socket.getOutputStream());) {
out.println(&quot;Hello world!&quot;);
out.flush();
} catch (Exception e) {
e.printStackTrace();
}
}
}
```
其实现要点是:
<li>
服务器端启动ServerSocket端口0表示自动绑定一个空闲端口。
</li>
<li>
调用accept方法阻塞等待客户端连接。
</li>
<li>
利用Socket模拟了一个简单的客户端只进行连接、读取、打印。
</li>
<li>
当连接建立后,启动一个单独线程负责回复客户端请求。
</li>
这样一个简单的Socket服务器就被实现出来了。
思考一下,这个解决方案在扩展性方面,可能存在什么潜在问题呢?
大家知道Java语言目前的线程实现是比较重量级的启动或者销毁一个线程是有明显开销的每个线程都有单独的线程栈等结构需要占用非常明显的内存所以每一个Client启动一个线程似乎都有些浪费。
那么,稍微修正一下这个问题,我们引入线程池机制来避免浪费。
```
serverSocket = new ServerSocket(0);
executor = Executors.newFixedThreadPool(8);
while (true) {
Socket socket = serverSocket.accept();
RequestHandler requestHandler = new RequestHandler(socket);
executor.execute(requestHandler);
}
```
这样做似乎好了很多,通过一个固定大小的线程池,来负责管理工作线程,避免频繁创建、销毁线程的开销,这是我们构建并发服务的典型方式。这种工作方式,可以参考下图来理解。
<img src="https://static001.geekbang.org/resource/image/da/29/da7e1ecfd3c3ee0263b8892342dbc629.png" alt="">
如果连接数并不是非常多,只有最多几百个连接的普通应用,这种模式往往可以工作的很好。但是,如果连接数量急剧上升,这种实现方式就无法很好地工作了,因为线程上下文切换开销会在高并发时变得很明显,这是同步阻塞方式的低扩展性劣势。
NIO引入的多路复用机制提供了另外一种思路请参考我下面提供的新的版本。
```
public class NIOServer extends Thread {
public void run() {
try (Selector selector = Selector.open();
ServerSocketChannel serverSocket = ServerSocketChannel.open();) {// 创建Selector和Channel
serverSocket.bind(new InetSocketAddress(InetAddress.getLocalHost(), 8888));
serverSocket.configureBlocking(false);
// 注册到Selector并说明关注点
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select();// 阻塞等待就绪的Channel这是关键点之一
Set&lt;SelectionKey&gt; selectedKeys = selector.selectedKeys();
Iterator&lt;SelectionKey&gt; iter = selectedKeys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
// 生产系统中一般会额外进行就绪状态检查
sayHelloWorld((ServerSocketChannel) key.channel());
iter.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
private void sayHelloWorld(ServerSocketChannel server) throws IOException {
try (SocketChannel client = server.accept();) { client.write(Charset.defaultCharset().encode(&quot;Hello world!&quot;));
}
}
// 省略了与前面类似的main
}
```
这个非常精简的样例掀开了NIO多路复用的面纱我们可以分析下主要步骤和元素
<li>
首先通过Selector.open()创建一个Selector作为类似调度员的角色。
</li>
<li>
然后创建一个ServerSocketChannel并且向Selector注册通过指定SelectionKey.OP_ACCEPT告诉调度员它关注的是新的连接请求。
**注意**为什么我们要明确配置非阻塞模式呢这是因为阻塞模式下注册操作是不允许的会抛出IllegalBlockingModeException异常。
</li>
<li>
Selector阻塞在select操作当有Channel发生接入请求就会被唤醒。
</li>
<li>
在sayHelloWorld方法中通过SocketChannel和Buffer进行数据操作在本例中是发送了一段字符串。
</li>
可以看到在前面两个样例中IO都是同步阻塞模式所以需要多线程以实现多任务处理。而NIO则是利用了单线程轮询事件的机制通过高效地定位就绪的Channel来决定做什么仅仅select阶段是阻塞的可以有效避免大量客户端连接时频繁线程切换带来的问题应用的扩展能力有了非常大的提高。下面这张图对这种实现思路进行了形象地说明。
<img src="https://static001.geekbang.org/resource/image/ad/a2/ad3b4a49f4c1bff67124563abc50a0a2.png" alt="">
在Java 7引入的NIO 2中又增添了一种额外的异步IO模式利用事件和回调处理Accept、Read等操作。 AIO实现看起来是类似这样子
```
AsynchronousServerSocketChannel serverSock = AsynchronousServerSocketChannel.open().bind(sockAddr);
serverSock.accept(serverSock, new CompletionHandler&lt;&gt;() { //为异步操作指定CompletionHandler回调函数
@Override
public void completed(AsynchronousSocketChannel sockChannel, AsynchronousServerSocketChannel serverSock) {
serverSock.accept(serverSock, this);
// 另外一个 writesockCompletionHandler{}
sayHelloWorld(sockChannel, Charset.defaultCharset().encode
(&quot;Hello World!&quot;));
}
// 省略其他路径处理方法...
});
```
鉴于其编程要素如Future、CompletionHandler等我们还没有进行准备工作为避免理解困难我会在专栏后面相关概念补充后的再进行介绍尤其是Reactor、Proactor模式等方面将在Netty主题一起分析这里我先进行概念性的对比
<li>
基本抽象很相似AsynchronousServerSocketChannel对应于上面例子中的ServerSocketChannelAsynchronousSocketChannel则对应SocketChannel。
</li>
<li>
业务逻辑的关键在于通过指定CompletionHandler回调接口在accept/read/write等关键节点通过事件机制调用这是非常不同的一种编程思路。
</li>
今天我初步对Java提供的IO机制进行了介绍概要地分析了传统同步IO和NIO的主要组成并根据典型场景通过不同的IO模式进行了实现与拆解。专栏下一讲我还将继续分析Java IO的主题。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗留一道思考题给你NIO多路复用的局限性是什么呢你遇到过相关的问题吗
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,308 @@
<audio id="audio" title="第12讲 | Java有几种文件拷贝方式哪一种最高效" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/82/de/8246090f09df3e374742411763ef8fde.mp3"></audio>
我在专栏上一讲提到NIO不止是多路复用NIO 2也不只是异步IO今天我们来看看Java IO体系中其他不可忽略的部分。
今天我要问你的问题是Java有几种文件拷贝方式哪一种最高效
## 典型回答
Java有多种比较典型的文件拷贝实现方式比如
利用java.io类库直接为源文件构建一个FileInputStream读取然后再为目标文件构建一个FileOutputStream完成写入工作。
```
public static void copyFileByStream(File source, File dest) throws
IOException {
try (InputStream is = new FileInputStream(source);
OutputStream os = new FileOutputStream(dest);){
byte[] buffer = new byte[1024];
int length;
while ((length = is.read(buffer)) &gt; 0) {
os.write(buffer, 0, length);
}
}
}
```
或者利用java.nio类库提供的transferTo或transferFrom方法实现。
```
public static void copyFileByChannel(File source, File dest) throws
IOException {
try (FileChannel sourceChannel = new FileInputStream(source)
.getChannel();
FileChannel targetChannel = new FileOutputStream(dest).getChannel
();){
for (long count = sourceChannel.size() ;count&gt;0 ;) {
long transferred = sourceChannel.transferTo(
sourceChannel.position(), count, targetChannel); sourceChannel.position(sourceChannel.position() + transferred);
count -= transferred;
}
}
}
```
当然Java标准类库本身已经提供了几种Files.copy的实现。
对于Copy的效率这个其实与操作系统和配置等情况相关总体上来说NIO transferTo/From的方式**可能更快**,因为它更能利用现代操作系统底层机制,避免不必要拷贝和上下文切换。
## 考点分析
今天这个问题从面试的角度来看确实是一个面试考察的点针对我上面的典型回答面试官还可能会从实践角度或者IO底层实现机制等方面进一步提问。这一讲的内容从面试题出发主要还是为了让你进一步加深对Java IO类库设计和实现的了解。
从实践角度我前面并没有明确说NIO transfer的方案一定最快真实情况也确实未必如此。我们可以根据理论分析给出可行的推断保持合理的怀疑给出验证结论的思路有时候面试官考察的就是如何将猜测变成可验证的结论思考方式远比记住结论重要。
从技术角度展开,下面这些方面值得注意:
<li>
不同的copy方式底层机制有什么区别
</li>
<li>
为什么零拷贝zero-copy可能有性能优势
</li>
<li>
Buffer分类与使用。
</li>
<li>
Direct Buffer对垃圾收集等方面的影响与实践选择。
</li>
接下来,我们一起来分析一下吧。
## 知识扩展
1.拷贝实现机制分析
先来理解一下,前面实现的不同拷贝方法,本质上有什么明显的区别。
首先你需要理解用户态空间User Space和内核态空间Kernel Space这是操作系统层面的基本概念操作系统内核、硬件驱动等运行在内核态空间具有相对高的特权而用户态空间则是给普通应用和服务使用。你可以参考[https://en.wikipedia.org/wiki/User_space](https://en.wikipedia.org/wiki/User_space)。
当我们使用输入输出流进行读写时,实际上是进行了多次上下文切换,比如应用读取数据时,先在内核态将数据从磁盘读取到内核缓存,再切换到用户态将数据从内核缓存读取到用户缓存。
写入操作也是类似,仅仅是步骤相反,你可以参考下面这张图。
<img src="https://static001.geekbang.org/resource/image/6d/85/6d2368424431f1b0d2b935386324b585.png" alt="" />
所以这种方式会带来一定的额外开销可能会降低IO效率。
而基于NIO transferTo的实现方式在Linux和Unix上则会使用到零拷贝技术数据传输并不需要用户态参与省去了上下文切换的开销和不必要的内存拷贝进而可能提高应用拷贝性能。注意transferTo不仅仅是可以用在文件拷贝中与其类似的例如读取磁盘文件然后进行Socket发送同样可以享受这种机制带来的性能和扩展性提高。
transferTo的传输过程是
<img src="https://static001.geekbang.org/resource/image/b0/ea/b0c8226992bb97adda5ad84fe25372ea.png" alt="" />
2.Java IO/NIO源码结构
前面我在典型回答中提了第三种方式即Java标准库也提供了文件拷贝方法java.nio.file.Files.copy。如果你这样回答就一定要小心了因为很少有问题的答案是仅仅调用某个方法。从面试的角度面试官往往会追问既然你提到了标准库那么它是怎么实现的呢有的公司面试官以喜欢追问而出名直到追问到你说不知道。
其实这个问题的答案还真不是那么直观因为实际上有几个不同的copy方法。
```
public static Path copy(Path source, Path target, CopyOption... options)
throws IOException
```
```
public static long copy(InputStream in, Path target, CopyOption... options)
throws IOException
```
```
public static long copy(Path source, OutputStream out)
throws IOException
```
可以看到copy不仅仅是支持文件之间操作没有人限定输入输出流一定是针对文件的这是两个很实用的工具方法。
后面两种copy实现能够在方法实现里直接看到使用的是InputStream.transferTo()你可以直接看源码其内部实现其实是stream在用户态的读写而对于第一种方法的分析过程要相对麻烦一些可以参考下面片段。简单起见我只分析同类型文件系统拷贝过程。
```
public static Path copy(Path source, Path target, CopyOption... options)
throws IOException
{
FileSystemProvider provider = provider(source);
if (provider(target) == provider) {
// same provider
provider.copy(source, target, options);//这是本文分析的路径
} else {
// different providers
CopyMoveHelper.copyToForeignTarget(source, target, options);
}
return target;
}
```
我把源码分析过程简单记录如下JDK的源代码中内部实现和公共API定义也不是可以能够简单关联上的NIO部分代码甚至是定义为模板而不是Java源文件在build过程自动生成源码下面顺便介绍一下部分JDK代码机制和如何绕过隐藏障碍。
<li>
首先直接跟踪发现FileSystemProvider只是个抽象类阅读它的[源码](http://hg.openjdk.java.net/jdk/jdk/file/f84ae8aa5d88/src/java.base/share/classes/java/nio/file/spi/FileSystemProvider.java)能够理解到原来文件系统实际逻辑存在于JDK内部实现里公共API其实是通过ServiceLoader机制加载一系列文件系统实现然后提供服务。
</li>
<li>
我们可以在JDK源码里搜索FileSystemProvider和nio可以定位到[sun/nio/fs](http://hg.openjdk.java.net/jdk/jdk/file/f84ae8aa5d88/src/java.base/share/classes/sun/nio/fs)我们知道NIO底层是和操作系统紧密相关的所以每个平台都有自己的部分特有文件系统逻辑。
</li>
<img src="https://static001.geekbang.org/resource/image/5e/f7/5e0bf3130dffa8e56f398f0856eb76f7.png" alt="" />
<li>
省略掉一些细节最后我们一步步定位到UnixFileSystemProvider → UnixCopyFile.Transfer发现这是个本地方法。
</li>
<li>
最后,明确定位到[UnixCopyFile.c](http://hg.openjdk.java.net/jdk/jdk/file/f84ae8aa5d88/src/java.base/unix/native/libnio/fs/UnixCopyFile.c),其内部实现清楚说明竟然只是简单的用户态空间拷贝!
</li>
所以我们明确这个最常见的copy方法其实不是利用transferTo而是本地技术实现的用户态拷贝。
前面谈了不少机制和源码我简单从实践角度总结一下如何提高类似拷贝等IO操作的性能有一些宽泛的原则
<li>
在程序中使用缓存等机制合理减少IO次数在网络通信中如TCP传输window大小也可以看作是类似思路
</li>
<li>
使用transferTo等机制减少上下文切换和额外IO操作。
</li>
<li>
尽量减少不必要的转换过程,比如编解码;对象序列化和反序列化,比如操作文本文件或者网络通信,如果不是过程中需要使用文本信息,可以考虑不要将二进制信息转换成字符串,直接传输二进制信息。
</li>
3.掌握NIO Buffer
我在上一讲提到Buffer是NIO操作数据的基本工具Java为每种原始数据类型都提供了相应的Buffer实现布尔除外所以掌握和使用Buffer是十分必要的尤其是涉及Direct Buffer等使用因为其在垃圾收集等方面的特殊性更要重点掌握。
<img src="https://static001.geekbang.org/resource/image/52/6e/5220029e92bc21e99920937a8210276e.png" alt="" />
Buffer有几个基本属性
<li>
capacity它反映这个Buffer到底有多大也就是数组的长度。
</li>
<li>
position要操作的数据起始位置。
</li>
<li>
limit相当于操作的限额。在读取或者写入时limit的意义很明显是不一样的。比如读取操作时很可能将limit设置到所容纳数据的上限而在写入时则会设置容量或容量以下的可写限度。
</li>
<li>
mark记录上一次postion的位置默认是0算是一个便利性的考虑往往不是必须的。
</li>
前面三个是我们日常使用最频繁的我简单梳理下Buffer的基本操作
<li>
我们创建了一个ByteBuffer准备放入数据capacity当然就是缓冲区大小而position就是0limit默认就是capacity的大小。
</li>
<li>
当我们写入几个字节的数据时position就会跟着水涨船高但是它不可能超过limit的大小。
</li>
<li>
如果我们想把前面写入的数据读出来需要调用flip方法将position设置为0limit设置为以前的position那里。
</li>
<li>
如果还想从头再读一遍可以调用rewind让limit不变position再次设置为0。
</li>
更进一步的详细使用,我建议参考相关[教程](http://tutorials.jenkov.com/java-nio/buffers.html)。
4.Direct Buffer和垃圾收集
我这里重点介绍两种特别的Buffer。
<li>
Direct Buffer如果我们看Buffer的方法定义你会发现它定义了isDirect()方法返回当前Buffer是否是Direct类型。这是因为Java提供了堆内和堆外DirectBuffer我们可以以它的allocate或者allocateDirect方法直接创建。
</li>
<li>
MappedByteBuffer它将文件按照指定大小直接映射为内存区域当程序访问这个内存区域时将直接操作这块儿文件数据省去了将数据从内核空间向用户空间传输的损耗。我们可以使用[FileChannel.map](https://docs.oracle.com/javase/9/docs/api/java/nio/channels/FileChannel.html#map-java.nio.channels.FileChannel.MapMode-long-long-)创建MappedByteBuffer它本质上也是种Direct Buffer。
</li>
在实际使用中Java会尽量对Direct Buffer仅做本地IO操作对于很多大数据量的IO密集操作可能会带来非常大的性能优势因为
<li>
Direct Buffer生命周期内内存地址都不会再发生更改进而内核可以安全地对其进行访问很多IO操作会很高效。
</li>
<li>
减少了堆内对象存储的可能额外维护工作,所以访问效率可能有所提高。
</li>
但是请注意Direct Buffer创建和销毁过程中都会比一般的堆内Buffer增加部分开销所以通常都建议用于长期使用、数据较大的场景。
使用Direct Buffer我们需要清楚它对内存和JVM参数的影响。首先因为它不在堆上所以Xmx之类参数其实并不能影响Direct Buffer等堆外成员所使用的内存额度我们可以使用下面参数设置大小
```
-XX:MaxDirectMemorySize=512M
```
从参数设置和内存问题排查角度来看这意味着我们在计算Java可以使用的内存大小的时候不能只考虑堆的需要还有Direct Buffer等一系列堆外因素。如果出现内存不足堆外内存占用也是一种可能性。
另外大多数垃圾收集过程中都不会主动收集Direct Buffer它的垃圾收集过程就是基于我在专栏前面所介绍的Cleaner一个内部实现和幻象引用PhantomReference机制其本身不是public类型内部实现了一个Deallocator负责销毁的逻辑。对它的销毁往往要拖到full GC的时候所以使用不当很容易导致OutOfMemoryError。
对于Direct Buffer的回收我有几个建议
<li>
在应用程序中显式地调用System.gc()来强制触发。
</li>
<li>
另外一种思路是在大量使用Direct Buffer的部分框架中框架会自己在程序中调用释放方法Netty就是这么做的有兴趣可以参考其实现PlatformDependent0
</li>
<li>
重复使用Direct Buffer。
</li>
5.跟踪和诊断Direct Buffer内存占用
因为通常的垃圾收集日志等记录并不包含Direct Buffer等信息所以Direct Buffer内存诊断也是个比较头疼的事情。幸好在JDK 8之后的版本我们可以方便地使用Native Memory TrackingNMT特性来进行诊断你可以在程序启动时加上下面参数
```
-XX:NativeMemoryTracking={summary|detail}
```
注意激活NMT通常都会导致JVM出现5%~10%的性能下降,请谨慎考虑。
运行时,可以采用下面命令进行交互式对比:
```
// 打印NMT信息
jcmd &lt;pid&gt; VM.native_memory detail
// 进行baseline以对比分配内存变化
jcmd &lt;pid&gt; VM.native_memory baseline
// 进行baseline以对比分配内存变化
jcmd &lt;pid&gt; VM.native_memory detail.diff
```
我们可以在Internal部分发现Direct Buffer内存使用的信息这是因为其底层实际是利用unsafe_allocatememory。严格说这不是JVM内部使用的内存所以在JDK 11以后其实它是归类在other部分里。
JDK 9的输出片段如下“+”表示的就是diff命令发现的分配变化
```
-Internal (reserved=679KB +4KB, committed=679KB +4KB)
(malloc=615KB +4KB #1571 +4)
(mmap: reserved=64KB, committed=64KB)
```
**注意**JVM的堆外内存远不止Direct BufferNMT输出的信息当然也远不止这些我在专栏后面有综合分析更加具体的内存结构的主题。
今天我分析了Java IO/NIO底层文件操作数据的机制以及如何实现零拷贝的高性能操作梳理了Buffer的使用和类型并针对Direct Buffer的生命周期管理和诊断进行了较详细的分析。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗你可以思考下如果我们需要在channel读取的过程中将不同片段写入到相应的Buffer里面类似二进制消息分拆成消息头、消息体等可以采用NIO的什么机制做到呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,210 @@
<audio id="audio" title="第13讲 | 谈谈接口和抽象类有什么区别?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d9/16/d9b1854e0b32714aa0e8c3e4bd442416.mp3"></audio>
Java是非常典型的面向对象语言曾经有一段时间程序员整天把面向对象、设计模式挂在嘴边。虽然如今大家对这方面已经不再那么狂热但是不可否认掌握面向对象设计原则和技巧是保证高质量代码的基础之一。
面向对象提供的基本机制,对于提高开发、沟通等各方面效率至关重要。考察面向对象也是面试中的常见一环,下面我来聊聊**面向对象设计基础**。
今天我要问你的问题是,谈谈接口和抽象类有什么区别?
## 典型回答
接口和抽象类是Java面向对象设计的两个基础机制。
接口是对行为的抽象它是抽象方法的集合利用接口可以达到API定义和实现分离的目的。接口不能实例化不能包含任何非常量成员任何field都是隐含着public static final的意义同时没有非静态方法实现也就是说要么是抽象方法要么是静态方法。Java标准类库中定义了非常多的接口比如java.util.List。
抽象类是不能实例化的类用abstract关键字修饰class其目的主要是代码重用。除了不能实例化形式上和一般的Java类并没有太大区别可以有一个或者多个抽象方法也可以没有抽象方法。抽象类大多用于抽取相关Java类的共用方法实现或者是共同成员变量然后通过继承的方式达到代码复用的目的。Java标准库中比如collection框架很多通用部分就被抽取成为抽象类例如java.util.AbstractList。
Java类实现interface使用implements关键词继承abstract class则是使用extends关键词我们可以参考Java标准库中的ArrayList。
```
public class ArrayList&lt;E&gt; extends AbstractList&lt;E&gt;
implements List&lt;E&gt;, RandomAccess, Cloneable, java.io.Serializable
{
//...
}
```
## 考点分析
这是个非常高频的Java面向对象基础问题看起来非常简单的问题如果面试官稍微深入一些你会发现很多有意思的地方可以从不同角度全面地考察你对基本机制的理解和掌握。比如:
<li>
对于Java的基本元素的语法是否理解准确。能否定义出语法基本正确的接口、抽象类或者相关继承实现涉及重载Overload、重写Override更是有各种不同的题目。
</li>
<li>
在软件设计开发中妥善地使用接口和抽象类。你至少知道典型应用场景掌握基础类库重要接口的使用掌握设计方法能够在review代码的时候看出明显的不利于未来维护的设计。
</li>
<li>
掌握Java语言特性演进。现在非常多的框架已经是基于Java 8并逐渐支持更新版本掌握相关语法理解设计目的是很有必要的。
</li>
## 知识扩展
我会从接口、抽象类的一些实践,以及语言变化方面去阐述一些扩展知识点。
Java相比于其他面向对象语言如C++,设计上有一些基本区别,比如**Java不支持多继承**。这种限制在规范了代码实现的同时也产生了一些局限性影响着程序设计结构。Java类可以实现多个接口因为接口是抽象方法的集合所以这是声明性的但不能通过扩展多个抽象类来重用逻辑。
在一些情况下存在特定场景需要抽象出与具体实现、实例化无关的通用逻辑或者纯调用关系的逻辑但是使用传统的抽象类会陷入到单继承的窘境。以往常见的做法是实现由静态方法组成的工具类Utils比如java.util.Collections。
设想,为接口添加任何抽象方法,相应的所有实现了这个接口的类,也必须实现新增方法,否则会出现编译错误。对于抽象类,如果我们添加非抽象方法,其子类只会享受到能力扩展,而不用担心编译出问题。
接口的职责也不仅仅限于抽象方法的集合其实有各种不同的实践。有一类没有任何方法的接口通常叫作Marker Interface顾名思义它的目的就是为了声明某些东西比如我们熟知的Cloneable、Serializable等。这种用法也存在于业界其他的Java产品代码中。
从表面看这似乎和Annotation异曲同工也确实如此它的好处是简单直接。对于Annotation因为可以指定参数和值在表达能力上要更强大一些所以更多人选择使用Annotation。
Java 8增加了函数式编程的支持所以又增加了一类定义即所谓functional interface简单说就是只有一个抽象方法的接口通常建议使用@FunctionalInterface Annotation来标记。Lambda表达式本身可以看作是一类functional interface某种程度上这和面向对象可以算是两码事。我们熟知的Runnable、Callable之类都是functional interface这里不再多介绍了有兴趣你可以参考[https://www.oreilly.com/learning/java-8-functional-interfaces](https://www.oreilly.com/learning/java-8-functional-interfaces) 。
还有一点可能让人感到意外,严格说,**Java 8以后接口也是可以有方法实现的**
从Java 8开始interface增加了对default method的支持。Java 9以后甚至可以定义private default method。Default method提供了一种二进制兼容的扩展已有接口的办法。比如我们熟知的java.util.Collection它是collection体系的root interface在Java 8中添加了一系列default method主要是增加Lambda、Stream相关的功能。我在专栏前面提到的类似Collections之类的工具类很多方法都适合作为default method实现在基础接口里面。
你可以参考下面代码片段:
```
public interface Collection&lt;E&gt; extends Iterable&lt;E&gt; {
/**
* Returns a sequential Stream with this collection as its source
* ...
**/
default Stream&lt;E&gt; stream() {
return StreamSupport.stream(spliterator(), false);
}
}
```
**面向对象设计**
谈到面向对象,很多人就会想起设计模式,那些是非常经典的问题和设计方法的总结。我今天来夯实一下基础,先来聊聊面向对象设计的基本方面。
我们一定要清楚面向对象的基本要素:封装、继承、多态。
**封装**的目的是隐藏事务内部的实现细节以便提高安全性和简化编程。封装提供了合理的边界避免外部调用者接触到内部的细节。我们在日常开发中因为无意间暴露了细节导致的难缠bug太多了比如在多线程环境暴露内部状态导致的并发修改问题。从另外一个角度看封装这种隐藏也提供了简化的界面避免太多无意义的细节浪费调用者的精力。
**继承**是代码复用的基础机制,类似于我们对于马、白马、黑马的归纳总结。但要注意,继承可以看作是非常紧耦合的一种关系,父类代码修改,子类行为也会变动。在实践中,过度滥用继承,可能会起到反效果。
**多态**你可能立即会想到重写override和重载overload、向上转型。简单说重写是父子类中相同名字和参数的方法不同的实现重载则是相同名字的方法但是不同的参数本质上这些方法签名是不一样的为了更好说明请参考下面的样例代码
```
public int doSomething() {
return 0;
}
// 输入参数不同,意味着方法签名不同,重载的体现
public int doSomething(List&lt;String&gt; strs) {
return 0;
}
// return类型不一样编译不能通过
public short doSomething() {
return 0;
}
```
这里你可以思考一个小问题方法名称和参数一致但是返回值不同这种情况在Java代码中算是有效的重载吗 答案是不是的,编译都会出错的。
进行面向对象编程掌握基本的设计原则是必须的我今天介绍最通用的部分也就是所谓的S.O.L.I.D原则。
<li>
单一职责Single Responsibility类或者对象最好是只有单一职责在程序设计中如果发现某个类承担着多种义务可以考虑进行拆分。
</li>
<li>
开关原则Open-Close, Open for extension, close for modification设计要对扩展开放对修改关闭。换句话说程序设计应保证平滑的扩展性尽量避免因为新增同类功能而修改已有实现这样可以少产出些回归regression问题。
</li>
<li>
里氏替换Liskov Substitution这是面向对象的基本要素之一进行继承关系抽象时凡是可以用父类或者基类的地方都可以用子类替换。
</li>
<li>
<p>接口分离Interface Segregation我们在进行类和接口设计时如果在一个接口里定义了太多方法其子类很可能面临两难就是只有部分方法对它是有意义的这就破坏了程序的内聚性。<br />
对于这种情况,可以通过拆分成功能单一的多个接口,将行为进行解耦。在未来维护中,如果某个接口设计有变,不会对使用其他接口的子类构成影响。</p>
</li>
<li>
依赖反转Dependency Inversion实体应该依赖于抽象而不是实现。也就是说高层次模块不应该依赖于低层次模块而是应该基于抽象。实践这一原则是保证产品代码之间适当耦合度的法宝。
</li>
**OOP原则实践中的取舍**
值得注意的是现代语言的发展很多时候并不是完全遵守前面的原则的比如Java 10中引入了本地方法类型推断和var类型。按照里氏替换原则我们通常这样定义变量
```
List&lt;String&gt; list = new ArrayList&lt;&gt;();
```
如果使用var类型可以简化为
```
var list = new ArrayList&lt;String&gt;();
```
但是list实际会被推断为“ArrayList &lt; String &gt;
```
ArrayList&lt;String&gt; list = new ArrayList&lt;String&gt;();
```
理论上,这种语法上的便利,其实是增强了程序对实现的依赖,但是微小的类型泄漏却带来了书写的便利和代码可读性的提高,所以,实践中我们还是要按照得失利弊进行选择,而不是一味得遵循原则。
**OOP原则在面试题目中的分析**
我在以往面试中发现即使是有多年编程经验的工程师也还没有真正掌握面向对象设计的基本的原则如开关原则Open-Close。看看下面这段代码改编自朋友圈盛传的某伟大公司产品代码你觉得可以利用面向对象设计原则如何改进
```
public class VIPCenter {
void serviceVIP(T extend User user&gt;) {
if (user instanceof SlumDogVIP) {
// 穷X VIP活动抢的那种
// do somthing
} else if(user instanceof RealVIP) {
// do somthing
}
// ...
}
```
这段代码的一个问题是,业务逻辑集中在一起,当出现新的用户类型时,比如,大数据发现了我们是肥羊,需要去收获一下, 这就需要直接去修改服务方法代码实现,这可能会意外影响不相关的某个用户类型逻辑。
利用开关原则,我们可以尝试改造为下面的代码:
```
public class VIPCenter {
private Map&lt;User.TYPE, ServiceProvider&gt; providers;
void serviceVIP(T extend User user {
providers.get(user.getType()).service(user);
}
}
interface ServiceProvider{
void service(T extend User user) ;
}
class SlumDogVIPServiceProvider implements ServiceProvider{
void service(T extend User user){
// do somthing
}
}
class RealVIPServiceProvider implements ServiceProvider{
void service(T extend User user) {
// do something
}
}
```
上面的示例,将不同对象分类的服务方法进行抽象,把业务逻辑的紧耦合关系拆开,实现代码的隔离保证了方便的扩展。
今天我对Java面向对象技术进行了梳理对比了抽象类和接口分析了Java语言在接口层面的演进和相应程序设计实现最后回顾并实践了面向对象设计的基本原则希望对你有所帮助。
## 一课一练
关于接口和抽象类的区别,你做到心中有数了吗?给你布置一个思考题,思考一下自己的产品代码,有没有什么地方违反了基本设计原则?那些一改就崩的代码,是否遵循了开关原则?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,213 @@
<audio id="audio" title="第14讲 | 谈谈你知道的设计模式?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/30/d7/30c7f2be43f2d6dcf1ad7a9f2d4d2bd7.mp3"></audio>
设计模式是人们为软件开发中相同表征的问题,抽象出的可重复利用的解决方案。在某种程度上,设计模式已经代表了一些特定情况的最佳实践,同时也起到了软件工程师之间沟通的“行话”的作用。理解和掌握典型的设计模式,有利于我们提高沟通、设计的效率和质量。
今天我要问你的问题是谈谈你知道的设计模式请手动实现单例模式Spring等框架中使用了哪些模式
## 典型回答
大致按照模式的应用目标分类,设计模式可以分为创建型模式、结构型模式和行为型模式。
<li>
创建型模式是对对象创建过程的各种问题和解决方案的总结包括各种工厂模式Factory、Abstract Factory、单例模式Singleton、构建器模式Builder、原型模式ProtoType
</li>
<li>
结构型模式是针对软件设计结构的总结关注于类、对象继承、组合方式的实践经验。常见的结构型模式包括桥接模式Bridge、适配器模式Adapter、装饰者模式Decorator、代理模式Proxy、组合模式Composite、外观模式Facade、享元模式Flyweight等。
</li>
<li>
行为型模式是从类或对象之间交互、职责划分等角度总结的模式。比较常见的行为型模式有策略模式Strategy、解释器模式Interpreter、命令模式Command、观察者模式Observer、迭代器模式Iterator、模板方法模式Template Method、访问者模式Visitor
</li>
## 考点分析
这个问题主要是考察你对设计模式的了解和掌握程度,更多相关内容你可以参考:[https://en.wikipedia.org/wiki/Design_Patterns。](https://en.wikipedia.org/wiki/Design_Patterns)
我建议可以在回答时适当地举些例子更加清晰地说明典型模式到底是什么样子典型使用场景是怎样的。这里举个Java基础类库中的例子供你参考。
首先,[专栏第11讲](http://time.geekbang.org/column/article/8369)刚介绍过IO框架我们知道InputStream是一个抽象类标准类库中提供了FileInputStream、ByteArrayInputStream等各种不同的子类分别从不同角度对InputStream进行了功能扩展这是典型的装饰器模式应用案例。
识别装饰器模式,可以通过**识别类设计特征**来进行判断,也就是其类构造函数以**相同的**抽象类或者接口为输入参数。
因为装饰器模式本质上是包装同类型实例,我们对目标对象的调用,往往会通过包装类覆盖过的方法,迂回调用被包装的实例,这就可以很自然地实现增加额外逻辑的目的,也就是所谓的“装饰”。
例如BufferedInputStream经过包装为输入流过程增加缓存类似这种装饰器还可以多次嵌套不断地增加不同层次的功能。
```
public BufferedInputStream(InputStream in)
```
我在下面的类图里简单总结了InputStream的装饰模式实践。
<img src="https://static001.geekbang.org/resource/image/77/33/77ad2dc2513da8155a3781e8291fac33.png" alt="" />
接下来再看第二个例子。创建型模式尤其是工厂模式在我们的代码中随处可见我举个相对不同的API设计实践。比如JDK最新版本中 HTTP/2 Client API下面这个创建HttpRequest的过程就是典型的构建器模式Builder通常会被实现成[fluent风格](https://en.wikipedia.org/wiki/Fluent_interface)的API也有人叫它方法链。
```
HttpRequest request = HttpRequest.newBuilder(new URI(uri))
.header(headerAlice, valueAlice)
.headers(headerBob, value1Bob,
headerCarl, valueCarl,
headerBob, value2Bob)
.GET()
.build();
```
使用构建器模式,可以比较优雅地解决构建复杂对象的麻烦,这里的“复杂”是指类似需要输入的参数组合较多,如果用构造函数,我们往往需要为每一种可能的输入参数组合实现相应的构造函数,一系列复杂的构造函数会让代码阅读性和可维护性变得很差。
上面的分析也进一步反映了创建型模式的初衷,即,将对象创建过程单独抽象出来,从结构上把对象使用逻辑和创建逻辑相互独立,隐藏对象实例的细节,进而为使用者实现了更加规范、统一的逻辑。
更进一步进行设计模式考察,面试官可能会:
<li>
希望你写一个典型的设计模式实现。这虽然看似简单,但即使是最简单的单例,也能够综合考察代码基本功。
</li>
<li>
考察典型的设计模式使用,尤其是结合标准库或者主流开源框架,考察你对业界良好实践的掌握程度。
</li>
在面试时如果恰好问到你不熟悉的模式,你可以稍微引导一下,比如介绍你在产品中使用了什么自己相对熟悉的模式,试图解决什么问题,它们的优点和缺点等。
下面,我会针对前面两点,结合代码实例进行分析。
## 知识扩展
我们来实现一个日常非常熟悉的单例设计模式。看起来似乎很简单,那么下面这个样例符合基本需求吗?
```
public class Singleton {
private static Singleton instance = new Singleton();
public static Singleton getInstance() {
return instance;
}
}
```
是不是总感觉缺了点什么原来Java会自动为没有明确声明构造函数的类定义一个public的无参数的构造函数所以上面的例子并不能保证额外的对象不被创建出来别人完全可以直接“new Singleton()”,那我们应该怎么处理呢?
不错可以为单例定义一个private的构造函数也有建议声明为枚举这是有争议的我个人不建议选择相对复杂的枚举毕竟日常开发不是学术研究。这样还有什么改进的余地吗
[专栏第10讲](http://time.geekbang.org/column/article/8137)介绍ConcurrentHashMap时提到过标准类库中很多地方使用懒加载lazy-load改善初始内存开销单例同样适用下面是修正后的改进版本。
```
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
```
这个实现在单线程环境不存在问题,但是如果处于并发场景,就需要考虑线程安全,最熟悉的就莫过于“双检锁”,其要点在于:
<li>
这里的volatile能够提供可见性以及保证getInstance返回的是初始化**完全**的对象。
</li>
<li>
在同步之前进行null检查以尽量避免进入相对昂贵的同步块。
</li>
<li>
直接在class级别进行同步保证线程安全的类方法调用。
</li>
```
public class Singleton {
private static volatile Singleton singleton = null;
private Singleton() {
}
public static Singleton getSingleton() {
    if (singleton == null) { // 尽量避免重复进入同步块
        synchronized (Singleton.class) { // 同步.class意味着对同步类方法调用
            if (singleton == null) {
                singleton = new Singleton();
            }
        }
    }
    return singleton;
}
}
```
在这段代码中争论较多的是volatile修饰静态变量当Singleton类本身有多个成员变量时需要保证初始化过程完成后才能被get到。
在现代Java中内存排序模型JMM已经非常完善通过volatile的write或者read能保证所谓的happen-before也就是避免常被提到的指令重排。换句话说构造对象的store指令能够被保证一定在volatile read之前。
当然,也有一些人推荐利用内部类持有静态对象的方式实现,其理论依据是对象初始化过程中隐含的初始化锁(有兴趣的话你可以参考[jls-12.4.2](https://docs.oracle.com/javase/specs/jls/se7/html/jls-12.html#jls-12.4.2) 中对LC的说明这种和前面的双检锁实现都能保证线程安全不过语法稍显晦涩未必有特别的优势。
```
public class Singleton {
private Singleton(){}
public static Singleton getSingleton(){
    return Holder.singleton;
}
private static class Holder {
    private static Singleton singleton = new Singleton();
}
}
```
所以,可以看出,即使是看似最简单的单例模式,在增加各种高标准需求之后,同样需要非常多的实现考量。
上面是比较学究的考察其实实践中未必需要如此复杂如果我们看Java核心类库自己的单例实现比如[java.lang.Runtime](http://hg.openjdk.java.net/jdk/jdk/file/18fba780c1d1/src/java.base/share/classes/java/lang/Runtime.java),你会发现:
<li>
它并没使用复杂的双检锁之类。
</li>
<li>
静态实例被声明为final这是被通常实践忽略的一定程度保证了实例不被篡改[专栏第6讲](http://time.geekbang.org/column/article/7489)介绍过,反射之类可以绕过私有访问限制),也有有限的保证执行顺序的语义。
</li>
```
private static final Runtime currentRuntime = new Runtime();
private static Version version;
// …
public static Runtime getRuntime() {
return currentRuntime;
}
/** Don't let anyone else instantiate this class */
private Runtime() {}
```
前面说了不少代码实践下面一起来简要看看主流开源框架如Spring等如何在API设计中使用设计模式。你至少要有个大体的印象
<li>
[BeanFactory](https://github.com/spring-projects/spring-framework/blob/master/spring-beans/src/main/java/org/springframework/beans/factory/BeanFactory.java)和[ApplicationContext](https://github.com/spring-projects/spring-framework/blob/master/spring-context/src/main/java/org/springframework/context/ApplicationContext.java)应用了工厂模式。
</li>
<li>
在Bean的创建中Spring也为不同scope定义的对象提供了单例和原型等模式实现。
</li>
<li>
我在[专栏第6讲](http://time.geekbang.org/column/article/7489)介绍的AOP领域则是使用了代理模式、装饰器模式、适配器模式等。
</li>
<li>
各种事件监听器,是观察者模式的典型应用。
</li>
<li>
类似JdbcTemplate等则是应用了模板模式。
</li>
今天我与你回顾了设计模式的分类和主要类型并从Java核心类库、开源框架等不同角度分析了其采用的模式并结合单例的不同实现分析了如何实现符合线程安全等需求的单例希望可以对你的工程实践有所帮助。另外我想最后补充的是设计模式也不是银弹要避免滥用或者过度设计。
## 一课一练
关于设计模式你做到心中有数了吗你可以思考下在业务代码中经常发现大量XXFacade外观模式是解决什么问题适用于什么场景
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,70 @@
<audio id="audio" title="第1讲 | 谈谈你对Java平台的理解" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d5/dd/d57dcc46ca03890d476064c30eabb7dd.mp3"></audio>
从你接触Java开发到现在你对Java最直观的印象是什么呢是它宣传的 “Write once, run anywhere”还是目前看已经有些过于形式主义的语法呢你对于Java平台到底了解到什么程度请你先停下来总结思考一下。
今天我要问你的问题是谈谈你对Java平台的理解“Java是解释执行”这句话正确吗
## 典型回答
Java本身是一种面向对象的语言最显著的特性有两个方面一是所谓的“**书写一次,到处运行**”Write once, run anywhere能够非常容易地获得跨平台能力另外就是**垃圾收集**GC, Garbage CollectionJava通过垃圾收集器Garbage Collector回收分配内存大部分情况下程序员不需要自己操心内存的分配和回收。
我们日常会接触到JREJava Runtime Environment或者JDKJava Development Kit。 JRE也就是Java运行环境包含了JVM和Java类库以及一些模块等。而JDK可以看作是JRE的一个超集提供了更多工具比如编译器、各种诊断工具等。
对于“Java是解释执行”这句话这个说法不太准确。我们开发的Java的源代码首先通过Javac编译成为字节码bytecode然后在运行时通过 Java虚拟机JVM内嵌的解释器将字节码转换成为最终的机器码。但是常见的JVM比如我们大多数情况使用的Oracle JDK提供的Hotspot JVM都提供了JITJust-In-Time编译器也就是通常所说的动态编译器JIT能够在运行时将热点代码编译成机器码这种情况下部分热点代码就属于**编译执行**,而不是解释执行了。
## 考点分析
其实这个问题问得有点笼统。题目本身是非常开放的往往考察的是多个方面比如基础知识理解是否很清楚是否掌握Java平台主要模块和运行原理等。很多面试者会在这种问题上吃亏稍微紧张了一下不知道从何说起就给出个很简略的回答。
对于这类笼统的问题,你需要尽量**表现出自己的思维深入并系统化Java知识理解得也比较全面**,一定要避免让面试官觉得你是个“知其然不知其所以然”的人。毕竟明白基本组成和机制,是日常工作中进行问题诊断或者性能调优等很多事情的基础,相信没有招聘方会不喜欢“热爱学习和思考”的面试者。
即使感觉自己的回答不是非常完善,也不用担心。我个人觉得这种笼统的问题,有时候回答得稍微片面也很正常,大多数有经验的面试官,不会因为一道题就对面试者轻易地下结论。通常会尽量引导面试者,把他的真实水平展现出来,这种问题就是做个开场热身,面试官经常会根据你的回答扩展相关问题。
## 知识扩展
回归正题对于Java平台的理解可以从很多方面简明扼要地谈一下例如Java语言特性包括泛型、Lambda等语言特性基础类库包括集合、IO/NIO、网络、并发、安全等基础类库。对于我们日常工作应用较多的类库面试前可以系统化总结一下有助于临场发挥。
或者谈谈JVM的一些基础概念和机制比如Java的类加载机制常用版本JDK如JDK 8内嵌的Class-Loader例如Bootstrap、 Application和Extension Class-loader类加载大致过程加载、验证、链接、初始化这里参考了周志明的《深入理解Java虚拟机》非常棒的JVM上手书籍自定义Class-Loader等。还有垃圾收集的基本原理最常见的垃圾收集器如SerialGC、Parallel GC、 CMS、 G1等对于适用于什么样的工作负载最好也心里有数。这些都是可以扩展开的领域我会在后面的专栏对此进行更系统的介绍。
当然还有JDK包含哪些工具或者Java领域内其他工具等如编译器、运行时环境、安全工具、诊断和监控工具等。这些基本工具是日常工作效率的保证对于我们工作在其他语言平台上同样有所帮助很多都是触类旁通的。
下图是我总结的一个相对宽泛的蓝图供你参考。
<img src="https://static001.geekbang.org/resource/image/20/32/20bc6a900fc0b829c2f0e723df050732.png" alt="" />
不再扩展了,回到前面问到的解释执行和编译执行的问题。有些面试官喜欢在特定问题上“刨根问底儿”,因为这是进一步了解面试者对知识掌握程度的有效方法,我稍微深入探讨一下。
众所周知我们通常把Java分为编译期和运行时。这里说的Java的编译和C/C++是有着不同的意义的Javac的编译编译Java源码生成“.class”文件里面实际是字节码而不是可以直接执行的机器码。Java通过字节码和Java虚拟机JVM这种跨平台的抽象屏蔽了操作系统和硬件的细节这也是实现“一次编译到处执行”的基础。
在运行时JVM会通过类加载器Class-Loader加载字节码解释或者编译执行。就像我前面提到的主流Java版本中如JDK 8实际是解释和编译混合的一种模式即所谓的混合模式-Xmixed。通常运行在server模式的JVM会进行上万次调用以收集足够的信息进行高效的编译client模式这个门限是1500次。Oracle Hotspot JVM内置了两个不同的JIT compilerC1对应前面说的client模式适用于对于启动速度敏感的应用比如普通Java桌面应用C2对应server模式它的优化是为长时间运行的服务器端应用设计的。默认是采用所谓的分层编译TieredCompilation。这里不再展开更多JIT的细节没必要一下子就钻进去我会在后面介绍分层编译的内容。
Java虚拟机启动时可以指定不同的参数对运行模式进行选择。 比如,指定“-Xint”就是告诉JVM只进行解释执行不对代码进行编译这种模式抛弃了JIT可能带来的性能优势。毕竟解释器interpreter是逐条读入逐条解释运行的。与其相对应的还有一个“-Xcomp”参数这是告诉JVM关闭解释器不要进行解释执行或者叫作最大优化级别。那你可能会问这种模式是不是最高效啊简单说还真未必。“-Xcomp”会导致JVM启动变慢非常多同时有些JIT编译器优化方式比如分支预测如果不进行profiling往往并不能进行有效优化。
除了我们日常最常见的Java使用模式其实还有一种新的编译方式即所谓的AOTAhead-of-Time Compilation直接将字节码编译成机器代码这样就避免了JIT预热等各方面的开销比如Oracle JDK 9就引入了实验性的AOT特性并且增加了新的jaotc工具。利用下面的命令把某个类或者某个模块编译成为AOT库。
```
jaotc --output libHelloWorld.so HelloWorld.class
jaotc --output libjava.base.so --module java.base
```
然后,在启动时直接指定就可以了。
```
java -XX:AOTLibrary=./libHelloWorld.so,./libjava.base.so HelloWorld
```
而且Oracle JDK支持分层编译和AOT协作使用这两者并不是二选一的关系。如果你有兴趣可以参考相关文档[http://openjdk.java.net/jeps/295](http://openjdk.java.net/jeps/295)。AOT也不仅仅是只有这一种方式业界早就有第三方工具如GCJ、Excelsior JET提供相关功能。
另外JVM作为一个强大的平台不仅仅只有Java语言可以运行在JVM上本质上合规的字节码都可以运行Java语言自身也为此提供了便利我们可以看到类似Clojure、Scala、Groovy、JRuby、Jython等大量JVM语言活跃在不同的场景。
今天我简单介绍了一下Java平台相关的一些内容目的是提纲挈领地构建一个整体的印象包括Java语言特性、 核心类库与常用第三方类库、Java虚拟机基本原理和相关工具希望对你有所帮助。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗知道不如做到请你也在留言区写写自己对Java平台的理解。我会选出经过认真思考的留言送给你一份学习鼓励金欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,168 @@
<audio id="audio" title="第2讲 | Exception和Error有什么区别" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cf/2b/cf0ea7ba055564ec975364da7714e02b.mp3"></audio>
世界上存在永远不会出错的程序吗?也许这只会出现在程序员的梦中。随着编程语言和软件的诞生,异常情况就如影随形地纠缠着我们,只有正确处理好意外情况,才能保证程序的可靠性。
Java语言在设计之初就提供了相对完善的异常处理机制这也是Java得以大行其道的原因之一因为这种机制大大降低了编写和维护可靠程序的门槛。如今异常处理机制已经成为现代编程语言的标配。
今天我要问你的问题是请对比Exception和Error另外运行时异常与一般异常有什么区别
## 典型回答
Exception和Error都是继承了Throwable类在Java中只有Throwable类型的实例才可以被抛出throw或者捕获catch它是异常处理机制的基本组成类型。
Exception和Error体现了Java平台设计者对不同异常情况的分类。Exception是程序正常运行中可以预料的意外情况可能并且应该被捕获进行相应处理。
Error是指在正常情况下不大可能出现的情况绝大部分的Error都会导致程序比如JVM自身处于非正常的、不可恢复状态。既然是非正常情况所以不便于也不需要捕获常见的比如OutOfMemoryError之类都是Error的子类。
Exception又分为**可检查**checked异常和**不检查**unchecked异常可检查异常在源代码里必须显式地进行捕获处理这是编译期检查的一部分。前面我介绍的不可查的Error是Throwable不是Exception。
不检查异常就是所谓的运行时异常,类似 NullPointerException、ArrayIndexOutOfBoundsException之类通常是可以编码避免的逻辑错误具体根据需要来判断是否需要捕获并不会在编译期强制要求。
## 考点分析
分析Exception和Error的区别是从概念角度考察了Java处理机制。总的来说还处于理解的层面面试者只要阐述清楚就好了。
我们在日常编程中,如何处理好异常是比较考验功底的,我觉得需要掌握两个方面。
第一,**理解Throwable、Exception、Error的设计和分类**。比如,掌握那些应用最为广泛的子类,以及如何自定义异常等。
很多面试官会进一步追问一些细节比如你了解哪些Error、Exception或者RuntimeException我画了一个简单的类图并列出来典型例子可以给你作为参考至少做到基本心里有数。
<img src="https://static001.geekbang.org/resource/image/ac/00/accba531a365e6ae39614ebfa3273900.png" alt="" />
其中有些子类型最好重点理解一下比如NoClassDefFoundError和ClassNotFoundException有什么区别这也是个经典的入门题目。
第二,**理解Java语言中操作Throwable的元素和实践**。掌握最基本的语法是必须的如try-catch-finally块throw、throws关键字等。与此同时也要懂得如何处理典型场景。
异常处理代码比较繁琐比如我们需要写很多千篇一律的捕获代码或者在finally里面做一些资源回收工作。随着Java语言的发展引入了一些更加便利的特性比如try-with-resources和multiple catch具体可以参考下面的代码段。在编译时期会自动生成相应的处理逻辑比如自动按照约定俗成close那些扩展了AutoCloseable或者Closeable的对象。
```
try (BufferedReader br = new BufferedReader(…);
BufferedWriter writer = new BufferedWriter(…)) {// Try-with-resources
// do something
catch ( IOException | XEception e) {// Multiple catch
// Handle it
}
```
## 知识扩展
前面谈的大多是概念性的东西,下面我来谈些实践中的选择,我会结合一些代码用例进行分析。
先开看第一个吧,下面的代码反映了异常处理中哪些不当之处?
```
try {
// 业务代码
// …
Thread.sleep(1000L);
} catch (Exception e) {
// Ignore it
}
```
这段代码虽然很短,但是已经违反了异常处理的两个基本原则。
第一,**尽量不要捕获类似Exception这样的通用异常而是应该捕获特定异常**在这里是Thread.sleep()抛出的InterruptedException。
这是因为在日常的开发和合作中我们读代码的机会往往超过写代码软件工程是门协作的艺术所以我们有义务让自己的代码能够直观地体现出尽量多的信息而泛泛的Exception之类恰恰隐藏了我们的目的。另外我们也要保证程序不会捕获到我们不希望捕获的异常。比如你可能更希望RuntimeException被扩散出来而不是被捕获。
进一步讲除非深思熟虑了否则不要捕获Throwable或者Error这样很难保证我们能够正确程序处理OutOfMemoryError。
第二,**不要生吞swallow异常**。这是异常处理中要特别注意的事情,因为很可能会导致非常难以诊断的诡异情况。
生吞异常,往往是基于假设这段代码可能不会发生,或者感觉忽略异常是无所谓的,但是千万不要在产品代码做这种假设!
如果我们不把异常抛出来或者也没有输出到日志Logger之类程序可能在后续代码以不可控的方式结束。没人能够轻易判断究竟是哪里抛出了异常以及是什么原因产生了异常。
再来看看第二段代码
```
try {
// 业务代码
// …
} catch (IOException e) {
e.printStackTrace();
}
```
这段代码作为一段实验代码,它是没有任何问题的,但是在产品代码中,通常都不允许这样处理。你先思考一下这是为什么呢?
我们先来看看[printStackTrace()](https://docs.oracle.com/javase/9/docs/api/java/lang/Throwable.html#printStackTrace--)的文档开头就是“Prints this throwable and its backtrace to the **standard error stream**”。问题就在这里在稍微复杂一点的生产系统中标准出错STERR不是个合适的输出选项因为你很难判断出到底输出到哪里去了。
尤其是对于分布式系统如果发生异常但是无法找到堆栈轨迹stacktrace这纯属是为诊断设置障碍。所以最好使用产品日志详细地输出到日志系统里。
我们接下来看下面的代码段,体会一下**Throw early, catch late原则**。
```
public void readPreferences(String fileName){
//...perform operations...
InputStream in = new FileInputStream(fileName);
//...read the preferences file...
}
```
如果fileName是null那么程序就会抛出NullPointerException但是由于没有第一时间暴露出问题堆栈信息可能非常令人费解往往需要相对复杂的定位。这个NPE只是作为例子实际产品代码中可能是各种情况比如获取配置失败之类的。在发现问题的时候第一时间抛出能够更加清晰地反映问题。
我们可以修改一下让问题“throw early”对应的异常信息就非常直观了。
```
public void readPreferences(String filename) {
Objects. requireNonNull(filename);
//...perform other operations...
InputStream in = new FileInputStream(filename);
//...read the preferences file...
}
```
至于“catch late”其实是我们经常苦恼的问题捕获异常后需要怎么处理呢最差的处理方式就是我前面提到的“生吞异常”本质上其实是掩盖问题。如果实在不知道如何处理可以选择保留原有异常的cause信息直接再抛出或者构建新的异常抛出去。在更高层面因为有了清晰的业务逻辑往往会更清楚合适的处理方式是什么。
有的时候,我们会根据需要自定义异常,这个时候除了保证提供足够的信息,还有两点需要考虑:
- 是否需要定义成Checked Exception因为这种类型设计的初衷更是为了从异常情况恢复作为异常设计者我们往往有充足信息进行分类。
- 在保证诊断信息足够的同时也要考虑避免包含敏感信息因为那样可能导致潜在的安全问题。如果我们看Java的标准类库你可能注意到类似java.net.ConnectException出错信息是类似“ Connection refused (Connection refused)”而不包含具体的机器名、IP、端口等一个重要考量就是信息安全。类似的情况在日志中也有比如用户数据一般是不可以输出到日志里面的。
业界有一种争论甚至可以算是某种程度的共识Java语言的Checked Exception也许是个设计错误反对者列举了几点
<li>
Checked Exception的假设是我们捕获了异常然后恢复程序。但是其实我们大多数情况下根本就不可能恢复。Checked Exception的使用已经大大偏离了最初的设计目的。
</li>
<li>
Checked Exception不兼容functional编程如果你写过Lambda/Stream代码相信深有体会。
</li>
很多开源项目已经采纳了这种实践比如Spring、Hibernate等甚至反映在新的编程语言设计中比如Scala等。 如果有兴趣,你可以参考:
[http://literatejava.com/exceptions/checked-exceptions-javas-biggest-mistake/](http://literatejava.com/exceptions/checked-exceptions-javas-biggest-mistake/)。
当然很多人也觉得没有必要矫枉过正因为确实有一些异常比如和环境相关的IO、网络等其实是存在可恢复性的而且Java已经通过业界的海量实践证明了其构建高质量软件的能力。我就不再进一步解读了感兴趣的同学可以点击**[链接](http://v.qq.com/x/page/d0635rf5x0o.html)**观看Bruce Eckel在2018年全球软件开发大会QCon的分享Failing at Failing: How and Why Weve Been Nonchalantly Moving Away From Exception Handling。
我们从性能角度来审视一下Java的异常处理机制这里有两个可能会相对昂贵的地方
<li>
try-catch代码段会产生额外的性能开销或者换个角度说它往往会影响JVM对代码进行优化所以建议仅捕获有必要的代码段尽量不要一个大的try包住整段的代码与此同时利用异常控制代码流程也不是一个好主意远比我们通常意义上的条件语句if/else、switch要低效。
</li>
<li>
Java每实例化一个Exception都会对当时的栈进行快照这是一个相对比较重的操作。如果发生的非常频繁这个开销可就不能被忽略了。
</li>
所以对于部分追求极致性能的底层类库有种方式是尝试创建不进行栈快照的Exception。这本身也存在争议因为这样做的假设在于我创建异常时知道未来是否需要堆栈。问题是实际上可能吗小范围或许可能但是在大规模项目中这么做可能不是个理智的选择。如果需要堆栈但又没有收集这些信息在复杂情况下尤其是类似微服务这种分布式系统这会大大增加诊断的难度。
当我们的服务出现反应变慢、吞吐量下降的时候检查发生最频繁的Exception也是一种思路。关于诊断后台变慢的问题我会在后面的Java性能基础模块中系统探讨。
今天我从一个常见的异常处理概念问题简单总结了Java异常处理的机制。并结合代码分析了一些普遍认可的最佳实践以及业界最新的一些异常使用共识。最后我分析了异常性能开销希望对你有所帮助。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗可以思考一个问题对于异常处理编程不同的编程范式也会影响到异常处理策略比如现在非常火热的反应式编程Reactive Stream因为其本身是异步、基于事件机制的所以出现异常情况决不能简单抛出去另外由于代码堆栈不再是同步调用那种垂直的结构这里的异常处理和日志需要更加小心我们看到的往往是特定executor的堆栈而不是业务方法调用关系。对于这种情况你有什么好的办法吗
请你在留言区分享一下你的解决方案,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,178 @@
<audio id="audio" title="第3讲 | 谈谈final、finally、 finalize有什么不同" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fd/b2/fdc58ecbae845a0dcf6045fc7d4132b2.mp3"></audio>
Java语言有很多看起来很相似但是用途却完全不同的语言要素这些内容往往容易成为面试官考察你知识掌握程度的切入点。
今天我要问你的是一个经典的Java基础题目谈谈final、finally、 finalize有什么不同
## 典型回答
final可以用来修饰类、方法、变量分别有不同的意义final修饰的class代表不可以继承扩展final的变量是不可以修改的而final的方法也是不可以重写的override
finally则是Java保证重点代码一定要被执行的一种机制。我们可以使用try-finally或者try-catch-finally来进行类似关闭JDBC连接、保证unlock锁等动作。
finalize是基础类java.lang.Object的一个方法它的设计目的是保证对象在被垃圾收集前完成特定资源的回收。finalize机制现在已经不推荐使用并且在JDK 9开始被标记为deprecated。
## 考点分析
这是一个非常经典的Java基础问题我上面的回答主要是从语法和使用实践角度出发的其实还有很多方面可以深入探讨面试官还可以考察你对性能、并发、对象生命周期或垃圾收集基本过程等方面的理解。
推荐使用final关键字来明确表示我们代码的语义、逻辑意图这已经被证明在很多场景下是非常好的实践比如
- 我们可以将方法或者类声明为final这样就可以明确告知别人这些行为是不许修改的。
如果你关注过Java核心类库的定义或源码 有没有发现java.lang包下面的很多类相当一部分都被声明成为final class在第三方类库的一些基础类中同样如此这可以有效避免API使用者更改基础功能某种程度上这是保证平台安全的必要手段。
<li>
使用final修饰参数或者变量也可以清楚地避免意外赋值导致的编程错误甚至有人明确推荐将所有方法参数、本地变量、成员变量声明成final。
</li>
<li>
final变量产生了某种程度的不可变immutable的效果所以可以用于保护只读数据尤其是在并发编程中因为明确地不能再赋值final变量有利于减少额外的同步开销也可以省去一些防御性拷贝的必要。
</li>
final也许会有性能的好处很多文章或者书籍中都介绍了可在特定场景提高性能比如利用final可能有助于JVM将方法进行内联可以改善编译器进行条件编译的能力等等。坦白说很多类似的结论都是基于假设得出的比如现代高性能JVM如HotSpot判断内联未必依赖final的提示要相信JVM还是非常智能的。类似的final字段对性能的影响大部分情况下并没有考虑的必要。
从开发实践的角度我不想过度强调这一点这是和JVM的实现很相关的未经验证比较难以把握。我的建议是在日常开发中除非有特别考虑不然最好不要指望这种小技巧带来的所谓性能好处程序最好是体现它的语义目的。如果你确实对这方面有兴趣可以查阅相关资料我就不再赘述了不过千万别忘了验证一下。
对于finally明确知道怎么使用就足够了。需要关闭的连接等资源更推荐使用Java 7中添加的try-with-resources语句因为通常Java平台能够更好地处理异常情况编码量也要少很多何乐而不为呢。
另外我注意到有一些常被考到的finally问题也比较偏门至少需要了解一下。比如下面代码会输出什么
```
try {
// do something
System.exit(1);
} finally{
System.out.println(“Print from finally”);
}
```
上面finally里面的代码可不会被执行的哦这是一个特例。
对于finalize我们要明确它是不推荐使用的业界实践一再证明它不是个好的办法在Java 9中甚至明确将Object.finalize()标记为deprecated如果没有特别的原因不要实现finalize方法也不要指望利用它来进行资源回收。
为什么呢简单说你无法保证finalize什么时候执行执行的是否符合预期。使用不当会影响性能导致程序死锁、挂起等。
通常来说利用上面的提到的try-with-resources或者try-finally机制是非常好的回收资源的办法。如果确实需要额外处理可以考虑Java提供的Cleaner机制或者其他替代方法。接下来我来介绍更多设计考虑和实践细节。
## 知识扩展
1.注意final不是immutable
我在前面介绍了final在实践中的益处需要注意的是**final并不等同于immutable**,比如下面这段代码:
```
final List&lt;String&gt; strList = new ArrayList&lt;&gt;();
strList.add(&quot;Hello&quot;);
strList.add(&quot;world&quot;);
List&lt;String&gt; unmodifiableStrList = List.of(&quot;hello&quot;, &quot;world&quot;);
unmodifiableStrList.add(&quot;again&quot;);
```
final只能约束strList这个引用不可以被赋值但是strList对象行为不被final影响添加元素等操作是完全正常的。如果我们真的希望对象本身是不可变的那么需要相应的类支持不可变的行为。在上面这个例子中[List.of方法](http://openjdk.java.net/jeps/269)创建的本身就是不可变List最后那句add是会在运行时抛出异常的。
Immutable在很多场景是非常棒的选择某种意义上说Java语言目前并没有原生的不可变支持如果要实现immutable的类我们需要做到
<li>
将class自身声明为final这样别人就不能扩展来绕过限制了。
</li>
<li>
将所有成员变量定义为private和final并且不要实现setter方法。
</li>
<li>
通常构造对象时,成员变量使用深度拷贝来初始化,而不是直接赋值,这是一种防御措施,因为你无法确定输入对象不被其他人修改。
</li>
<li>
如果确实需要实现getter方法或者其他可能会返回内部状态的方法使用copy-on-write原则创建私有的copy。
</li>
这些原则是不是在并发编程实践中经常被提到?的确如此。
关于setter/getter方法很多人喜欢直接用IDE一次全部生成建议最好是你确定有需要时再实现。
2.finalize真的那么不堪
前面简单介绍了finalize是一种已经被业界证明了的非常不好的实践那么为什么会导致那些问题呢
finalize的执行是和垃圾收集关联在一起的一旦实现了非空的finalize方法就会导致相应对象回收呈现数量级上的变慢有人专门做过benchmark大概是40~50倍的下降。
因为finalize被设计成在对象**被垃圾收集前**调用这就意味着实现了finalize方法的对象是个“特殊公民”JVM要对它进行额外处理。finalize本质上成为了快速回收的阻碍者可能导致你的对象经过多个垃圾收集周期才能被回收。
有人也许会问我用System.runFinalization()告诉JVM积极一点是不是就可以了也许有点用但是问题在于这还是不可预测、不能保证的所以本质上还是不能指望。实践中因为finalize拖慢垃圾收集导致大量对象堆积也是一种典型的导致OOM的原因。
从另一个角度我们要确保回收资源就是因为资源都是有限的垃圾收集时间的不可预测可能会极大加剧资源占用。这意味着对于消耗非常高频的资源千万不要指望finalize去承担资源释放的主要职责最多让finalize作为最后的“守门员”况且它已经暴露了如此多的问题。这也是为什么我推荐**资源用完即显式释放,或者利用资源池来尽量重用**。
finalize还会掩盖资源回收时的出错信息我们看下面一段JDK的源代码截取自java.lang.ref.Finalizer
```
private void runFinalizer(JavaLangAccess jla) {
// ... 省略部分代码
try {
Object finalizee = this.get();
if (finalizee != null &amp;&amp; !(finalizee instanceof java.lang.Enum)) {
jla.invokeFinalize(finalizee);
// Clear stack slot containing this variable, to decrease
// the chances of false retention with a conservative GC
finalizee = null;
}
} catch (Throwable x) { }
super.clear();
}
```
结合我上期专栏介绍的异常处理实践,你认为这段代码会导致什么问题?
是的,你没有看错,这里的**Throwable是被生吞了的**也就意味着一旦出现异常或者出错你得不到任何有效信息。况且Java在finalize阶段也没有好的方式处理任何信息不然更加不可预测。
3.有什么机制可以替换finalize吗
Java平台目前在逐步使用java.lang.ref.Cleaner来替换掉原有的finalize实现。Cleaner的实现利用了幻象引用PhantomReference这是一种常见的所谓post-mortem清理机制。我会在后面的专栏系统介绍Java的各种引用利用幻象引用和引用队列我们可以保证对象被彻底销毁前做一些类似资源回收的工作比如关闭文件描述符操作系统有限的资源它比finalize更加轻量、更加可靠。
吸取了finalize里的教训每个Cleaner的操作都是独立的它有自己的运行线程所以可以避免意外死锁等问题。
实践中我们可以为自己的模块构建一个Cleaner然后实现相应的清理逻辑。下面是JDK自身提供的样例程序
```
public class CleaningExample implements AutoCloseable {
// A cleaner, preferably one shared within a library
private static final Cleaner cleaner = &lt;cleaner&gt;;
static class State implements Runnable {
State(...) {
// initialize State needed for cleaning action
}
public void run() {
// cleanup action accessing State, executed at most once
}
}
private final State;
private final Cleaner.Cleanable cleanable
public CleaningExample() {
this.state = new State(...);
this.cleanable = cleaner.register(this, state);
}
public void close() {
cleanable.clean();
}
}
```
注意从可预测性的角度来判断Cleaner或者幻象引用改善的程度仍然是有限的如果由于种种原因导致幻象引用堆积同样会出现问题。所以Cleaner适合作为一种最后的保证手段而不是完全依赖Cleaner进行资源回收不然我们就要再做一遍finalize的噩梦了。
我也注意到很多第三方库自己直接利用幻象引用定制资源收集比如广泛使用的MySQL JDBC driver之一的mysql-connector-j就利用了幻象引用机制。幻象引用也可以进行类似链条式依赖关系的动作比如进行总量控制的场景保证只有连接被关闭相应资源被回收连接池才能创建新的连接。
另外这种代码如果稍有不慎添加了对资源的强引用关系就会导致循环引用关系前面提到的MySQL JDBC就在特定模式下有这种问题导致内存泄漏。上面的示例代码中将State定义为static就是为了避免普通的内部类隐含着对外部对象的强引用因为那样会使外部对象无法进入幻象可达的状态。
今天我从语法角度分析了final、finally、finalize并从安全、性能、垃圾收集等方面逐步深入探讨了实践中的注意事项希望对你有所帮助。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗也许你已经注意到了JDK自身使用的Cleaner机制仍然是有缺陷的你有什么更好的建议吗
请你在留言区写写你的建议,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,181 @@
<audio id="audio" title="第4讲 | 强引用、软引用、弱引用、幻象引用有什么区别?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/38/67/3842eb094a8cf102f2ad7765b69a1e67.mp3"></audio>
在Java语言中除了原始数据类型的变量其他所有都是所谓的引用类型指向各种不同的对象理解引用对于掌握Java对象生命周期和JVM内部相关机制非常有帮助。
今天我要问你的问题是,强引用、软引用、弱引用、幻象引用有什么区别?具体使用场景是什么?
## 典型回答
不同的引用类型,主要体现的是**对象不同的可达性reachable状态和对垃圾收集的影响**。
所谓强引用“Strong” Reference就是我们最常见的普通对象引用只要还有强引用指向一个对象就能表明对象还“活着”垃圾收集器不会碰这种对象。对于一个普通的对象如果没有其他的引用关系只要超过了引用的作用域或者显式地将相应引用赋值为null就是可以被垃圾收集的了当然具体回收时机还是要看垃圾收集策略。
软引用SoftReference是一种相对强引用弱化一些的引用可以让对象豁免一些垃圾收集只有当JVM认为内存不足时才会去试图回收软引用指向的对象。JVM会确保在抛出OutOfMemoryError之前清理软引用指向的对象。软引用通常用来实现内存敏感的缓存如果还有空闲内存就可以暂时保留缓存当内存不足时清理掉这样就保证了使用缓存的同时不会耗尽内存。
弱引用WeakReference并不能使对象豁免垃圾收集仅仅是提供一种访问在弱引用状态下对象的途径。这就可以用来构建一种没有特定约束的关系比如维护一种非强制性的映射关系如果试图获取时对象还在就使用它否则重现实例化。它同样是很多缓存实现的选择。
对于幻象引用有时候也翻译成虚引用你不能通过它访问对象。幻象引用仅仅是提供了一种确保对象被finalize以后做某些事情的机制比如通常用来做所谓的Post-Mortem清理机制我在专栏上一讲中介绍的Java平台自身Cleaner机制等也有人利用幻象引用监控对象的创建和销毁。
## 考点分析
这道面试题,属于既偏门又非常高频的一道题目。说它偏门,是因为在大多数应用开发中,很少直接操作各种不同引用,虽然我们使用的类库、框架可能利用了其机制。它被频繁问到,是因为这是一个综合性的题目,既考察了我们对基础概念的理解,也考察了对底层对象生命周期、垃圾收集机制等的掌握。
充分理解这些引用对于我们设计可靠的缓存等框架或者诊断应用OOM等问题会很有帮助。比如诊断MySQL connector-j驱动在特定模式下useCompression=true的内存泄漏问题就需要我们理解怎么排查幻象引用的堆积问题。
## 知识扩展
1.对象可达性状态流转分析
首先请你看下面流程图我这里简单总结了对象生命周期和不同可达性状态以及不同状态可能的改变关系可能未必100%严谨,来阐述下可达性的变化。
<img src="https://static001.geekbang.org/resource/image/36/b0/36d3c7b158eda9421ef32463cb4d4fb0.png" alt="" />
我来解释一下上图的具体状态这是Java定义的不同可达性级别reachability level具体如下
<li>
强可达Strongly Reachable就是当一个对象可以有一个或多个线程可以不通过各种引用访问到的情况。比如我们新创建一个对象那么创建它的线程对它就是强可达。
</li>
<li>
软可达Softly Reachable就是当我们只能通过软引用才能访问到对象的状态。
</li>
<li>
弱可达Weakly Reachable类似前面提到的就是无法通过强引用或者软引用访问只能通过弱引用访问时的状态。这是十分临近finalize状态的时机当弱引用被清除的时候就符合finalize的条件了。
</li>
<li>
幻象可达Phantom Reachable上面流程图已经很直观了就是没有强、软、弱引用关联并且finalize过了只有幻象引用指向这个对象的时候。
</li>
<li>
当然还有一个最后的状态就是不可达unreachable意味着对象可以被清除了。
</li>
判断对象可达性是JVM垃圾收集器决定如何处理对象的一部分考虑。
所有引用类型都是抽象类java.lang.ref.Reference的子类你可能注意到它提供了get()方法:
<img src="https://static001.geekbang.org/resource/image/ba/3e/bae702d46c665e12113f5abd876eb53e.png" alt="" />
除了幻象引用因为get永远返回null如果对象还没有被销毁都可以通过get方法获取原有对象。这意味着利用软引用和弱引用我们可以将访问到的对象重新指向强引用也就是人为的改变了对象的可达性状态这也是为什么我在上面图里有些地方画了双向箭头。
所以,对于软引用、弱引用之类,垃圾收集器可能会存在二次确认的问题,以保证处于弱引用状态的对象,没有改变为强引用。
但是,你觉得这里有没有可能出现什么问题呢?
不错如果我们错误的保持了强引用比如赋值给了static变量那么对象可能就没有机会变回类似弱引用的可达性状态了就会产生内存泄漏。所以检查弱引用指向对象是否被垃圾收集也是诊断是否有特定内存泄漏的一个思路如果我们的框架使用到弱引用又怀疑有内存泄漏就可以从这个角度检查。
2.引用队列ReferenceQueue使用
谈到各种引用的编程就必然要提到引用队列。我们在创建各种引用并关联到相应对象时可以选择是否需要关联引用队列JVM会在特定时机将引用enqueue到队列里我们可以从队列里获取引用remove方法在这里实际是有获取的意思进行相关后续逻辑。尤其是幻象引用get方法只返回null如果再不指定引用队列基本就没有意义了。看看下面的示例代码。利用引用队列我们可以在对象处于相应状态时对于幻象引用就是前面说的被finalize了处于幻象可达状态执行后期处理逻辑。
```
Object counter = new Object();
ReferenceQueue refQueue = new ReferenceQueue&lt;&gt;();
PhantomReference&lt;Object&gt; p = new PhantomReference&lt;&gt;(counter, refQueue);
counter = null;
System.gc();
try {
// Remove是一个阻塞方法可以指定timeout或者选择一直阻塞
Reference&lt;Object&gt; ref = refQueue.remove(1000L);
if (ref != null) {
// do something
}
} catch (InterruptedException e) {
// Handle it
}
```
3.显式地影响软引用垃圾收集
前面泛泛提到了引用对垃圾收集的影响尤其是软引用到底JVM内部是怎么处理它的其实并不是非常明确。那么我们能不能使用什么方法来影响软引用的垃圾收集呢
答案是有的。软引用通常会在最后一次引用后还能保持一段时间默认值是根据堆剩余空间计算的以M bytes为单位。从Java 1.3.1开始,提供了-XX:SoftRefLRUPolicyMSPerMB参数我们可以以毫秒milliseconds为单位设置。比如下面这个示例就是设置为3秒3000毫秒
```
-XX:SoftRefLRUPolicyMSPerMB=3000
```
这个剩余空间其实会受不同JVM模式影响对于Client模式比如通常的Windows 32 bit JDK剩余空间是计算当前堆里空闲的大小所以更加倾向于回收而对于server模式JVM则是根据-Xmx指定的最大值来计算。
本质上这个行为还是个黑盒取决于JVM实现即使是上面提到的参数在新版的JDK上也未必有效另外Client模式的JDK已经逐步退出历史舞台。所以在我们应用时可以参考类似设置但不要过于依赖它。
4.诊断JVM引用情况
如果你怀疑应用存在引用或finalize导致的回收问题可以有很多工具或者选项可供选择比如HotSpot JVM自身便提供了明确的选项PrintReferenceGC去获取相关信息我指定了下面选项去使用JDK 8运行一个样例应用
```
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintReferenceGC
```
这是JDK 8使用ParrallelGC收集的垃圾收集日志各种引用数量非常清晰。
```
0.403: [GC (Allocation Failure) 0.871: [SoftReference, 0 refs, 0.0000393 secs]0.871: [WeakReference, 8 refs, 0.0000138 secs]0.871: [FinalReference, 4 refs, 0.0000094 secs]0.871: [PhantomReference, 0 refs, 0 refs, 0.0000085 secs]0.871: [JNI Weak Reference, 0.0000071 secs][PSYoungGen: 76272K-&gt;10720K(141824K)] 128286K-&gt;128422K(316928K), 0.4683919 secs] [Times: user=1.17 sys=0.03, real=0.47 secs]
```
**注意JDK 9对JVM和垃圾收集日志进行了广泛的重构**类似PrintGCTimeStamps和PrintReferenceGC已经不再存在我在专栏后面的垃圾收集主题里会更加系统的阐述。
5.Reachability Fence
除了我前面介绍的几种基本引用类型我们也可以通过底层API来达到强引用的效果这就是所谓的设置**reachability fence**。
为什么需要这种机制呢考虑一下这样的场景按照Java语言规范如果一个对象没有指向强引用就符合垃圾收集的标准有些时候对象本身并没有强引用但是也许它的部分属性还在被使用这样就导致诡异的问题所以我们需要一个方法在没有强引用情况下通知JVM对象是在被使用的。说起来有点绕我们来看看Java 9中提供的案例。
```
class Resource {
private static ExternalResource[] externalResourceArray = ...
int myIndex; Resource(...) {
myIndex = ...
externalResourceArray[myIndex] = ...;
...
}
protected void finalize() {
externalResourceArray[myIndex] = null;
...
}
public void action() {
try {
// 需要被保护的代码
int i = myIndex;
Resource.update(externalResourceArray[i]);
} finally {
// 调用reachbilityFence明确保障对象strongly reachable
Reference.reachabilityFence(this);
}
}
private static void update(ExternalResource ext) {
ext.status = ...;
}
}
```
方法action的执行依赖于对象的部分属性所以被特定保护了起来。否则如果我们在代码中像下面这样调用那么就可能会出现困扰因为没有强引用指向我们创建出来的Resource对象JVM对它进行finalize操作是完全合法的。
```
new Resource().action()
```
类似的书写结构,在异步编程中似乎是很普遍的,因为异步编程中往往不会用传统的“执行-&gt;返回-&gt;使用”的结构。
在Java 9之前实现类似功能相对比较繁琐有的时候需要采取一些比较隐晦的小技巧。幸好java.lang.ref.Reference给我们提供了新方法它是JEP 193: Variable Handles的一部分将Java平台底层的一些能力暴露出来
```
static void reachabilityFence(Object ref)
```
在JDK源码中reachabilityFence大多使用在Executors或者类似新的HTTP/2客户端代码中大部分都是异步调用的情况。编程中可以按照上面这个例子将需要reachability保障的代码段利用try-finally包围起来在finally里明确声明对象强可达。
今天我总结了Java语言提供的几种引用类型、相应可达状态以及对于JVM工作的意义并分析了引用队列使用的一些实际情况最后介绍了在新的编程模式下如何利用API去保障对象不被意外回收希望对你有所帮助。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗?给你留一道练习题,你能从自己的产品或者第三方类库中找到使用各种引用的案例吗?它们都试图解决什么问题?
请你在留言区写写你的答案,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享出去,或许你能帮到他。

View File

@@ -0,0 +1,191 @@
<audio id="audio" title="第5讲 | String、StringBuffer、StringBuilder有什么区别" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/af/35a90cbba35136702628acf87c4b30af.mp3"></audio>
今天我会聊聊日常使用的字符串,别看它似乎很简单,但其实字符串几乎在所有编程语言里都是个特殊的存在,因为不管是数量还是体积,字符串都是大多数应用中的重要组成。
今天我要问你的问题是理解Java的字符串String、StringBuffer、StringBuilder有什么区别
## 典型回答
String是Java语言非常基础和重要的类提供了构造和管理字符串的各种基本逻辑。它是典型的Immutable类被声明成为final class所有属性也都是final的。也由于它的不可变性类似拼接、裁剪字符串等动作都会产生新的String对象。由于字符串操作的普遍性所以相关操作的效率往往对应用性能有明显影响。
StringBuffer是为解决上面提到拼接产生太多中间对象的问题而提供的一个类我们可以用append或者add方法把字符串添加到已有序列的末尾或者指定位置。StringBuffer本质是一个线程安全的可修改字符序列它保证了线程安全也随之带来了额外的性能开销所以除非有线程安全的需要不然还是推荐使用它的后继者也就是StringBuilder。
StringBuilder是Java 1.5中新增的在能力上和StringBuffer没有本质区别但是它去掉了线程安全的部分有效减小了开销是绝大部分情况下进行字符串拼接的首选。
## 考点分析
几乎所有的应用开发都离不开操作字符串理解字符串的设计和实现以及相关工具如拼接类的使用对写出高质量代码是非常有帮助的。关于这个问题我前面的回答是一个通常的概要性回答至少你要知道String是Immutable的字符串操作不当可能会产生大量临时字符串以及线程安全方面的区别。
如果继续深入,面试官可以从各种不同的角度考察,比如可以:
<li>
通过String和相关类考察基本的线程安全设计与实现各种基础编程实践。
</li>
<li>
考察JVM对象缓存机制的理解以及如何良好地使用。
</li>
<li>
考察JVM优化Java代码的一些技巧。
</li>
<li>
String相关类的演进比如Java 9中实现的巨大变化。
</li>
<li>
</li>
针对上面这几方面,我会在知识扩展部分与你详细聊聊。
## 知识扩展
1.字符串设计和实现考量
我在前面介绍过String是Immutable类的典型实现原生的保证了基础线程安全因为你无法对它内部数据进行任何修改这种便利甚至体现在拷贝构造函数中由于不可变Immutable对象在拷贝时不需要额外复制数据。
我们再来看看StringBuffer实现的一些细节它的线程安全是通过把各种修改数据的方法都加上synchronized关键字实现的非常直白。其实这种简单粗暴的实现方式非常适合我们常见的线程安全类实现不必纠结于synchronized性能之类的有人说“过早优化是万恶之源”考虑可靠性、正确性和代码可读性才是大多数应用开发最重要的因素。
为了实现修改字符序列的目的StringBuffer和StringBuilder底层都是利用可修改的charJDK 9以后是byte数组二者都继承了AbstractStringBuilder里面包含了基本操作区别仅在于最终的方法是否加了synchronized。
另外这个内部数组应该创建成多大的呢如果太小拼接的时候可能要重新创建足够大的数组如果太大又会浪费空间。目前的实现是构建时初始字符串长度加16这意味着如果没有构建对象时输入最初的字符串那么初始值就是16。我们如果确定拼接会发生非常多次而且大概是可预计的那么就可以指定合适的大小避免很多次扩容的开销。扩容会产生多重开销因为要抛弃原有数组创建新的可以简单认为是倍数数组还要进行arraycopy。
前面我讲的这些内容,在具体的代码书写中,应该如何选择呢?
在没有线程安全问题的情况下全部拼接操作是应该都用StringBuilder实现吗毕竟这样书写的代码还是要多敲很多字的可读性也不理想下面的对比非常明显。
```
String strByBuilder = new
StringBuilder().append(&quot;aa&quot;).append(&quot;bb&quot;).append(&quot;cc&quot;).append
(&quot;dd&quot;).toString();
String strByConcat = &quot;aa&quot; + &quot;bb&quot; + &quot;cc&quot; + &quot;dd&quot;;
```
其实在通常情况下没有必要过于担心要相信Java还是非常智能的。
我们来做个实验把下面一段代码利用不同版本的JDK编译然后再反编译例如
```
public class StringConcat {
public static String concat(String str) {
return str + “aa” + “bb”;
}
}
```
先编译再反编译比如使用不同版本的JDK
```
${JAVA_HOME}/bin/javac StringConcat.java
${JAVA_HOME}/bin/javap -v StringConcat.class
```
JDK 8的输出片段是
```
0: new #2 // class java/lang/StringBuilder
3: dup
4: invokespecial #3 // Method java/lang/StringBuilder.&quot;&lt;init&gt;&quot;:()V
7: aload_0
8: invokevirtual #4 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
11: ldc #5 // String aa
13: invokevirtual #4 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
16: ldc #6 // String bb
18: invokevirtual #4 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
```
而在JDK 9中反编译的结果就会有点特别了片段是
```
// concat method
1: invokedynamic #2, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
// ...
// 实际是利用了MethodHandle,统一了入口
0: #15 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
```
你可以看到非静态的拼接逻辑在JDK 8中会自动被javac转换为StringBuilder操作而在JDK 9里面则是体现了思路的变化。Java 9利用InvokeDynamic将字符串拼接的优化与javac生成的字节码解耦假设未来JVM增强相关运行时实现将不需要依赖javac的任何修改。
在日常编程中,保证程序的可读性、可维护性,往往比所谓的最优性能更重要,你可以根据实际需求酌情选择具体的编码方式。
2.字符串缓存
我们粗略统计过把常见应用进行堆转储Dump Heap然后分析对象组成会发现平均25%的对象是字符串,并且其中约半数是重复的。如果能避免创建重复字符串,可以有效降低内存消耗和对象创建开销。
String在Java 6以后提供了intern()方法目的是提示JVM把相应字符串缓存起来以备重复使用。在我们创建字符串对象并调用intern()方法的时候如果已经有缓存的字符串就会返回缓存里的实例否则将其缓存起来。一般来说JVM会将所有的类似“abc”这样的文本字符串或者字符串常量之类缓存起来。
看起来很不错是吧但实际情况估计会让你大跌眼镜。一般使用Java 6这种历史版本并不推荐大量使用intern为什么呢魔鬼存在于细节中被缓存的字符串是存在所谓PermGen里的也就是臭名昭著的“永久代”这个空间是很有限的也基本不会被FullGC之外的垃圾收集照顾到。所以如果使用不当OOM就会光顾。
在后续版本中这个缓存被放置在堆中这样就极大避免了永久代占满的问题甚至永久代在JDK 8中被MetaSpace元数据区替代了。而且默认缓存大小也在不断地扩大中从最初的1009到7u40以后被修改为60013。你可以使用下面的参数直接打印具体数字可以拿自己的JDK立刻试验一下。
```
-XX:+PrintStringTableStatistics
```
你也可以使用下面的JVM参数手动调整大小但是绝大部分情况下并不需要调整除非你确定它的大小已经影响了操作效率。
```
-XX:StringTableSize=N
```
Intern是一种**显式地排重机制**,但是它也有一定的副作用,因为需要开发者写代码时明确调用,一是不方便,每一个都显式调用是非常麻烦的;另外就是我们很难保证效率,应用开发阶段很难清楚地预计字符串的重复情况,有人认为这是一种污染代码的实践。
幸好在Oracle JDK 8u20之后推出了一个新的特性也就是G1 GC下的字符串排重。它是通过将相同数据的字符串指向同一份数据来做到的是JVM底层的改变并不需要Java类库做什么修改。
注意这个功能目前是默认关闭的你需要使用下面参数开启并且记得指定使用G1 GC
```
-XX:+UseStringDeduplication
```
前面说到的几个方面只是Java底层对字符串各种优化的一角在运行时字符串的一些基础操作会直接利用JVM内部的Intrinsic机制往往运行的就是特殊优化的本地代码而根本就不是Java代码生成的字节码。Intrinsic可以简单理解为是一种利用native方式hard-coded的逻辑算是一种特别的内联很多优化还是需要直接使用特定的CPU指令具体可以看相关[源码](http://hg.openjdk.java.net/jdk/jdk/file/44b64fc0baa3/src/hotspot/share/classfile/vmSymbols.hpp)搜索“string”以查找相关Intrinsic定义。当然你也可以在启动实验应用时使用下面参数了解intrinsic发生的状态。
```
-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
//样例输出片段
180 3 3 java.lang.String::charAt (25 bytes)
@ 1 java.lang.String::isLatin1 (19 bytes)
...
@ 7 java.lang.StringUTF16::getChar (60 bytes) intrinsic
```
可以看出仅仅是字符串一个实现就需要Java平台工程师和科学家付出如此大且默默无闻的努力我们得到的很多便利都是来源于此。
我会在专栏后面的JVM和性能等主题详细介绍JVM内部优化的一些方法如果你有兴趣可以再深入学习。即使你不做JVM开发或者暂时还没有使用到特别的性能优化这些知识也能帮助你增加技术深度。
3.String自身的演化
如果你仔细观察过Java的字符串在历史版本中它是使用char数组来存数据的这样非常直接。但是Java中的char是两个bytes大小拉丁语系语言的字符根本就不需要太宽的char这样无区别的实现就造成了一定的浪费。密度是编程语言平台永恒的话题因为归根结底绝大部分任务是要来操作数据的。
其实在Java 6的时候Oracle JDK就提供了压缩字符串的特性但是这个特性的实现并不是开源的而且在实践中也暴露出了一些问题所以在最新的JDK版本中已经将它移除了。
在Java 9中我们引入了Compact Strings的设计对字符串进行了大刀阔斧的改进。将数据存储方式从char数组改变为一个byte数组加上一个标识编码的所谓coder并且将相关字符串操作类都进行了修改。另外所有相关的Intrinsic之类也都进行了重写以保证没有任何性能损失。
虽然底层实现发生了这么大的改变但是Java字符串的行为并没有任何大的变化所以这个特性对于绝大部分应用来说是透明的绝大部分情况不需要修改已有代码。
当然在极端情况下字符串也出现了一些能力退化比如最大字符串的大小。你可以思考下原来char数组的实现字符串的最大长度就是数组本身的长度限制但是替换成byte数组同样数组长度下存储能力是退化了一倍的还好这是存在于理论中的极限还没有发现现实应用受此影响。
在通用的性能测试和产品实验中,我们能非常明显地看到紧凑字符串带来的优势,**即更小的内存占用、更快的操作速度**。
今天我从String、StringBuffer和StringBuilder的主要设计和实现特点开始分析了字符串缓存的intern机制、非代码侵入性的虚拟机层面排重、Java 9中紧凑字符的改进并且初步接触了JVM的底层优化机制intrinsic。从实践的角度不管是Compact Strings还是底层intrinsic优化都说明了使用Java基础类库的优势它们往往能够得到最大程度、最高质量的优化而且只要升级JDK版本就能零成本地享受这些益处。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗限于篇幅有限还有很多字符相关的问题没有来得及讨论比如编码相关的问题。可以思考一下很多字符串操作比如getBytes()/[String](https://docs.oracle.com/javase/9/docs/api/java/lang/String.html#String-byte:A-)(byte[] bytes)等都是隐含着使用平台默认编码,这是一种好的实践吗?是否有利于避免乱码?
请你在留言区写写你对这个问题的思考,或者分享一下你在操作字符串时掉过的坑,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,169 @@
<audio id="audio" title="第6讲 | 动态代理是基于什么原理?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/91/84/91bc6bafb00eda3b5369dbb25210ad84.mp3"></audio>
编程语言通常有各种不同的分类角度,动态类型和静态类型就是其中一种分类角度,简单区分就是语言类型信息是在运行时检查,还是编译期检查。
与其近似的还有一个对比,就是所谓强类型和弱类型,就是不同类型变量赋值时,是否需要显式地(强制)进行类型转换。
那么如何分类Java语言呢通常认为Java是静态的强类型语言但是因为提供了类似反射等机制也具备了部分动态类型语言的能力。
言归正传今天我要问你的问题是谈谈Java反射机制动态代理是基于什么原理
## 典型回答
反射机制是Java语言提供的一种基础功能赋予程序在运行时**自省**introspect官方用语的能力。通过反射我们可以直接操作类或者对象比如获取某个对象的类定义获取类声明的属性和方法调用方法或者构造对象甚至可以运行时修改类定义。
动态代理是一种方便运行时动态构建代理、动态处理代理方法调用的机制很多场景都是利用类似机制做到的比如用来包装RPC调用、面向切面的编程AOP
实现动态代理的方式很多比如JDK自身提供的动态代理就是主要利用了上面提到的反射机制。还有其他的实现方式比如利用传说中更高性能的字节码操作机制类似ASM、cglib基于ASM、Javassist等。
## 考点分析
这个题目给我的第一印象是稍微有点诱导的嫌疑可能会下意识地以为动态代理就是利用反射机制实现的这么说也不算错但稍微有些不全面。功能才是目的实现的方法有很多。总的来说这道题目考察的是Java语言的另外一种基础机制 反射它就像是一种魔法引入运行时自省能力赋予了Java语言令人意外的活力通过运行时操作元数据或对象Java可以灵活地操作运行时才能确定的信息。而动态代理则是延伸出来的一种广泛应用于产品开发中的技术很多繁琐的重复编程都可以被动态代理机制优雅地解决。
从考察知识点的角度,这道题涉及的知识点比较庞杂,所以面试官能够扩展或者深挖的内容非常多,比如:
<li>
考察你对反射机制的了解和掌握程度。
</li>
<li>
动态代理解决了什么问题,在你业务系统中的应用场景是什么?
</li>
<li>
JDK动态代理在设计和实现上与cglib等方式有什么不同进而如何取舍
</li>
这些考点似乎不是短短一篇文章能够囊括的,我会在知识扩展部分尽量梳理一下。
## 知识扩展
1.反射机制及其演进
对于Java语言的反射机制本身如果你去看一下java.lang或java.lang.reflect包下的相关抽象就会有一个很直观的印象了。Class、Field、Method、Constructor等这些完全就是我们去操作类和对象的元数据对应。反射各种典型用例的编程相信有太多文章或书籍进行过详细的介绍我就不再赘述了至少你需要掌握基本场景编程这里是官方提供的参考文档[https://docs.oracle.com/javase/tutorial/reflect/index.html](https://docs.oracle.com/javase/tutorial/reflect/index.html) 。
关于反射有一点我需要特意提一下就是反射提供的AccessibleObject.setAccessible(boolean flag)。它的子类也大都重写了这个方法这里的所谓accessible可以理解成修饰成员的public、protected、private这意味着我们可以在运行时修改成员访问限制
setAccessible的应用场景非常普遍遍布我们的日常开发、测试、依赖注入等各种框架中。比如在O/R Mapping框架中我们为一个Java实体对象运行时自动生成setter、getter的逻辑这是加载或者持久化数据非常必要的框架通常可以利用反射做这个事情而不需要开发者手动写类似的重复代码。
另一个典型场景就是绕过API访问控制。我们日常开发时可能被迫要调用内部API去做些事情比如自定义的高性能NIO框架需要显式地释放DirectBuffer使用反射绕开限制是一种常见办法。
但是在Java 9以后这个方法的使用可能会存在一些争议因为Jigsaw项目新增的模块化系统出于强封装性的考虑对反射访问进行了限制。Jigsaw引入了所谓Open的概念只有当被反射操作的模块和指定的包对反射调用者模块Open才能使用setAccessible否则被认为是不合法illegal操作。如果我们的实体类是定义在模块里面我们需要在模块描述符中明确声明
```
module MyEntities {
// Open for reflection
opens com.mycorp to java.persistence;
}
```
因为反射机制使用广泛根据社区讨论目前Java 9仍然保留了兼容Java 8的行为但是很有可能在未来版本完全启用前面提到的针对setAccessible的限制即只有当被反射操作的模块和指定的包对反射调用者模块Open才能使用setAccessible我们可以使用下面参数显式设置。
```
--illegal-access={ permit | warn | deny }
```
2.动态代理
前面的问题问到了动态代理,我们一起看看,它到底是解决什么问题?
首先,它是一个**代理机制**。如果熟悉设计模式中的代理模式我们会知道代理可以看作是对调用目标的一个包装这样我们对目标代码的调用不是直接发生的而是通过代理完成。其实很多动态代理场景我认为也可以看作是装饰器Decorator模式的应用我会在后面的专栏设计模式主题予以补充。
通过代理可以让调用者与实现者之间**解耦**。比如进行RPC调用框架内部的寻址、序列化、反序列化等对于调用者往往是没有太大意义的通过代理可以提供更加友善的界面。
代理的发展经历了静态到动态的过程源于静态代理引入的额外工作。类似早期的RMI之类古董技术还需要rmic之类工具生成静态stub等各种文件增加了很多繁琐的准备工作而这又和我们的业务逻辑没有关系。利用动态代理机制相应的stub等类可以在运行时生成对应的调用操作也是动态完成极大地提高了我们的生产力。改进后的RMI已经不再需要手动去准备这些了虽然它仍然是相对古老落后的技术未来也许会逐步被移除。
这么说可能不够直观我们可以看JDK动态代理的一个简单例子。下面只是加了一句print在生产系统中我们可以轻松扩展类似逻辑进行诊断、限流等。
```
public class MyDynamicProxy {
public static void main (String[] args) {
HelloImpl hello = new HelloImpl();
MyInvocationHandler handler = new MyInvocationHandler(hello);
// 构造代码实例
Hello proxyHello = (Hello) Proxy.newProxyInstance(HelloImpl.class.getClassLoader(), HelloImpl.class.getInterfaces(), handler);
// 调用代理方法
proxyHello.sayHello();
}
}
interface Hello {
void sayHello();
}
class HelloImpl implements Hello {
@Override
public void sayHello() {
System.out.println(&quot;Hello World&quot;);
}
}
class MyInvocationHandler implements InvocationHandler {
private Object target;
public MyInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
System.out.println(&quot;Invoking sayHello&quot;);
Object result = method.invoke(target, args);
return result;
}
}
```
上面的JDK Proxy例子非常简单地实现了动态代理的构建和代理操作。首先实现对应的InvocationHandler然后以接口Hello为纽带为被调用目标构建代理对象进而应用程序就可以使用代理对象间接运行调用目标的逻辑代理为应用插入额外逻辑这里是println提供了便利的入口。
从API设计和实现的角度这种实现仍然有局限性因为它是以接口为中心的相当于添加了一种对于被调用者没有太大意义的限制。我们实例化的是Proxy对象而不是真正的被调用类型这在实践中还是可能带来各种不便和能力退化。
如果被调用者没有实现接口而我们还是希望利用动态代理机制那么可以考虑其他方式。我们知道Spring AOP支持两种模式的动态代理JDK Proxy或者cglib如果我们选择cglib方式你会发现对接口的依赖被克服了。
cglib动态代理采取的是创建目标类的子类的方式因为是子类化我们可以达到近似使用被调用者本身的效果。在Spring编程中框架通常会处理这种情况当然我们也可以[显式指定](http://cliffmeyers.com/blog/2006/12/29/spring-aop-cglib-or-jdk-dynamic-proxies.html)。关于类似方案的实现细节,我就不再详细讨论了。
那我们在开发中怎样选择呢?我来简单对比下两种方式各自优势。
JDK Proxy的优势
<li>
最小化依赖关系减少依赖意味着简化开发和维护JDK本身的支持可能比cglib更加可靠。
</li>
<li>
平滑进行JDK版本升级而字节码类库通常需要进行更新以保证在新版Java上能够使用。
</li>
<li>
代码实现简单。
</li>
基于类似cglib框架的优势
<li>
有的时候调用目标可能不便实现额外接口从某种角度看限定调用者实现接口是有些侵入性的实践类似cglib动态代理就没有这种限制。
</li>
<li>
只操作我们关心的类,而不必为其他相关类增加工作量。
</li>
<li>
高性能。
</li>
另外从性能角度我想补充几句。记得有人曾经得出结论说JDK Proxy比cglib或者Javassist慢几十倍。坦白说不去争论具体的benchmark细节在主流JDK版本中JDK Proxy在典型场景可以提供对等的性能水平数量级的差距基本上不是广泛存在的。而且反射机制性能在现代JDK中自身已经得到了极大的改进和优化同时JDK很多功能也不完全是反射同样使用了ASM进行字节码操作。
我们在选型中,性能未必是唯一考量,可靠性、可维护性、编程工作量等往往是更主要的考虑因素,毕竟标准类库和反射编程的门槛要低得多,代码量也是更加可控的,如果我们比较下不同开源项目在动态代理开发上的投入,也能看到这一点。
动态代理应用非常广泛虽然最初多是因为RPC等使用进入我们视线但是动态代理的使用场景远远不仅如此它完美符合Spring AOP等切面编程。我在后面的专栏还会进一步详细分析AOP的目的和能力。简单来说它可以看作是对OOP的一个补充因为OOP对于跨越不同对象或类的分散、纠缠逻辑表现力不够比如在不同模块的特定阶段做一些事情类似日志、用户鉴权、全局性异常处理、性能监控甚至事务处理等你可以参考下面这张图。
<img src="https://static001.geekbang.org/resource/image/ba/2b/ba9a5b6228b188f5b9b15017e29a302b.png" alt="" />
AOP通过动态代理机制可以让开发者从这些繁琐事项中抽身出来大幅度提高了代码的抽象程度和复用度。从逻辑上来说我们在软件设计和实现中的类似代理如Facade、Observer等很多设计目的都可以通过动态代理优雅地实现。
今天我简要回顾了反射机制谈了反射在Java语言演进中正在发生的变化并且进一步探讨了动态代理机制和相关的切面编程分析了其解决的问题并探讨了生产实践中的选择考量。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗?留一道思考题给你,你在工作中哪些场景使用到了动态代理?相应选择了什么实现技术?选择的依据是什么?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,196 @@
<audio id="audio" title="第7讲 | int和Integer有什么区别" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e6/2b/e68304e65436d3900b0a056f4a62e92b.mp3"></audio>
Java虽然号称是面向对象的语言但是原始数据类型仍然是重要的组成元素所以在面试中经常考察原始数据类型和包装类等Java语言特性。
今天我要问你的问题是int和Integer有什么区别谈谈Integer的值缓存范围。
## 典型回答
int是我们常说的整形数字是Java的8个原始数据类型Primitive Typesboolean、byte 、short、char、int、float、double、long之一。**Java语言虽然号称一切都是对象但原始数据类型是例外。**
Integer是int对应的包装类它有一个int类型的字段存储数据并且提供了基本操作比如数学运算、int和字符串之间转换等。在Java 5中引入了自动装箱和自动拆箱功能boxing/unboxingJava可以根据上下文自动进行转换极大地简化了相关编程。
关于Integer的值缓存这涉及Java 5中另一个改进。构建Integer对象的传统方式是直接调用构造器直接new一个对象。但是根据实践我们发现大部分数据操作都是集中在有限的、较小的数值范围因而在Java 5中新增了静态工厂方法valueOf在调用它的时候会利用一个缓存机制带来了明显的性能改进。按照Javadoc**这个值默认缓存是-128到127之间。**
## 考点分析
今天这个问题涵盖了Java里的两个基础要素原始数据类型、包装类。谈到这里就可以非常自然地扩展到自动装箱、自动拆箱机制进而考察封装类的一些设计和实践。坦白说理解基本原理和用法已经足够日常工作需求了但是要落实到具体场景还是有很多问题需要仔细思考才能确定。
面试官可以结合其他方面,来考察面试者的掌握程度和思考逻辑,比如:
<li>
我在专栏第1讲中介绍的Java使用的不同阶段编译阶段、运行时自动装箱/自动拆箱是发生在什么阶段?
</li>
<li>
我在前面提到使用静态工厂方法valueOf会使用到缓存机制那么自动装箱的时候缓存机制起作用吗
</li>
<li>
为什么我们需要原始数据类型Java的对象似乎也很高效应用中具体会产生哪些差异
</li>
<li>
阅读过Integer源码吗分析下类或某些方法的设计要点。
</li>
似乎有太多内容可以探讨,我们一起来分析一下。
## 知识扩展
1.理解自动装箱、拆箱
自动装箱实际上算是一种**语法糖**。什么是语法糖可以简单理解为Java平台为我们自动进行了一些转换保证不同的写法在运行时等价它们发生在编译阶段也就是生成的字节码是一致的。
像前面提到的整数javac替我们自动把装箱转换为Integer.valueOf()把拆箱替换为Integer.intValue()这似乎这也顺道回答了另一个问题既然调用的是Integer.valueOf自然能够得到缓存的好处啊。
如何程序化的验证上面的结论呢?
你可以写一段简单的程序包含下面两句代码,然后反编译一下。当然,这是一种从表现倒推的方法,大多数情况下,我们还是直接参考规范文档会更加可靠,毕竟软件承诺的是遵循规范,而不是保持当前行为。
```
Integer integer = 1;
int unboxing = integer ++;
```
反编译输出:
```
1: invokestatic #2 // Method
java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
8: invokevirtual #3 // Method
java/lang/Integer.intValue:()I
```
这种缓存机制并不是只有Integer才有同样存在于其他的一些包装类比如
<li>
Boolean缓存了true/false对应实例确切说只会返回两个常量实例Boolean.TRUE/FALSE。
</li>
<li>
Short同样是缓存了-128到127之间的数值。
</li>
<li>
Byte数值有限所以全部都被缓存。
</li>
<li>
Character缓存范围\u0000\u007F
</li>
自动装箱/自动拆箱似乎很酷,在编程实践中,有什么需要注意的吗?
原则上,**建议避免无意中的装箱、拆箱行为**尤其是在性能敏感的场合创建10万个Java对象和10万个整数的开销可不是一个数量级的不管是内存使用还是处理速度光是对象头的空间占用就已经是数量级的差距了。
我们其实可以把这个观点扩展开使用原始数据类型、数组甚至本地代码实现等在性能极度敏感的场景往往具有比较大的优势用其替换掉包装类、动态数组如ArrayList等可以作为性能优化的备选项。一些追求极致性能的产品或者类库会极力避免创建过多对象。当然在大多数产品代码里并没有必要这么做还是以开发效率优先。以我们经常会使用到的计数器实现为例下面是一个常见的线程安全计数器实现。
```
class Counter {
private final AtomicLong counter = new AtomicLong();
public void increase() {
counter.incrementAndGet();
}
}
```
如果利用原始数据类型,可以将其修改为
```
class CompactCounter {
private volatile long counter;
private static final AtomicLongFieldUpdater&lt;CompactCounter&gt; updater = AtomicLongFieldUpdater.newUpdater(CompactCounter.class, &quot;counter&quot;);
public void increase() {
updater.incrementAndGet(this);
}
}
```
2.源码分析
考察是否阅读过、是否理解JDK源代码可能是部分面试官的关注点这并不完全是一种苛刻要求阅读并实践高质量代码也是程序员成长的必经之路下面我来分析下Integer的源码。
整体看一下Integer的职责它主要包括各种基础的常量比如最大值、最小值、位数等前面提到的各种静态工厂方法valueOf()获取环境变量数值的方法各种转换方法比如转换为不同进制的字符串如8进制或者反过来的解析方法等。我们进一步来看一些有意思的地方。
首先继续深挖缓存Integer的缓存范围虽然默认是-128到127但是在特别的应用场景比如我们明确知道应用会频繁使用更大的数值这时候应该怎么办呢
缓存上限值实际是可以根据需要调整的JVM提供了参数设置
```
-XX:AutoBoxCacheMax=N
```
这些实现,都体现在[java.lang.Integer](http://hg.openjdk.java.net/jdk/jdk/file/26ac622a4cab/src/java.base/share/classes/java/lang/Integer.java)源码之中并实现在IntegerCache的静态初始化块里。
```
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue = VM.getSavedProperty(&quot;java.lang.Integer.IntegerCache.high&quot;);
...
// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high &gt;= 127;
}
...
}
```
第二我们在分析字符串的设计实现时提到过字符串是不可变的保证了基本的信息安全和并发编程中的线程安全。如果你去看包装类里存储数值的成员变量“value”你会发现不管是Integer还Boolean等都被声明为“private final”所以它们同样是不可变类型
这种设计是可以理解的或者说是必须的选择。想象一下这个应用场景比如Integer提供了getInteger()方法用于方便地读取系统属性我们可以用属性来设置服务器某个服务的端口如果我可以轻易地把获取到的Integer对象改变为其他数值这会带来产品可靠性方面的严重问题。
第三Integer等包装类定义了类似SIZE或者BYTES这样的常量这反映了什么样的设计考虑呢如果你使用过其他语言比如C、C++类似整数的位数其实是不确定的可能在不同的平台比如32位或者64位平台存在非常大的不同。那么在32位JDK或者64位JDK里数据位数会有不同吗或者说这个问题可以扩展为我使用32位JDK开发编译的程序运行在64位JDK上需要做什么特别的移植工作吗
其实这种移植对于Java来说相对要简单些因为原始数据类型是不存在差异的这些明确定义在[Java语言规范](https://docs.oracle.com/javase/specs/jls/se10/html/jls-4.html#jls-4.2)里面不管是32位还是64位环境开发者无需担心数据的位数差异。
对于应用移植虽然存在一些底层实现的差异比如64位HotSpot JVM里的对象要比32位HotSpot JVM大具体区别取决于不同JVM实现的选择但是总体来说并没有行为差异应用移植还是可以做到宣称的“一次书写到处执行”应用开发者更多需要考虑的是容量、能力等方面的差异。
3.原始类型线程安全
前面提到了线程安全设计,你有没有想过,原始数据类型操作是不是线程安全的呢?
这里可能存在着不同层面的问题:
<li>
原始数据类型的变量显然要使用并发相关手段才能保证线程安全这些我会在专栏后面的并发主题详细介绍。如果有线程安全的计算需要建议考虑使用类似AtomicInteger、AtomicLong这样的线程安全类。
</li>
<li>
特别的是部分比较宽的数据类型比如float、double甚至不能保证更新操作的原子性可能出现程序读取到只更新了一半数据位的数值
</li>
4.Java原始数据类型和引用类型局限性
前面我谈了非常多的技术细节最后再从Java平台发展的角度来看看原始数据类型、对象的局限性和演进。
对于Java应用开发者设计复杂而灵活的类型系统似乎已经习以为常了。但是坦白说毕竟这种类型系统的设计是源于很多年前的技术决定现在已经逐渐暴露出了一些副作用例如
- 原始数据类型和Java泛型并不能配合使用
这是因为Java的泛型某种程度上可以算作伪泛型它完全是一种编译期的技巧Java编译期会自动将类型转换为对应的特定类型这就决定了使用泛型必须保证相应类型可以转换为Object。
- 无法高效地表达数据也不便于表达复杂的数据结构比如vector和tuple
我们知道Java的对象都是引用类型如果是一个原始数据类型数组它在内存里是一段连续的内存而对象数组则不然数据存储的是引用对象往往是分散地存储在堆的不同位置。这种设计虽然带来了极大灵活性但是也导致了数据操作的低效尤其是无法充分利用现代CPU缓存机制。
Java为对象内建了各种多态、线程安全等方面的支持但这不是所有场合的需求尤其是数据处理重要性日益提高更加高密度的值类型是非常现实的需求。
针对这些方面的增强目前正在OpenJDK领域紧锣密鼓地进行开发有兴趣的话你可以关注相关工程[http://openjdk.java.net/projects/valhalla/](http://openjdk.java.net/projects/valhalla/) 。
今天,我梳理了原始数据类型及其包装类,从源码级别分析了缓存机制等设计和实现细节,并且针对构建极致性能的场景,分析了一些可以借鉴的实践。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗留一道思考题给你前面提到了从空间角度Java对象要比原始数据类型开销大的多。你知道对象的内存结构是什么样的吗比如对象头的结构。如何计算或者获取某个Java对象的大小?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,163 @@
<audio id="audio" title="第8讲 | 对比Vector、ArrayList、LinkedList有何区别" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/ae/d8ed06ea052e0673c2aa9200f0cdd4ae.mp3"></audio>
我们在日常的工作中能够高效地管理和操作数据是非常重要的。由于每个编程语言支持的数据结构不尽相同比如我最早学习的C语言需要自己实现很多基础数据结构管理和操作会比较麻烦。相比之下Java则要方便的多针对通用场景的需求Java提供了强大的集合框架大大提高了开发者的生产力。
今天我要问你的是有关集合框架方面的问题对比Vector、ArrayList、LinkedList有何区别
## 典型回答
这三者都是实现集合框架中的List也就是所谓的有序集合因此具体功能也比较近似比如都提供按照位置进行定位、添加或者删除的操作都提供迭代器以遍历其内容等。但因为具体的设计区别在行为、性能、线程安全等方面表现又有很大不同。
Vector是Java早期提供的**线程安全的动态数组**如果不需要线程安全并不建议选择毕竟同步是有额外开销的。Vector内部是使用对象数组来保存数据可以根据需要自动的增加容量当数组已满时会创建新的数组并拷贝原有数组数据。
ArrayList是应用更加广泛的**动态数组**实现它本身不是线程安全的所以性能要好很多。与Vector近似ArrayList也是可以根据需要调整容量不过两者的调整逻辑有所区别Vector在扩容时会提高1倍而ArrayList则是增加50%。
LinkedList顾名思义是Java提供的**双向链表**,所以它不需要像上面两种那样调整容量,它也不是线程安全的。
## 考点分析
似乎从我接触Java开始这个问题就一直是经典的面试题前面我的回答覆盖了三者的一些基本的设计和实现。
一般来说,也可以补充一下不同容器类型适合的场景:
<li>
Vector和ArrayList作为动态数组其内部元素以数组形式顺序存储的所以非常适合随机访问的场合。除了尾部插入和删除元素往往性能会相对较差比如我们在中间位置插入一个元素需要移动后续所有元素。
</li>
<li>
而LinkedList进行节点插入、删除却要高效得多但是随机访问性能则要比动态数组慢。
</li>
所以,在应用开发中,如果事先可以估计到,应用操作是偏向于插入、删除,还是随机访问较多,就可以针对性的进行选择。这也是面试最常见的一个考察角度,给定一个场景,选择适合的数据结构,所以对于这种典型选择一定要掌握清楚。
考察Java集合框架我觉得有很多方面需要掌握
<li>
Java集合框架的设计结构至少要有一个整体印象。
</li>
<li>
Java提供的主要容器集合和Map类型了解或掌握对应的**数据结构、算法**,思考具体技术选择。
</li>
<li>
将问题扩展到性能、并发等领域。
</li>
<li>
集合框架的演进与发展。
</li>
作为Java专栏我会在尽量围绕Java相关进行扩展否则光是罗列集合部分涉及的数据结构就要占用很大篇幅。这并不代表那些不重要数据结构和算法是基本功往往也是必考的点有些公司甚至以考察这些方面而非常知名甚至是“臭名昭著”。我这里以需要掌握典型排序算法为例你至少需要熟知
<li>
内部排序,至少掌握基础算法如归并排序、交换排序(冒泡、快排)、选择排序、插入排序等。
</li>
<li>
外部排序,掌握利用内存和外部存储处理超大数据集,至少要理解过程和思路。
</li>
考察算法不仅仅是如何简单实现,面试官往往会刨根问底,比如哪些是排序是不稳定的呢(快排、堆排),或者思考稳定意味着什么;对不同数据集,各种排序的最好或最差情况;从某个角度如何进一步优化(比如空间占用,假设业务场景需要最小辅助空间,这个角度堆排序就比归并优异)等,从简单的了解,到进一步的思考,面试官通常还会观察面试者处理问题和沟通时的思路。
以上只是一个方面的例子,建议学习相关书籍,如《算法导论》《编程珠玑》等,或相关[教程](https://www.coursera.org/learn/algorithms-part1)。对于特定领域比如推荐系统建议咨询领域专家。单纯从面试的角度很多朋友推荐使用一些算法网站如LeetCode等帮助复习和准备面试但坦白说我并没有刷过这些算法题这也是仁者见仁智者见智的事情招聘时我更倾向于考察面试者自身最擅长的东西免得招到纯面试高手。
## 知识扩展
我们先一起来理解集合框架的整体设计为了有个直观的印象我画了一个简要的类图。注意为了避免混淆我这里没有把java.util.concurrent下面的线程安全容器添加进来也没有列出Map容器虽然通常概念上我们也会把Map作为集合框架的一部分但是它本身并不是真正的集合Collection
所以,我今天主要围绕狭义的集合框架,其他都会在专栏后面的内容进行讲解。
<img src="https://static001.geekbang.org/resource/image/67/c7/675536edf1563b11ab7ead0def1215c7.png" alt="" />
我们可以看到Java的集合框架Collection接口是所有集合的根然后扩展开提供了三大类集合分别是
<li>
List也就是我们前面介绍最多的有序集合它提供了方便的访问、插入、删除等操作。
</li>
<li>
SetSet是不允许重复元素的这是和List最明显的区别也就是不存在两个对象equals返回true。我们在日常开发中有很多需要保证元素唯一性的场合。
</li>
<li>
Queue/Deque则是Java提供的标准队列结构的实现除了集合的基本功能它还支持类似先入先出FIFO First-in-First-Out或者后入先出LIFOLast-In-First-Out等特定行为。这里不包括BlockingQueue因为通常是并发编程场合所以被放置在并发包里。
</li>
每种集合的通用逻辑都被抽象到相应的抽象类之中比如AbstractList就集中了各种List操作的通用部分。这些集合不是完全孤立的比如LinkedList本身既是List也是Deque哦。
如果阅读过更多[源码](http://hg.openjdk.java.net/jdk/jdk/file/bf9177eac58d/src/java.base/share/classes/java/util/TreeSet.java)你会发现其实TreeSet代码里实际默认是利用TreeMap实现的Java类库创建了一个Dummy对象“PRESENT”作为value然后所有插入的元素其实是以键的形式放入了TreeMap里面同理HashSet其实也是以HashMap为基础实现的原来他们只是Map类的马甲
就像前面提到过的我们需要对各种具体集合实现至少了解基本特征和典型使用场景以Set的几个实现为例
<li>
TreeSet支持自然顺序访问但是添加、删除、包含等操作要相对低效log(n)时间)。
</li>
<li>
HashSet则是利用哈希算法理想情况下如果哈希散列正常可以提供常数时间的添加、删除、包含等操作但是它不保证有序。
</li>
<li>
LinkedHashSet内部构建了一个记录插入顺序的双向链表因此提供了按照插入顺序遍历的能力与此同时也保证了常数时间的添加、删除、包含等操作这些操作性能略低于HashSet因为需要维护链表的开销。
</li>
<li>
在遍历元素时HashSet性能受自身容量影响所以初始化时除非有必要不然不要将其背后的HashMap容量设置过大。而对于LinkedHashSet由于其内部链表提供的方便遍历性能只和元素多少有关系。
</li>
我今天介绍的这些集合类都不是线程安全的对于java.util.concurrent里面的线程安全容器我在专栏后面会去介绍。但是并不代表这些集合完全不能支持并发编程的场景在Collections工具类中提供了一系列的synchronized方法比如
```
static &lt;T&gt; List&lt;T&gt; synchronizedList(List&lt;T&gt; list)
```
我们完全可以利用类似方法来实现基本的线程安全集合:
```
List list = Collections.synchronizedList(new ArrayList());
```
它的实现基本就是将每个基本方法比如get、set、add之类都通过synchronized添加基本的同步支持非常简单粗暴但也非常实用。注意这些方法创建的线程安全集合都符合迭代时fail-fast行为当发生意外的并发修改时尽早抛出ConcurrentModificationException异常以避免不可预计的行为。
另外一个经常会被考察到的问题就是理解Java提供的默认排序算法具体是什么排序方式以及设计思路等。
这个问题本身就是有点陷阱的意味因为需要区分是Arrays.sort()还是Collections.sort() 底层是调用Arrays.sort()什么数据类型多大的数据集太小的数据集复杂排序是没必要的Java会直接进行二分插入排序等。
<li>
对于原始数据类型目前使用的是所谓双轴快速排序Dual-Pivot QuickSort是一种改进的快速排序算法早期版本是相对传统的快速排序你可以阅读[源码](http://hg.openjdk.java.net/jdk/jdk/file/26ac622a4cab/src/java.base/share/classes/java/util/DualPivotQuicksort.java)。
</li>
<li>
而对于对象数据类型,目前则是使用[TimSort](http://hg.openjdk.java.net/jdk/jdk/file/26ac622a4cab/src/java.base/share/classes/java/util/TimSort.java)思想上也是一种归并和二分插入排序binarySort结合的优化排序算法。TimSort并不是Java的独创简单说它的思路是查找数据集中已经排好序的分区这里叫run然后合并这些分区来达到排序的目的。
</li>
另外Java 8引入了并行排序算法直接使用parallelSort方法这是为了充分利用现代多核处理器的计算能力底层实现基于fork-join框架专栏后面会对fork-join进行相对详细的介绍当处理的数据集比较小的时候差距不明显甚至还表现差一点但是当数据集增长到数万或百万以上时提高就非常大了具体还是取决于处理器和系统环境。
排序算法仍然在不断改进最近双轴快速排序实现的作者提交了一个更进一步的改进历时多年的研究目前正在审核和验证阶段。根据作者的性能测试对比相比于基于归并排序的实现新改进可以提高随机数据排序速度提高10%20%,甚至在其他特征的数据集上也有几倍的提高,有兴趣的话你可以参考具体代码和介绍:<br />
[http://mail.openjdk.java.net/pipermail/core-libs-dev/2018-January/051000.html](http://mail.openjdk.java.net/pipermail/core-libs-dev/2018-January/051000.html) 。
在Java 8之中Java平台支持了Lambda和Stream相应的Java集合框架也进行了大范围的增强以支持类似为集合创建相应stream或者parallelStream的方法实现我们可以非常方便的实现函数式代码。
阅读Java源代码你会发现这些API的设计和实现比较独特它们并不是实现在抽象类里面而是以**默认方法**的形式实现在Collection这样的接口里这是Java 8在语言层面的新特性允许接口实现默认方法理论上来说我们原来实现在类似Collections这种工具类中的方法大多可以转换到相应的接口上。针对这一点我在面向对象主题会专门梳理Java语言面向对象基本机制的演进。
在Java 9中Java标准类库提供了一系列的静态工厂方法比如List.of()、Set.of()大大简化了构建小的容器实例的代码量。根据业界实践经验我们发现相当一部分集合实例都是容量非常有限的而且在生命周期中并不会进行修改。但是在原有的Java类库中我们可能不得不写成
```
ArrayList&lt;String&gt; list = new ArrayList&lt;&gt;();
list.add(&quot;Hello&quot;);
list.add(&quot;World&quot;);
```
而利用新的容器静态工厂方法,一句代码就够了,并且保证了不可变性。
```
List&lt;String&gt; simpleList = List.of(&quot;Hello&quot;,&quot;world&quot;);
```
更进一步通过各种of静态工厂方法创建的实例还应用了一些我们所谓的最佳实践比如它是不可变的符合我们对线程安全的需求它因为不需要考虑扩容所以空间上更加紧凑等。
如果我们去看of方法的源码你还会发现一个特别有意思的地方我们知道Java已经支持所谓的可变参数varargs但是官方类库还是提供了一系列特定参数长度的方法看起来似乎非常不优雅为什么呢这其实是为了最优的性能JVM在处理变长参数的时候会有明显的额外开销如果你需要实现性能敏感的API也可以进行参考。
今天我从Verctor、ArrayList、LinkedList开始逐步分析其设计实现区别、适合的应用场景等并进一步对集合框架进行了简单的归纳介绍了集合框架从基础算法到API设计实现的各种改进希望能对你的日常开发和API设计能够有帮助。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗留一道思考题给你先思考一个应用场景比如你需要实现一个云计算任务调度系统希望可以保证VIP客户的任务被优先处理你可以利用哪些数据结构或者标准的集合类型呢更进一步讲类似场景大多是基于什么数据结构呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,344 @@
<audio id="audio" title="第9讲 | 对比Hashtable、HashMap、TreeMap有什么不同" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a0/77/a0434e68bf102953824a7f3cdd595877.mp3"></audio>
Map是广义Java集合框架中的另外一部分HashMap作为框架中使用频率最高的类型之一它本身以及相关类型自然也是面试考察的热点。
今天我要问你的问题是对比Hashtable、HashMap、TreeMap有什么不同谈谈你对HashMap的掌握。
## 典型回答
Hashtable、HashMap、TreeMap都是最常见的一些Map实现是以**键值对**的形式存储和操作数据的容器类型。
Hashtable是早期Java类库提供的一个[哈希表](https://zh.wikipedia.org/wiki/%E5%93%88%E5%B8%8C%E8%A1%A8)实现本身是同步的不支持null键和值由于同步导致的性能开销所以已经很少被推荐使用。
HashMap是应用更加广泛的哈希表实现行为上大致上与HashTable一致主要区别在于HashMap不是同步的支持null键和值等。通常情况下HashMap进行put或者get操作可以达到常数时间的性能所以**它是绝大部分利用键值对存取场景的首选**比如实现一个用户ID和用户信息对应的运行时存储结构。
TreeMap则是基于红黑树的一种提供顺序访问的Map和HashMap不同它的get、put、remove之类操作都是Olog(n)的时间复杂度具体顺序可以由指定的Comparator来决定或者根据键的自然顺序来判断。
## 考点分析
上面的回答只是对一些基本特征的简单总结针对Map相关可以扩展的问题很多从各种数据结构、典型应用场景到程序设计实现的技术考量尤其是在Java 8里HashMap本身发生了非常大的变化这些都是经常考察的方面。
很多朋友向我反馈面试官似乎钟爱考察HashMap的设计和实现细节所以今天我会增加相应的源码解读主要专注于下面几个方面
<li>
理解Map相关类似整体结构尤其是有序数据结构的一些要点。
</li>
<li>
从源码去分析HashMap的设计和实现要点理解容量、负载因子等为什么需要这些参数如何影响Map的性能实践中如何取舍等。
</li>
<li>
理解树化改造的相关原理和改进原因。
</li>
除了典型的代码分析还有一些有意思的并发相关问题也经常会被提到如HashMap在并发环境可能出现[无限循环占用CPU](https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6423457)、size不准确等诡异的问题。
我认为这是一种典型的使用错误因为HashMap明确声明不是线程安全的数据结构如果忽略这一点简单用在多线程场景里难免会出现问题。
理解导致这种错误的原因,也是深入理解并发程序运行的好办法。对于具体发生了什么,你可以参考这篇很久以前的[分析](http://mailinator.blogspot.com/2009/06/beautiful-race-condition.html),里面甚至提供了示意图,我就不再重复别人写好的内容了。
## 知识扩展
1.Map整体结构
首先我们先对Map相关类型有个整体了解Map虽然通常被包括在Java集合框架里但是其本身并不是狭义上的集合类型Collection具体你可以参考下面这个简单类图。
<img src="https://static001.geekbang.org/resource/image/26/7c/266cfaab2573c9777b1157816784727c.png" alt="" />
Hashtable比较特别作为类似Vector、Stack的早期集合相关类型它是扩展了Dictionary类的类结构上与HashMap之类明显不同。
HashMap等其他Map实现则是都扩展了AbstractMap里面包含了通用方法抽象。不同Map的用途从类图结构就能体现出来设计目的已经体现在不同接口上。
大部分使用Map的场景通常就是放入、访问或者删除而对顺序没有特别要求HashMap在这种情况下基本是最好的选择。**HashMap的性能表现非常依赖于哈希码的有效性请务必掌握hashCode和equals的一些基本约定**,比如:
<li>
equals相等hashCode一定要相等。
</li>
<li>
重写了hashCode也要重写equals。
</li>
<li>
hashCode需要保持一致性状态改变返回的哈希值仍然要一致。
</li>
<li>
equals的对称、反射、传递等特性。
</li>
这方面内容网上有很多资料,我就不在这里详细展开了。
针对有序Map的分析内容比较有限我再补充一些虽然LinkedHashMap和TreeMap都可以保证某种顺序但二者还是非常不同的。
- LinkedHashMap通常提供的是遍历顺序符合插入顺序它的实现是通过为条目键值对维护一个双向链表。注意通过特定构造函数我们可以创建反映访问顺序的实例所谓的put、get、compute等都算作“访问”。
这种行为适用于一些特定应用场景例如我们构建一个空间占用敏感的资源池希望可以自动将最不常被访问的对象释放掉这就可以利用LinkedHashMap提供的机制来实现参考下面的示例
```
import java.util.LinkedHashMap;
import java.util.Map;
public class LinkedHashMapSample {
public static void main(String[] args) {
LinkedHashMap&lt;String, String&gt; accessOrderedMap = new LinkedHashMap&lt;String, String&gt;(16, 0.75F, true){
@Override
protected boolean removeEldestEntry(Map.Entry&lt;String, String&gt; eldest) { // 实现自定义删除策略否则行为就和普遍Map没有区别
return size() &gt; 3;
}
};
accessOrderedMap.put(&quot;Project1&quot;, &quot;Valhalla&quot;);
accessOrderedMap.put(&quot;Project2&quot;, &quot;Panama&quot;);
accessOrderedMap.put(&quot;Project3&quot;, &quot;Loom&quot;);
accessOrderedMap.forEach( (k,v) -&gt; {
System.out.println(k +&quot;:&quot; + v);
});
// 模拟访问
accessOrderedMap.get(&quot;Project2&quot;);
accessOrderedMap.get(&quot;Project2&quot;);
accessOrderedMap.get(&quot;Project3&quot;);
System.out.println(&quot;Iterate over should be not affected:&quot;);
accessOrderedMap.forEach( (k,v) -&gt; {
System.out.println(k +&quot;:&quot; + v);
});
// 触发删除
accessOrderedMap.put(&quot;Project4&quot;, &quot;Mission Control&quot;);
System.out.println(&quot;Oldest entry should be removed:&quot;);
accessOrderedMap.forEach( (k,v) -&gt; {// 遍历顺序不变
System.out.println(k +&quot;:&quot; + v);
});
}
}
```
- 对于TreeMap它的整体顺序是由键的顺序关系决定的通过Comparator或Comparable自然顺序来决定。
我在上一讲留给你的思考题提到了构建一个具有优先级的调度系统的问题其本质就是个典型的优先队列场景Java标准库提供了基于二叉堆实现的PriorityQueue它们都是依赖于同一种排序机制当然也包括TreeMap的马甲TreeSet。
类似hashCode和equals的约定为了避免模棱两可的情况自然顺序同样需要符合一个约定就是compareTo的返回值需要和equals一致否则就会出现模棱两可情况。
我们可以分析TreeMap的put方法实现
```
public V put(K key, V value) {
Entry&lt;K,V&gt; t = …
cmp = k.compareTo(t.key);
if (cmp &lt; 0)
t = t.left;
else if (cmp &gt; 0)
t = t.right;
else
return t.setValue(value);
// ...
}
```
从代码里,你可以看出什么呢? 当我不遵守约定时两个不符合唯一性equals要求的对象被当作是同一个因为compareTo返回0这会导致歧义的行为表现。
2.HashMap源码分析
前面提到HashMap设计与实现是个非常高频的面试题所以我会在这进行相对详细的源码解读主要围绕
<li>
HashMap内部实现基本点分析。
</li>
<li>
容量capacity和负载系数load factor
</li>
<li>
树化 。
</li>
首先我们来一起看看HashMap内部的结构它可以看作是数组Node&lt;K,V&gt;[] table和链表结合组成的复合结构数组被分为一个个桶bucket通过哈希值决定了键值对在这个数组的寻址哈希值相同的键值对则以链表形式存储你可以参考下面的示意图。这里需要注意的是如果链表大小超过阈值TREEIFY_THRESHOLD, 8图中的链表就会被改造为树形结构。
<img src="https://static001.geekbang.org/resource/image/1f/56/1f72306a9d8719c66790b56ef7977c56.png" alt="" />
从非拷贝构造函数的实现来看,这个表格(数组)似乎并没有在最初就初始化好,仅仅设置了一些初始值而已。
```
public HashMap(int initialCapacity, float loadFactor){
// ...
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
```
所以我们深刻怀疑HashMap也许是按照lazy-load原则在首次使用时被初始化拷贝构造函数除外我这里仅介绍最通用的场景。既然如此我们去看看put方法实现似乎只有一个putVal的调用
```
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
```
看来主要的秘密似乎藏在putVal里面到底有什么秘密呢为了节省空间我这里只截取了putVal比较关键的几部分。
```
final V putVal(int hash, K key, V value, boolean onlyIfAbent,
boolean evit) {
Node&lt;K,V&gt;[] tab; Node&lt;K,V&gt; p; int , i;
if ((tab = table) == null || (n = tab.length) = 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) &amp; hash]) == ull)
tab[i] = newNode(hash, key, value, nll);
else {
// ...
if (binCount &gt;= TREEIFY_THRESHOLD - 1) // -1 for first
treeifyBin(tab, hash);
// ...
}
}
```
从putVal方法最初的几行我们就可以发现几个有意思的地方
<li>
如果表格是nullresize方法会负责初始化它这从tab = resize()可以看出。
</li>
<li>
resize方法兼顾两个职责创建初始存储表格或者在容量不满足需求的时候进行扩容resize
</li>
<li>
在放置新的键值对的过程中,如果发生下面条件,就会发生扩容。
</li>
```
if (++size &gt; threshold)
resize();
```
- 具体键值对在哈希表中的位置数组index取决于下面的位运算
```
i = (n - 1) &amp; hash
```
仔细观察哈希值的源头我们会发现它并不是key本身的hashCode而是来自于HashMap内部的另外一个hash方法。注意为什么这里需要将高位数据移位到低位进行异或运算呢**这是因为有些数据计算出的哈希值差异主要在高位而HashMap里的哈希寻址是忽略容量以上的高位的那么这种处理就可以有效避免类似情况下的哈希碰撞。**
```
static final int hash(Object kye) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h &gt;&gt;&gt;16;
}
```
- 我前面提到的链表结构这里叫bin会在达到一定门限值时发生树化我稍后会分析为什么HashMap需要对bin进行处理。
可以看到putVal方法本身逻辑非常集中从初始化、扩容到树化全部都和它有关推荐你阅读源码的时候可以参考上面的主要逻辑。
我进一步分析一下身兼多职的resize方法很多朋友都反馈经常被面试官追问它的源码设计。
```
final Node&lt;K,V&gt;[] resize() {
// ...
else if ((newCap = oldCap &lt;&lt; 1) &lt; MAXIMUM_CAPACIY &amp;&amp;
oldCap &gt;= DEFAULT_INITIAL_CAPAITY)
newThr = oldThr &lt;&lt; 1; // double there
// ...
else if (oldThr &gt; 0) // initial capacity was placed in threshold
newCap = oldThr;
else {
// zero initial threshold signifies using defaultsfults
newCap = DEFAULT_INITIAL_CAPAITY;
newThr = (int)(DEFAULT_LOAD_ATOR* DEFAULT_INITIAL_CAPACITY
}
if (newThr ==0) {
float ft = (float)newCap * loadFator;
newThr = (newCap &lt; MAXIMUM_CAPACITY &amp;&amp; ft &lt; (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);
}
threshold = neThr;
Node&lt;K,V&gt;[] newTab = (Node&lt;K,V&gt;[])new Node[newap];
table = n
// 移动到新的数组结构e数组结构
}
```
依据resize源码不考虑极端情况容量理论最大极限由MAXIMUM_CAPACITY指定数值为 1&lt;&lt;30也就是2的30次方我们可以归纳为
<li>
门限值等于负载因子x容量如果构建HashMap的时候没有指定它们那么就是依据相应的默认常量值。
</li>
<li>
门限通常是以倍数进行调整 newThr = oldThr &lt;&lt; 1我前面提到根据putVal中的逻辑当元素个数超过门限大小时则调整Map大小。
</li>
<li>
扩容后,需要将老的数组中的元素重新放置到新的数组,这是扩容的一个主要开销来源。
</li>
3.容量、负载因子和树化
前面我们快速梳理了一下HashMap从创建到放入键值对的相关逻辑现在思考一下为什么我们需要在乎容量和负载因子呢
这是因为容量和负载系数决定了可用的桶的数量,空桶太多会浪费空间,如果使用的太满则会严重影响操作的性能。极端情况下,假设只有一个桶,那么它就退化成了链表,完全不能提供所谓常数时间存的性能。
既然容量和负载因子这么重要,我们在实践中应该如何选择呢?
如果能够知道HashMap要存取的键值对数量可以考虑预先设置合适的容量大小。具体数值我们可以根据扩容发生的条件来做简单预估根据前面的代码分析我们知道它需要符合计算条件
```
负载因子 * 容量 &gt; 元素数量
```
所以,预先设置的容量需要满足,大于“预估元素数量/负载因子”同时它是2的幂数结论已经非常清晰了。
而对于负载因子,我建议:
<li>
如果没有特别需求不要轻易进行更改因为JDK自身的默认负载因子是非常符合通用场景的需求的。
</li>
<li>
如果确实需要调整建议不要设置超过0.75的数值因为会显著增加冲突降低HashMap的性能。
</li>
<li>
如果使用太小的负载因子,按照上面的公式,预设容量值也进行调整,否则可能会导致更加频繁的扩容,增加无谓的开销,本身访问性能也会受影响。
</li>
我们前面提到了树化改造对应逻辑主要在putVal和treeifyBin中。
```
final void treeifyBin(Node&lt;K,V&gt;[] tab, int hash) {
int n, index; Node&lt;K,V&gt; e;
if (tab == null || (n = tab.length) &lt; MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) &amp; hash]) != null) {
//树化改造逻辑
}
}
```
上面是精简过的treeifyBin示意综合这两个方法树化改造的逻辑就非常清晰了可以理解为当bin的数量大于TREEIFY_THRESHOLD时
<li>
如果容量小于MIN_TREEIFY_CAPACITY只会进行简单的扩容。
</li>
<li>
如果容量大于MIN_TREEIFY_CAPACITY ,则会进行树化改造。
</li>
那么为什么HashMap要树化呢
**本质上这是个安全问题。**因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表,我们知道链表查询是线性的,会严重影响存取的性能。
而在现实世界构造哈希冲突的数据并不是非常复杂的事情恶意代码就可以利用这些数据大量与服务器端交互导致服务器端CPU大量占用这就构成了哈希碰撞拒绝服务攻击国内一线互联网公司就发生过类似攻击事件。
今天我从Map相关的几种实现对比对各种Map进行了分析讲解了有序集合类型容易混淆的地方并从源码级别分析了HashMap的基本结构希望对你有所帮助。
## 一课一练
关于今天我们讨论的题目你做到心中有数了吗?留一道思考题给你,解决哈希冲突有哪些典型方法呢?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。