mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-15 13:43:49 +08:00
mod
This commit is contained in:
@@ -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<K,V>
|
||||
implements Map<K,V>, Serializable {
|
||||
private final Map<K,V> 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<K,V> s; // manually integrate access methods to reduce overhead
|
||||
HashEntry<K,V>[] tab;
|
||||
int h = hash(key.hashCode());
|
||||
//利用位操作替换普通数学运算
|
||||
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
|
||||
// 以Segment为单位,进行定位
|
||||
// 利用Unsafe直接进行volatile access
|
||||
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
|
||||
(tab = s.table) != null) {
|
||||
//省略
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而对于put操作,首先是通过二次哈希避免哈希冲突,然后以Unsafe调用方式,直接获取相应的Segment,然后进行线程安全的put操作:
|
||||
|
||||
```
|
||||
public V put(K key, V value) {
|
||||
Segment<K,V> s;
|
||||
if (value == null)
|
||||
throw new NullPointerException();
|
||||
// 二次哈希,以保证数据的分散性,避免哈希冲突
|
||||
int hash = hash(key.hashCode());
|
||||
int j = (hash >>> segmentShift) & segmentMask;
|
||||
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
|
||||
(segments, (j << 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<K,V> node = tryLock() ? null :
|
||||
scanAndLockForPut(key, hash, value);
|
||||
V oldValue;
|
||||
try {
|
||||
HashEntry<K,V>[] tab = table;
|
||||
int index = (tab.length - 1) & hash;
|
||||
HashEntry<K,V> first = entryAt(tab, index);
|
||||
for (HashEntry<K,V> 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<K,V> implements Map.Entry<K,V> {
|
||||
final int hash;
|
||||
final K key;
|
||||
volatile V val;
|
||||
volatile Node<K,V> 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<K,V>[] tab = table;;) {
|
||||
Node<K,V> 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) & hash)) == null) {
|
||||
// 利用CAS去进行无锁线程安全操作,如果bin是空的
|
||||
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
|
||||
break;
|
||||
}
|
||||
else if ((fh = f.hash) == MOVED)
|
||||
tab = helpTransfer(tab, f);
|
||||
else if (onlyIfAbsent // 不加锁,进行检查
|
||||
&& fh == hash
|
||||
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
|
||||
&& (fv = f.val) != null)
|
||||
return fv;
|
||||
else {
|
||||
V oldVal = null;
|
||||
synchronized (f) {
|
||||
// 细粒度的同步修改操作...
|
||||
}
|
||||
}
|
||||
// Bin超过阈值,进行树化
|
||||
if (binCount != 0) {
|
||||
if (binCount >= 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<K,V>[] initTable() {
|
||||
Node<K,V>[] tab; int sc;
|
||||
while ((tab = table) == null || tab.length == 0) {
|
||||
// 如果发现冲突,进行spin等待
|
||||
if ((sc = sizeCtl) < 0)
|
||||
Thread.yield();
|
||||
// CAS成功返回true,则进入真正的初始化逻辑
|
||||
else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
|
||||
try {
|
||||
if ((tab = table) == null || tab.length == 0) {
|
||||
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
|
||||
@SuppressWarnings("unchecked")
|
||||
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
|
||||
table = tab = nt;
|
||||
sc = n - (n >>> 2);
|
||||
}
|
||||
} finally {
|
||||
sizeCtl = sc;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
当bin为空时,同样是没有必要锁定,也是以CAS操作去放置。
|
||||
|
||||
你有没有注意到,在同步逻辑上,它使用的是synchronized,而不是通常建议的ReentrantLock之类,这是为什么呢?现代JDK中,synchronized已经被不断优化,可以不再过分担心性能差异,另外,相比于ReentrantLock,它可以减少内存消耗,这是个非常大的优势。
|
||||
|
||||
与此同时,更多细节实现通过使用Unsafe进行了优化,例如tabAt就是直接利用getObjectAcquire,避免间接调用的开销。
|
||||
|
||||
```
|
||||
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
|
||||
return (Node<K,V>)U.getObjectAcquire(tab, ((long)i << 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 < 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这样的并发容器呢?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
@@ -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方式,也有很多人叫它AIO(Asynchronous IO)。异步IO操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。
|
||||
|
||||
## 考点分析
|
||||
|
||||
我上面列出的回答是基于一种常见分类方式,即所谓的BIO、NIO、NIO 2(AIO)。
|
||||
|
||||
在实际面试中,从传统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得以充分利用现代操作系统底层机制,获得特定场景的性能优化,例如,DMA(Direct 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上NIO2(AIO)模式则是依赖于[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("Hello world!"));
|
||||
|
||||
```
|
||||
|
||||
**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 -> 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("Hello world!");
|
||||
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<SelectionKey> selectedKeys = selector.selectedKeys();
|
||||
Iterator<SelectionKey> 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("Hello world!"));
|
||||
}
|
||||
}
|
||||
// 省略了与前面类似的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<>() { //为异步操作指定CompletionHandler回调函数
|
||||
@Override
|
||||
public void completed(AsynchronousSocketChannel sockChannel, AsynchronousServerSocketChannel serverSock) {
|
||||
serverSock.accept(serverSock, this);
|
||||
// 另外一个 write(sock,CompletionHandler{})
|
||||
sayHelloWorld(sockChannel, Charset.defaultCharset().encode
|
||||
("Hello World!"));
|
||||
}
|
||||
// 省略其他路径处理方法...
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
鉴于其编程要素(如Future、CompletionHandler等),我们还没有进行准备工作,为避免理解困难,我会在专栏后面相关概念补充后的再进行介绍,尤其是Reactor、Proactor模式等方面将在Netty主题一起分析,这里我先进行概念性的对比:
|
||||
|
||||
<li>
|
||||
基本抽象很相似,AsynchronousServerSocketChannel对应于上面例子中的ServerSocketChannel;AsynchronousSocketChannel则对应SocketChannel。
|
||||
</li>
|
||||
<li>
|
||||
业务逻辑的关键在于,通过指定CompletionHandler回调接口,在accept/read/write等关键节点,通过事件机制调用,这是非常不同的一种编程思路。
|
||||
</li>
|
||||
|
||||
今天我初步对Java提供的IO机制进行了介绍,概要地分析了传统同步IO和NIO的主要组成,并根据典型场景,通过不同的IO模式进行了实现与拆解。专栏下一讲,我还将继续分析Java IO的主题。
|
||||
|
||||
## 一课一练
|
||||
|
||||
关于今天我们讨论的题目你做到心中有数了吗?留一道思考题给你,NIO多路复用的局限性是什么呢?你遇到过相关的问题吗?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
308
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第12讲 | Java有几种文件拷贝方式?哪一种最高效?.md
Normal file
308
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第12讲 | Java有几种文件拷贝方式?哪一种最高效?.md
Normal 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)) > 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>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就是0,limit默认就是capacity的大小。
|
||||
</li>
|
||||
<li>
|
||||
当我们写入几个字节的数据时,position就会跟着水涨船高,但是它不可能超过limit的大小。
|
||||
</li>
|
||||
<li>
|
||||
如果我们想把前面写入的数据读出来,需要调用flip方法,将position设置为0,limit设置为以前的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提供了堆内和堆外(Direct)Buffer,我们可以以它的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 Tracking(NMT)特性来进行诊断,你可以在程序启动时加上下面参数:
|
||||
|
||||
```
|
||||
-XX:NativeMemoryTracking={summary|detail}
|
||||
|
||||
```
|
||||
|
||||
注意,激活NMT通常都会导致JVM出现5%~10%的性能下降,请谨慎考虑。
|
||||
|
||||
运行时,可以采用下面命令进行交互式对比:
|
||||
|
||||
```
|
||||
// 打印NMT信息
|
||||
jcmd <pid> VM.native_memory detail
|
||||
|
||||
// 进行baseline,以对比分配内存变化
|
||||
jcmd <pid> VM.native_memory baseline
|
||||
|
||||
// 进行baseline,以对比分配内存变化
|
||||
jcmd <pid> 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 Buffer,NMT输出的信息当然也远不止这些,我在专栏后面有综合分析更加具体的内存结构的主题。
|
||||
|
||||
今天我分析了Java IO/NIO底层文件操作数据的机制,以及如何实现零拷贝的高性能操作,梳理了Buffer的使用和类型,并针对Direct Buffer的生命周期管理和诊断进行了较详细的分析。
|
||||
|
||||
## 一课一练
|
||||
|
||||
关于今天我们讨论的题目你做到心中有数了吗?你可以思考下,如果我们需要在channel读取的过程中,将不同片段写入到相应的Buffer里面(类似二进制消息分拆成消息头、消息体等),可以采用NIO的什么机制做到呢?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
210
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第13讲 | 谈谈接口和抽象类有什么区别?.md
Normal file
210
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第13讲 | 谈谈接口和抽象类有什么区别?.md
Normal 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<E> extends AbstractList<E>
|
||||
implements List<E>, 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<E> extends Iterable<E> {
|
||||
/**
|
||||
* Returns a sequential Stream with this collection as its source
|
||||
* ...
|
||||
**/
|
||||
default Stream<E> stream() {
|
||||
return StreamSupport.stream(spliterator(), false);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**面向对象设计**
|
||||
|
||||
谈到面向对象,很多人就会想起设计模式,那些是非常经典的问题和设计方法的总结。我今天来夯实一下基础,先来聊聊面向对象设计的基本方面。
|
||||
|
||||
我们一定要清楚面向对象的基本要素:封装、继承、多态。
|
||||
|
||||
**封装**的目的是隐藏事务内部的实现细节,以便提高安全性和简化编程。封装提供了合理的边界,避免外部调用者接触到内部的细节。我们在日常开发中,因为无意间暴露了细节导致的难缠bug太多了,比如在多线程环境暴露内部状态,导致的并发修改问题。从另外一个角度看,封装这种隐藏,也提供了简化的界面,避免太多无意义的细节浪费调用者的精力。
|
||||
|
||||
**继承**是代码复用的基础机制,类似于我们对于马、白马、黑马的归纳总结。但要注意,继承可以看作是非常紧耦合的一种关系,父类代码修改,子类行为也会变动。在实践中,过度滥用继承,可能会起到反效果。
|
||||
|
||||
**多态**,你可能立即会想到重写(override)和重载(overload)、向上转型。简单说,重写是父子类中相同名字和参数的方法,不同的实现;重载则是相同名字的方法,但是不同的参数,本质上这些方法签名是不一样的,为了更好说明,请参考下面的样例代码:
|
||||
|
||||
```
|
||||
public int doSomething() {
|
||||
return 0;
|
||||
}
|
||||
// 输入参数不同,意味着方法签名不同,重载的体现
|
||||
public int doSomething(List<String> 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<String> list = new ArrayList<>();
|
||||
|
||||
```
|
||||
|
||||
如果使用var类型,可以简化为
|
||||
|
||||
```
|
||||
var list = new ArrayList<String>();
|
||||
|
||||
|
||||
```
|
||||
|
||||
但是,list实际会被推断为“ArrayList < String >”
|
||||
|
||||
```
|
||||
ArrayList<String> list = new ArrayList<String>();
|
||||
|
||||
```
|
||||
|
||||
理论上,这种语法上的便利,其实是增强了程序对实现的依赖,但是微小的类型泄漏却带来了书写的便利和代码可读性的提高,所以,实践中我们还是要按照得失利弊进行选择,而不是一味得遵循原则。
|
||||
|
||||
**OOP原则在面试题目中的分析**
|
||||
|
||||
我在以往面试中发现,即使是有多年编程经验的工程师,也还没有真正掌握面向对象设计的基本的原则,如开关原则(Open-Close)。看看下面这段代码,改编自朋友圈盛传的某伟大公司产品代码,你觉得可以利用面向对象设计原则如何改进?
|
||||
|
||||
```
|
||||
public class VIPCenter {
|
||||
void serviceVIP(T extend User user>) {
|
||||
if (user instanceof SlumDogVIP) {
|
||||
// 穷X VIP,活动抢的那种
|
||||
// do somthing
|
||||
} else if(user instanceof RealVIP) {
|
||||
// do somthing
|
||||
}
|
||||
// ...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码的一个问题是,业务逻辑集中在一起,当出现新的用户类型时,比如,大数据发现了我们是肥羊,需要去收获一下, 这就需要直接去修改服务方法代码实现,这可能会意外影响不相关的某个用户类型逻辑。
|
||||
|
||||
利用开关原则,我们可以尝试改造为下面的代码:
|
||||
|
||||
```
|
||||
public class VIPCenter {
|
||||
private Map<User.TYPE, ServiceProvider> 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语言在接口层面的演进和相应程序设计实现,最后回顾并实践了面向对象设计的基本原则,希望对你有所帮助。
|
||||
|
||||
## 一课一练
|
||||
|
||||
关于接口和抽象类的区别,你做到心中有数了吗?给你布置一个思考题,思考一下自己的产品代码,有没有什么地方违反了基本设计原则?那些一改就崩的代码,是否遵循了开关原则?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
213
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第14讲 | 谈谈你知道的设计模式?.md
Normal file
213
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第14讲 | 谈谈你知道的设计模式?.md
Normal 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,外观模式是解决什么问题?适用于什么场景?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
70
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第1讲 | 谈谈你对Java平台的理解?.md
Normal file
70
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第1讲 | 谈谈你对Java平台的理解?.md
Normal 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 Collection),Java通过垃圾收集器(Garbage Collector)回收分配内存,大部分情况下,程序员不需要自己操心内存的分配和回收。
|
||||
|
||||
我们日常会接触到JRE(Java Runtime Environment)或者JDK(Java Development Kit)。 JRE,也就是Java运行环境,包含了JVM和Java类库,以及一些模块等。而JDK可以看作是JRE的一个超集,提供了更多工具,比如编译器、各种诊断工具等。
|
||||
|
||||
对于“Java是解释执行”这句话,这个说法不太准确。我们开发的Java的源代码,首先通过Javac编译成为字节码(bytecode),然后,在运行时,通过 Java虚拟机(JVM)内嵌的解释器将字节码转换成为最终的机器码。但是常见的JVM,比如我们大多数情况使用的Oracle JDK提供的Hotspot JVM,都提供了JIT(Just-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 compiler,C1对应前面说的client模式,适用于对于启动速度敏感的应用,比如普通Java桌面应用;C2对应server模式,它的优化是为长时间运行的服务器端应用设计的。默认是采用所谓的分层编译(TieredCompilation)。这里不再展开更多JIT的细节,没必要一下子就钻进去,我会在后面介绍分层编译的内容。
|
||||
|
||||
Java虚拟机启动时,可以指定不同的参数对运行模式进行选择。 比如,指定“-Xint”,就是告诉JVM只进行解释执行,不对代码进行编译,这种模式抛弃了JIT可能带来的性能优势。毕竟解释器(interpreter)是逐条读入,逐条解释运行的。与其相对应的,还有一个“-Xcomp”参数,这是告诉JVM关闭解释器,不要进行解释执行,或者叫作最大优化级别。那你可能会问这种模式是不是最高效啊?简单说,还真未必。“-Xcomp”会导致JVM启动变慢非常多,同时有些JIT编译器优化方式,比如分支预测,如果不进行profiling,往往并不能进行有效优化。
|
||||
|
||||
除了我们日常最常见的Java使用模式,其实还有一种新的编译方式,即所谓的AOT(Ahead-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平台的理解。我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
168
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第2讲 | Exception和Error有什么区别?.md
Normal file
168
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第2讲 | Exception和Error有什么区别?.md
Normal 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 We’ve 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的堆栈,而不是业务方法调用关系。对于这种情况,你有什么好的办法吗?
|
||||
|
||||
请你在留言区分享一下你的解决方案,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
@@ -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<String> strList = new ArrayList<>();
|
||||
strList.add("Hello");
|
||||
strList.add("world");
|
||||
List<String> unmodifiableStrList = List.of("hello", "world");
|
||||
unmodifiableStrList.add("again");
|
||||
|
||||
```
|
||||
|
||||
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 && !(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 = <cleaner>;
|
||||
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机制仍然是有缺陷的,你有什么更好的建议吗?
|
||||
|
||||
请你在留言区写写你的建议,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
181
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第4讲 | 强引用、软引用、弱引用、幻象引用有什么区别?.md
Normal file
181
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第4讲 | 强引用、软引用、弱引用、幻象引用有什么区别?.md
Normal 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<>();
|
||||
PhantomReference<Object> p = new PhantomReference<>(counter, refQueue);
|
||||
counter = null;
|
||||
System.gc();
|
||||
try {
|
||||
// Remove是一个阻塞方法,可以指定timeout,或者选择一直阻塞
|
||||
Reference<Object> 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->10720K(141824K)] 128286K->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()
|
||||
|
||||
```
|
||||
|
||||
类似的书写结构,在异步编程中似乎是很普遍的,因为异步编程中往往不会用传统的“执行->返回->使用”的结构。
|
||||
|
||||
在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去保障对象不被意外回收,希望对你有所帮助。
|
||||
|
||||
## 一课一练
|
||||
|
||||
关于今天我们讨论的题目你做到心中有数了吗?给你留一道练习题,你能从自己的产品或者第三方类库中找到使用各种引用的案例吗?它们都试图解决什么问题?
|
||||
|
||||
请你在留言区写写你的答案,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享出去,或许你能帮到他。
|
||||
@@ -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底层都是利用可修改的(char,JDK 9以后是byte)数组,二者都继承了AbstractStringBuilder,里面包含了基本操作,区别仅在于最终的方法是否加了synchronized。
|
||||
|
||||
另外,这个内部数组应该创建成多大的呢?如果太小,拼接的时候可能要重新创建足够大的数组;如果太大,又会浪费空间。目前的实现是,构建时初始字符串长度加16(这意味着,如果没有构建对象时输入最初的字符串,那么初始值就是16)。我们如果确定拼接会发生非常多次,而且大概是可预计的,那么就可以指定合适的大小,避免很多次扩容的开销。扩容会产生多重开销,因为要抛弃原有数组,创建新的(可以简单认为是倍数)数组,还要进行arraycopy。
|
||||
|
||||
前面我讲的这些内容,在具体的代码书写中,应该如何选择呢?
|
||||
|
||||
在没有线程安全问题的情况下,全部拼接操作是应该都用StringBuilder实现吗?毕竟这样书写的代码,还是要多敲很多字的,可读性也不理想,下面的对比非常明显。
|
||||
|
||||
```
|
||||
String strByBuilder = new
|
||||
StringBuilder().append("aa").append("bb").append("cc").append
|
||||
("dd").toString();
|
||||
|
||||
String strByConcat = "aa" + "bb" + "cc" + "dd";
|
||||
|
||||
```
|
||||
|
||||
其实,在通常情况下,没有必要过于担心,要相信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."<init>":()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)等都是隐含着使用平台默认编码,这是一种好的实践吗?是否有利于避免乱码?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,或者分享一下你在操作字符串时掉过的坑,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
169
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第6讲 | 动态代理是基于什么原理?.md
Normal file
169
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第6讲 | 动态代理是基于什么原理?.md
Normal 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("Hello World");
|
||||
}
|
||||
}
|
||||
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("Invoking sayHello");
|
||||
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语言演进中正在发生的变化,并且进一步探讨了动态代理机制和相关的切面编程,分析了其解决的问题,并探讨了生产实践中的选择考量。
|
||||
|
||||
## 一课一练
|
||||
|
||||
关于今天我们讨论的题目你做到心中有数了吗?留一道思考题给你,你在工作中哪些场景使用到了动态代理?相应选择了什么实现技术?选择的依据是什么?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
196
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第7讲 | int和Integer有什么区别?.md
Normal file
196
极客时间专栏/Java核心技术面试精讲/模块一 Java基础/第7讲 | int和Integer有什么区别?.md
Normal 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 Types,boolean、byte 、short、char、int、float、double、long)之一。**Java语言虽然号称一切都是对象,但原始数据类型是例外。**
|
||||
|
||||
Integer是int对应的包装类,它有一个int类型的字段存储数据,并且提供了基本操作,比如数学运算、int和字符串之间转换等。在Java 5中,引入了自动装箱和自动拆箱功能(boxing/unboxing),Java可以根据上下文,自动进行转换,极大地简化了相关编程。
|
||||
|
||||
关于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<CompactCounter> updater = AtomicLongFieldUpdater.newUpdater(CompactCounter.class, "counter");
|
||||
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("java.lang.Integer.IntegerCache.high");
|
||||
...
|
||||
// range [-128, 127] must be interned (JLS7 5.1.7)
|
||||
assert IntegerCache.high >= 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对象的大小?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
|
||||
|
||||
@@ -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>
|
||||
Set,Set是不允许重复元素的,这是和List最明显的区别,也就是不存在两个对象equals返回true。我们在日常开发中有很多需要保证元素唯一性的场合。
|
||||
</li>
|
||||
<li>
|
||||
Queue/Deque,则是Java提供的标准队列结构的实现,除了集合的基本功能,它还支持类似先入先出(FIFO, First-in-First-Out)或者后入先出(LIFO,Last-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 <T> List<T> synchronizedList(List<T> 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<String> list = new ArrayList<>();
|
||||
list.add("Hello");
|
||||
list.add("World");
|
||||
|
||||
```
|
||||
|
||||
而利用新的容器静态工厂方法,一句代码就够了,并且保证了不可变性。
|
||||
|
||||
```
|
||||
List<String> simpleList = List.of("Hello","world");
|
||||
|
||||
```
|
||||
|
||||
更进一步,通过各种of静态工厂方法创建的实例,还应用了一些我们所谓的最佳实践,比如,它是不可变的,符合我们对线程安全的需求;它因为不需要考虑扩容,所以空间上更加紧凑等。
|
||||
|
||||
如果我们去看of方法的源码,你还会发现一个特别有意思的地方:我们知道Java已经支持所谓的可变参数(varargs),但是官方类库还是提供了一系列特定参数长度的方法,看起来似乎非常不优雅,为什么呢?这其实是为了最优的性能,JVM在处理变长参数的时候会有明显的额外开销,如果你需要实现性能敏感的API,也可以进行参考。
|
||||
|
||||
今天我从Verctor、ArrayList、LinkedList开始,逐步分析其设计实现区别、适合的应用场景等,并进一步对集合框架进行了简单的归纳,介绍了集合框架从基础算法到API设计实现的各种改进,希望能对你的日常开发和API设计能够有帮助。
|
||||
|
||||
## 一课一练
|
||||
|
||||
关于今天我们讨论的题目你做到心中有数了吗?留一道思考题给你,先思考一个应用场景,比如你需要实现一个云计算任务调度系统,希望可以保证VIP客户的任务被优先处理,你可以利用哪些数据结构或者标准的集合类型呢?更进一步讲,类似场景大多是基于什么数据结构呢?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
@@ -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之类操作都是O(log(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<String, String> accessOrderedMap = new LinkedHashMap<String, String>(16, 0.75F, true){
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) { // 实现自定义删除策略,否则行为就和普遍Map没有区别
|
||||
return size() > 3;
|
||||
}
|
||||
};
|
||||
accessOrderedMap.put("Project1", "Valhalla");
|
||||
accessOrderedMap.put("Project2", "Panama");
|
||||
accessOrderedMap.put("Project3", "Loom");
|
||||
accessOrderedMap.forEach( (k,v) -> {
|
||||
System.out.println(k +":" + v);
|
||||
});
|
||||
// 模拟访问
|
||||
accessOrderedMap.get("Project2");
|
||||
accessOrderedMap.get("Project2");
|
||||
accessOrderedMap.get("Project3");
|
||||
System.out.println("Iterate over should be not affected:");
|
||||
accessOrderedMap.forEach( (k,v) -> {
|
||||
System.out.println(k +":" + v);
|
||||
});
|
||||
// 触发删除
|
||||
accessOrderedMap.put("Project4", "Mission Control");
|
||||
System.out.println("Oldest entry should be removed:");
|
||||
accessOrderedMap.forEach( (k,v) -> {// 遍历顺序不变
|
||||
System.out.println(k +":" + v);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
- 对于TreeMap,它的整体顺序是由键的顺序关系决定的,通过Comparator或Comparable(自然顺序)来决定。
|
||||
|
||||
我在上一讲留给你的思考题提到了,构建一个具有优先级的调度系统的问题,其本质就是个典型的优先队列场景,Java标准库提供了基于二叉堆实现的PriorityQueue,它们都是依赖于同一种排序机制,当然也包括TreeMap的马甲TreeSet。
|
||||
|
||||
类似hashCode和equals的约定,为了避免模棱两可的情况,自然顺序同样需要符合一个约定,就是compareTo的返回值需要和equals一致,否则就会出现模棱两可情况。
|
||||
|
||||
我们可以分析TreeMap的put方法实现:
|
||||
|
||||
```
|
||||
public V put(K key, V value) {
|
||||
Entry<K,V> t = …
|
||||
cmp = k.compareTo(t.key);
|
||||
if (cmp < 0)
|
||||
t = t.left;
|
||||
else if (cmp > 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<K,V>[] 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<K,V>[] tab; Node<K,V> p; int , i;
|
||||
if ((tab = table) == null || (n = tab.length) = 0)
|
||||
n = (tab = resize()).length;
|
||||
if ((p = tab[i = (n - 1) & hash]) == ull)
|
||||
tab[i] = newNode(hash, key, value, nll);
|
||||
else {
|
||||
// ...
|
||||
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for first
|
||||
treeifyBin(tab, hash);
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
从putVal方法最初的几行,我们就可以发现几个有意思的地方:
|
||||
|
||||
<li>
|
||||
如果表格是null,resize方法会负责初始化它,这从tab = resize()可以看出。
|
||||
</li>
|
||||
<li>
|
||||
resize方法兼顾两个职责,创建初始存储表格,或者在容量不满足需求的时候,进行扩容(resize)。
|
||||
</li>
|
||||
<li>
|
||||
在放置新的键值对的过程中,如果发生下面条件,就会发生扩容。
|
||||
</li>
|
||||
|
||||
```
|
||||
if (++size > threshold)
|
||||
resize();
|
||||
|
||||
```
|
||||
|
||||
- 具体键值对在哈希表中的位置(数组index)取决于下面的位运算:
|
||||
|
||||
```
|
||||
i = (n - 1) & hash
|
||||
|
||||
```
|
||||
|
||||
仔细观察哈希值的源头,我们会发现,它并不是key本身的hashCode,而是来自于HashMap内部的另外一个hash方法。注意,为什么这里需要将高位数据移位到低位进行异或运算呢?**这是因为有些数据计算出的哈希值差异主要在高位,而HashMap里的哈希寻址是忽略容量以上的高位的,那么这种处理就可以有效避免类似情况下的哈希碰撞。**
|
||||
|
||||
```
|
||||
static final int hash(Object kye) {
|
||||
int h;
|
||||
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>>16;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- 我前面提到的链表结构(这里叫bin),会在达到一定门限值时,发生树化,我稍后会分析为什么HashMap需要对bin进行处理。
|
||||
|
||||
可以看到,putVal方法本身逻辑非常集中,从初始化、扩容到树化,全部都和它有关,推荐你阅读源码的时候,可以参考上面的主要逻辑。
|
||||
|
||||
我进一步分析一下身兼多职的resize方法,很多朋友都反馈经常被面试官追问它的源码设计。
|
||||
|
||||
```
|
||||
final Node<K,V>[] resize() {
|
||||
// ...
|
||||
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACIY &&
|
||||
oldCap >= DEFAULT_INITIAL_CAPAITY)
|
||||
newThr = oldThr << 1; // double there
|
||||
// ...
|
||||
else if (oldThr > 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 < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);
|
||||
}
|
||||
threshold = neThr;
|
||||
Node<K,V>[] newTab = (Node<K,V>[])new Node[newap];
|
||||
table = n;
|
||||
// 移动到新的数组结构e数组结构
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
依据resize源码,不考虑极端情况(容量理论最大极限由MAXIMUM_CAPACITY指定,数值为 1<<30,也就是2的30次方),我们可以归纳为:
|
||||
|
||||
<li>
|
||||
门限值等于(负载因子)x(容量),如果构建HashMap的时候没有指定它们,那么就是依据相应的默认常量值。
|
||||
</li>
|
||||
<li>
|
||||
门限通常是以倍数进行调整 (newThr = oldThr << 1),我前面提到,根据putVal中的逻辑,当元素个数超过门限大小时,则调整Map大小。
|
||||
</li>
|
||||
<li>
|
||||
扩容后,需要将老的数组中的元素重新放置到新的数组,这是扩容的一个主要开销来源。
|
||||
</li>
|
||||
|
||||
3.容量、负载因子和树化
|
||||
|
||||
前面我们快速梳理了一下HashMap从创建到放入键值对的相关逻辑,现在思考一下,为什么我们需要在乎容量和负载因子呢?
|
||||
|
||||
这是因为容量和负载系数决定了可用的桶的数量,空桶太多会浪费空间,如果使用的太满则会严重影响操作的性能。极端情况下,假设只有一个桶,那么它就退化成了链表,完全不能提供所谓常数时间存的性能。
|
||||
|
||||
既然容量和负载因子这么重要,我们在实践中应该如何选择呢?
|
||||
|
||||
如果能够知道HashMap要存取的键值对数量,可以考虑预先设置合适的容量大小。具体数值我们可以根据扩容发生的条件来做简单预估,根据前面的代码分析,我们知道它需要符合计算条件:
|
||||
|
||||
```
|
||||
负载因子 * 容量 > 元素数量
|
||||
|
||||
|
||||
```
|
||||
|
||||
所以,预先设置的容量需要满足,大于“预估元素数量/负载因子”,同时它是2的幂数,结论已经非常清晰了。
|
||||
|
||||
而对于负载因子,我建议:
|
||||
|
||||
<li>
|
||||
如果没有特别需求,不要轻易进行更改,因为JDK自身的默认负载因子是非常符合通用场景的需求的。
|
||||
</li>
|
||||
<li>
|
||||
如果确实需要调整,建议不要设置超过0.75的数值,因为会显著增加冲突,降低HashMap的性能。
|
||||
</li>
|
||||
<li>
|
||||
如果使用太小的负载因子,按照上面的公式,预设容量值也进行调整,否则可能会导致更加频繁的扩容,增加无谓的开销,本身访问性能也会受影响。
|
||||
</li>
|
||||
|
||||
我们前面提到了树化改造,对应逻辑主要在putVal和treeifyBin中。
|
||||
|
||||
```
|
||||
final void treeifyBin(Node<K,V>[] tab, int hash) {
|
||||
int n, index; Node<K,V> e;
|
||||
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
|
||||
resize();
|
||||
else if ((e = tab[index = (n - 1) & hash]) != null) {
|
||||
//树化改造逻辑
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
上面是精简过的treeifyBin示意,综合这两个方法,树化改造的逻辑就非常清晰了,可以理解为,当bin的数量大于TREEIFY_THRESHOLD时:
|
||||
|
||||
<li>
|
||||
如果容量小于MIN_TREEIFY_CAPACITY,只会进行简单的扩容。
|
||||
</li>
|
||||
<li>
|
||||
如果容量大于MIN_TREEIFY_CAPACITY ,则会进行树化改造。
|
||||
</li>
|
||||
|
||||
那么,为什么HashMap要树化呢?
|
||||
|
||||
**本质上这是个安全问题。**因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表,我们知道链表查询是线性的,会严重影响存取的性能。
|
||||
|
||||
而在现实世界,构造哈希冲突的数据并不是非常复杂的事情,恶意代码就可以利用这些数据大量与服务器端交互,导致服务器端CPU大量占用,这就构成了哈希碰撞拒绝服务攻击,国内一线互联网公司就发生过类似攻击事件。
|
||||
|
||||
今天我从Map相关的几种实现对比,对各种Map进行了分析,讲解了有序集合类型容易混淆的地方,并从源码级别分析了HashMap的基本结构,希望对你有所帮助。
|
||||
|
||||
## 一课一练
|
||||
|
||||
关于今天我们讨论的题目你做到心中有数了吗?留一道思考题给你,解决哈希冲突有哪些典型方法呢?
|
||||
|
||||
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||
|
||||
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||
Reference in New Issue
Block a user