杨柳亭

杨柳亭

Set


HashSet 集合

底层数据结构是哈希表(是一个元素为链表的数组) + 红黑树

要点

  • 实现 Set 接口
  • 不保证迭代顺序
  • 允许元素为 null
  • 底层实际上是一个 HashMap 实例
  • 非同步
  • 初始容量非常影响迭代性能

HashSet 实际上就是封装了 HashMap,操作 HashSet 元素实际上就是操作 HashMap。这也是面向对象的一种体现,重用性贼高!

详解见 HashMap

TreeSet 集合

底层数据结构是红黑树(是一个自平衡的二叉树)

保证元素的排序方式

要点

  • 实现NavigableSet接口
  • 可以实现排序功能
  • 底层实际上是一个TreeMap实例
  • 非同步

LinkedHashSet 集合

底层数据结构由哈希表(是一个元素为链表的数组)和双向链表组成。

List


List 集合的三个子类:

  • ArrayList :底层数据结构是数组。线程不安全
  • LinkedList :底层数据结构是链表。线程不安全
  • Vector:底层数据结构是数组。线程安全

ArrayList

简介

  • ArrayList 继承了 AbstractList,实现了 List。它是一个数组队列,提供了相关的添加、删除、修改、遍历等功能。
  • ArrayList 实现了 RandomAccess 接口, RandomAccess 是一个标志接口,表明实现这个这个接口的 List 集合是支持快速随机访问的。在 ArrayList 中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问。
  • ArrayList 实现了 Cloneable 接口,即覆盖了函数 clone(),能被克隆。
  • ArrayList 实现 java.io.Serializable 接口,这意味着 ArrayList 支持序列化,能通过序列化去传输。
  • 和 Vector 不同,ArrayList 中的操作不是线程安全的!所以,建议在单线程中才使用 ArrayList,而在多线程中可以选择 Vector 或者 CopyOnWriteArrayList。

System.arraycopy()和 Arrays.copyOf()方法

两者联系与区别

联系:

看两者源代码可以发现 copyOf()内部调用了 System.arraycopy()方法

区别:

arraycopy()需要目标数组,将原数组拷贝到你自己定义的数组里,而且可以选择拷贝的起点和长度以及放入新数组中的位置

copyOf()是系统自动在内部新建一个数组,并返回该数组。

注意的是:

  • java 中的 length 属性是针对数组说的,比如说你声明了一个数组,想知道这个数组的长度则用到了 length 这个属性.
  • java 中的 length()方法是针对字 符串 String 说的,如果想看这个字符串的长度则用到 length()这个方法.
  • java 中的 size()方法是针对泛型集合说的,如果想看这个泛型有多少个元素,就调用此方法来查看!

细节

  • ArrayList 是基于动态数组实现的,在增删时候,需要数组的拷贝复制。(navite 方法由 C/C++实现)
  • ArrayList 的默认初始化容量是 10,每次扩容时候增加原先容量的一半,也就是变为原来的 1.5 倍
  • 删除元素时不会减少容量,若希望减少容量则调用 trimToSize()
  • 它不是线程安全的。它能存放 null 值。

LinkedList

简介

底层实现是双向链表(双向链表方便实现往前遍历)
LinkedList 是一个实现了 List 接口和 Deque 接口的双端链表。 LinkedList 底层的链表结构使它支持高效的插入和删除操作,另外它实现了 Deque 接口,使得 LinkedList 类也具有队列的特性; LinkedList 不是线程安全的,如果想使 LinkedList 变成线程安全的,可以调用静态类 Collections 类中的 synchronizedList 方法:

Vector:

  • 底层是数组,现在已少用,被 ArrayList 替代,原因有两个:
  • Vector 所有方法都是同步,有性能损失。
  • Vector 初始 length 是 10 超过 length 时 以 100%比率增长,相比于 ArrayList 更多消耗内存。
  • ArrayList 在底层数组不够用时在原来的基础上扩展 0.5 倍,Vector 是扩展 1 倍。

总的来说:查询多用 ArrayList,增删多用 LinkedList。

ArrayList 增删慢不是绝对的(在数量大的情况下,已测试):

如果增加元素一直是使用 add()(增加到末尾)的话,那是 ArrayList 要快

一直删除末尾的元素也是 ArrayList 要快【不用复制移动位置】

至于如果删除的是中间的位置的话,还是 ArrayList 要快!

SkipList

CopyOnWriteArrayList

适用场景

  • 读操作可以尽可能的快,而写即使慢一些也没关系

在很多应用场景中,读操作可能会远远多于写操作。比如,有些系统级别的信息,往往只需要加载或者修改很少的次数,但是会被系统内所有模块频繁的访问。对于这种场景,我们最希望看到的就是读操作可以尽可能的快,而写即使慢一些也没关系。

  • 读多写少

黑名单是最典型的场景,假如我们有一个搜索网站,用户在这个网站的搜索框中,输入关键字搜索内容,但是某些关键字不允许被搜索。这些不能被搜索的关键字会被放在一个黑名单中,黑名单并不需要实时更新,可能每天晚上更新一次就可以了。当用户搜索时,会检查当前关键字在不在黑名单中,如果在,则提示不能搜索。这种读多写少的场景也很适合使用 CopyOnWrite 集合。

读写规则

  • 读写锁的规则

读写锁的思想是:读读共享、其他都互斥(写写互斥、读写互斥、写读互斥),原因是由于读操作不会修改原有的数据,因此并发读并不会有安全问题;而写操作是危险的,所以当写操作发生时,不允许有读操作加入,也不允许第二个写线程加入。

  • 对读写锁规则的升级

CopyOnWriteArrayList 的思想比读写锁的思想又更进一步。为了将读取的性能发挥到极致,CopyOnWriteArrayList 读取是完全不用加锁的,更厉害的是,写入也不会阻塞读取操作,也就是说你可以在写入的同时进行读取,只有写入和写入之间需要进行同步,也就是不允许多个写入同时发生,但是在写入发生时允许读取同时发生。这样一来,读操作的性能就会大幅度提升。

特点

  • CopyOnWrite的含义

从 CopyOnWriteArrayList 的名字就能看出它是满足 CopyOnWrite 的 ArrayList,CopyOnWrite 的意思是说,当容器需要被修改的时候,不直接修改当前容器,而是先将当前容器进行 Copy,复制出一个新的容器,然后修改新的容器,完成修改之后,再将原容器的引用指向新的容器。这样就完成了整个修改过程。

这样做的好处是,CopyOnWriteArrayList 利用了“不变性”原理,因为容器每次修改都是创建新副本,所以对于旧容器来说,其实是不可变的,也是线程安全的,无需进一步的同步操作。我们可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素,也不会有修改。

CopyOnWriteArrayList 的所有修改操作(add,set等)都是通过创建底层数组的新副本来实现的,所以 CopyOnWrite 容器也是一种读写分离的思想体现,读和写使用不同的容器。

  • 迭代期间允许修改集合内容

ArrayList 在迭代期间如果修改集合的内容,会抛出 ConcurrentModificationException 异常。CopyOnWriteArrayList 的迭代器在迭代的时候,如果数组内容被修改了,CopyOnWriteArrayList 不会报 ConcurrentModificationException 的异常,因为迭代器使用的依然是旧数组,只不过迭代的内容可能已经过时了

缺点

这些缺点不仅是针对 CopyOnWriteArrayList,其实同样也适用于其他的 CopyOnWrite 容器:

  • 内存占用问题

因为 CopyOnWrite 的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,这一点会占用额外的内存空间。

  • 在元素较多或者复杂的情况下,复制的开销很大

复制过程不仅会占用双倍内存,还需要消耗 CPU 等资源,会降低整体性能。

  • 数据一致性问题

由于 CopyOnWrite 容器的修改是先修改副本,所以这次修改对于其他线程来说,并不是实时能看到的,只有在修改完之后才能体现出来。如果你希望写入的的数据马上能被其他线程看到,CopyOnWrite 容器是不适用的。

内部使用ReentrantLock 锁,用来保证修改操作的线程安全

Map

HashMap

JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。

JDK1.8 之前

“拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

JDK1.8 之后

当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。

ConcurrentHashMap

1.8及之后

  1. 用table保存数据,锁的粒度更小,减少并发冲突的概率。采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率,并发控制使用Synchronized和CAS来操作。
  2. 存储数据时采用了数组+ 链表+红黑树的形式。
  1. CurrentHashMap重要参数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private static final int MAXIMUM_CAPACITY = 1 << 30; // 数组的最大值 

private static final int DEFAULT_CAPACITY = 16; // 默认数组长度

static final int TREEIFY_THRESHOLD = 8; // 链表转红黑树的一个条件

static final int UNTREEIFY_THRESHOLD = 6; // 红黑树转链表的一个条件

static final int MIN_TREEIFY_CAPACITY = 64; // 链表转红黑树的另一个条件

static final int MOVED = -1; // 表示正在扩容转移

static final int TREEBIN = -2; // 表示已经转换成树

static final int RESERVED = -3; // hash for transient reservations

static final int HASH_BITS = 0x7fffffff; // 获得hash值的辅助参数

transient volatile Node<K,V>[] table;// 默认没初始化的数组,用来保存元素

private transient volatile Node<K,V>[] nextTable; // 转移的时候用的数组

static final int NCPU = Runtime.getRuntime().availableProcessors();// 获取可用的CPU个数

private transient volatile Node<K,V>[] nextTable; // 连接表,用于哈希表扩容,扩容完成后会被重置为 null

private transient volatile long baseCount; //保存着整个哈希表中存储的所有的结点的个数总和,有点类似于 HashMap 的 size 属性。
private transient volatile int sizeCtl;
//sizeCtl = 0:表示没有指定初始容量
//sizeCtl > 0:表示初始容量(可以使用阶段)
//sizeCtl = -1,标记作用,告知其他线程,正在初始化
//sizeCtl = 0.75n ,扩容阈值
//sizeCtl < 0 : 表示有其他线程正在执行扩容or初始化(不能使用阶段)
//sizeCtl = (resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2 :表示此时只有一个线程在执行扩容
  1. 扩容机制
  • nextTable: 扩容期间,将元素迁移到 nextTable 新Map, nextTable是共享变量。
  • sizeCtl: 多线程之间,sizeCtl来判断ConcurrentHashMap当前所处的状态。
    • 通过CAS设置sizeCtl属性,告知其他线程ConcurrentHashMap的状态变更。
  • transferIndex: 扩容索引,表示已经完成数据分配的table数组索引位置。
    • 数据转移已经到了哪个位置? 其他线程根据这个值帮助扩容从这个索引位置继续转移数据
  • ForwardingNode节点: 标记作用,表示此节点已经扩容完毕,hash值等于-1
    • 数组位置的数据已经被转移到新Map中,此位置就会被设置为这个属性
    • 这个属性包装了新Map,可以用find方法取扩容转移后的值

扩容流程:

  • 线程执行put操作,发现容量已经达到扩容阈值,需要进行扩容操作
  • 扩容线程A 以CAS机制修改transferindex值,然后按照降序迁移数据,transferindex是数组尾部的索引
    • transferindex的初始值: 新数组的长度 - 1 -> 就是数组的最后一位
  • 迁移hash桶时,会将桶内的链表或者红黑树,按照一定算法,拆分成2份,将其插入nextTable[i]nextTable[i+n](n是之前table数组的长度)。
    • 扩容后重新计算的hash值与之前hash值一样,则存放位置不变
    • 重新计算的hash值与之前hash值不一样,则存放再索引i +n处(之前的数组长度 + 计算的索引)
  • 迁移完毕的hash桶,都会被设置成ForwardingNode节点,以此告知访问此桶的其他线程,此节点已经迁移完毕,但数据并没有全部迁移完成。
  • 此时线程2访问到了ForwardingNode节点,如果线程2执行的put或remove等写操作,那么就会先帮其扩容。
    • 如果线程2执行的是get等读方法,则会调用ForwardingNode的find方法,去nextTable里面查找相关元素。
  1. put

put操作采用 CAS + synchronized 实现并发插入或更新操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// Node 节点的 hash值在HashMap中存储的就是hash值,在currenthashmap中可能有多种情况哦!
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());// 最终hash值计算
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) {
//// 如果对应桶位置为空
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // CAS 原子性的尝试插入
}
else if ((fh = f.hash) == MOVED)
//f.hash == MOVED 表示为:ForwardingNode,说明其他线程正在扩容
//并且这个数组的位置数据已经转移到新的数组结构中
//但是数据还没有全部转移完成,帮助线程先扩容
// 如果当前节点正在扩容。还要帮着去扩容
tab = helpTransfer(tab, f);
else if (onlyIfAbsent //jdk 检查第一个节点 是否已存在key 存在返回对应value值 check first node without acquiring lock
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
else {
V oldVal = null;//桶存在数据 加锁操作进行处理
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {// 如果存储的是链表 存储的是节点的hash值
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 遍历链表去查找,如果找到key 选择性覆盖
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {// 找到尾部插入
pred.next = new Node<K,V>(hash, key, value);
break;
}
}
}
else if (f instanceof TreeBin) {// 如果桶节点类型为TreeBin
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
// 尝试红黑树插入,同时也要防止节点本来就有,选择性覆盖
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
if (binCount != 0) {// 如果链表数量
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);// 链表转红黑树哦!
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount); // 统计大小 并且检查是否要扩容。
return null;
}

涉及到重要函数initTable、tabAt、casTabAt、helpTransfer、putTreeVal、treeifyBin、addCount函数。

  1. initTable

「只允许一个线程」 对表进行初始化,如果不巧有其他线程进来了,那么会让其他线程交出 CPU 等待下次系统调度Thread.yield。这样,保证了表同时只会被一个线程初始化,对于table的大小,会根据sizeCtl的值进行设置,如果没有设置szieCtl的值,那么默认生成的table大小为16,否则,会根据sizeCtl的大小设置table大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 容器初始化 操作
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) null || tab.length 0) {
if ((sc = sizeCtl) < 0) // 如果正在初始化-1,-N 正在扩容。
Thread.yield(); // 进行线程让步等待
// 让掉当前线程 CPU 的时间片,使正在运行中的线程重新变成就绪状态,并重新竞争 CPU 的调度权。
// 它可能会获取到,也有可能被其他线程获取到。
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
// 比较sizeCtl的值与sc是否相等,相等则用 -1 替换,这表明我这个线程在进行初始化了!
try {
if ((tab = table) null || tab.length 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY; // 默认为16
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc; //设置sizeCtl 类似threshold
}
break;
}
}
return tab;
}

Collection

Collection

  • Collection 接口的接口 对象的集合
    • List 子接口 按进入先后有序保存 可重复
      • LinkedList(双向链表) 接口实现类 链表 插入删除 没有同步 线程不安全
      • ArrayList(Object 数组) 接口实现类 数组 随机访问 没有同步 线程不安全
      • Vector(Object 数组) 接口实现类 数组 同步 线程安全
    • Set 子接口 不可重复
      • HashSet(无序,唯一)
      • LinkedHashSet(有序,唯一)
        线程不安全
      • TreeSet(有序,唯一)
  • Map 接口 键值对的集合
    • Hashtable(链表+数组) 接口实现类 同步 线程安全
    • HashMap(1.8 之前数组+链表,1.8 之后达到一定长度会转化为红黑树) 接口实现类 没有同步 线程不安全
      • LinkedHashMap(在 HashMap 基础上添加一条双向链表)
    • TreeMap(红黑树(自平衡排序二叉树))

Iterable

Iterator 也是一个接口,它只有三个方法:

  • hasNext()
  • next()
  • remove()

ArrayList 与 LinkedList 区别

  • 都不保证线程安全
  • ArrayList 底层是数组,LinkedList 底层使用双向链表(1.6 之前为循环链表,1.7 之后取消循环)
  • ArrayList 采用数组存储,删除和插入的时间复杂度会受元素位置影响。LinkedList 采用链式存储,对于插入删除不受元素位置影响
  • LinkedList 不支持随机访问,ArrayList 支持元素序号访问
  • ArrayList 会在列表结尾预留一定空余空间,LinkedList 每个节点所消耗的空间比 ArrayList 大

HashMap 和 Hashtable 区别

  • HashMap 是非线程安全的,HashTable 是线程安全的 (如果你要保证线程安全的话就使用 ConcurrentHashMap 吧,效率比 hashtable 高);
  • HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛出 NullPointerException。
  • 初始化
    • ① 创建时如果不指定容量初始值: Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。
    • ② 创建时如果给定了容量初始值:Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小
  • JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。

ConcurrentHashMap 和 Hashtable 的区别

ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。

  • 底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
  • 实现线程安全的方式(重要):
    • ① 在 JDK1.7 的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 对 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
    • ② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

HashTable:全表锁

JDK7 的 ConcurrentHashMap:

首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。

一个 ConcurrentHashMap 里包含一个 Segment(片段) 数组。Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。

https://imgchr.com/i/t2J5Kx

JDK1.8 的 ConcurrentHashMap(TreeBin: 红黑二叉树节点 Node: 链表节点)`

1.8之前ConcurrentHashMap将hashmap数组分段,对于每个段加锁。而在1.8后几乎与hashmap类似,只是更改了put操作,在计算hash值和索引位置后会先判断数组是否正在扩容。若正在扩容则调用扩容辅助函数。没有则在判断是否为空,为空则采用csa方法放入数组,若不为空则使用synchronized锁住该节点,根据链表或红黑树选择插入方法。最后判断长度,为8则开始转换红黑树。synchronized ​只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。

HashMap 和 HashSet 区别

HashSet 底层就是基于 HashMap 实现的

HashMap HashSet
实现了 Map 接口 实现 Set 接口
存储键值对 仅存储对象
调用 put()向 map 中添加元素 调用 add()方法向 Set 中添加元素
HashMap 使用键(Key)计算 Hashcode HashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相

66. 明智审慎地本地方法

66. 明智审慎地本地方法

Java 本地接口(JNI)允许 Java 程序调用本地方法,这些方法是用 C 或 C++ 等本地编程语言编写的。从历史上看,本地方法主要有三种用途。它们提供对特定于平台的设施(如注册中心)的访问。它们提供对现有本地代码库的访问,包括提供对遗留数据访问。最后,本地方法可以通过本地语言编写应用程序中注重性能的部分,以提高性能。

使用本地方法访问特定于平台的机制是合法的,但是很少有必要:随着 Java 平台的成熟,它提供了对许多以前只能在宿主平台中上找到的特性。例如,Java 9 中添加的流 API 提供了对 OS 流程的访问。在 Java 中没有等效库时,使用本地方法来使用本地库也是合法的。

使用本地方法来提高性能的行为很少是明智的。 在早期版本(Java 3 之前),这通常是必要的,但是从那时起 JVM 变得更快了。对于大多数任务,现在可以在 Java 中获得类似的性能。例如,在版本 1.1 中添加了 java.math,BigInteger 是在一个用 C 编写的快速多精度运算库的基础上实现的。在当时,为了获得足够的性能这样做是必要的。在 Java 3 中,BigInteger 则完全用 Java 重写了,并且进行了性能调优,新的版本比原来的版本更快。

这个故事的一个可悲的结尾是,除了在 Java 8 中对大数进行更快的乘法运算之外,BigInteger 此后几乎没有发生什么变化。在此期间,对本地库的工作继续快速进行,尤其是 GNU 多精度算术库(GMP)。需要真正高性能多精度算法的 Java 程序员现在可以通过本地方法使用 GMP [Blum14]。

使用本地方法有严重的缺点。由于本地语言不安全(详见第 50 条),使用本地方法的应用程序不再能免受内存毁坏错误的影响。由于本地语言比 Java 更依赖于平台,因此使用本地方法的程序的可移植性较差。它们也更难调试。如果不小心,本地方法可能会降低性能,因为垃圾收集器无法自动跟踪本地内存使用情况(详见第 8 条),而且进出本地代码会产生相关的成本。最后,本地方法需要「粘合代码」,这很难阅读,而且编写起来很乏味。

总之,在使用本地方法之前要三思。一般很少需要使用它们来提高性能。如果必须使用本地方法来访问底层资源或本地库,请尽可能少地使用本地代码,并对其进行彻底的测试。本地代码中的一个错误就可以破坏整个应用程序。

77. 不要忽略异常

77. 不要忽略异常

尽管这条建议看上去是显而易见的,但是它却常常被违反,因而值得再次提出来。当 API 的设计者声明一个方法将抛出某个异常的时候,他们等于正在试图说明某些事情。所以,请不要忽略它!要忽略一个异常非常容易,只需将方法调用通过 try 语句包围起来,并包含一个空的 catch 块:

1
2
3
4
5
// Empty catch block ignores exception - Highly suspect!
try {
...
} catch ( SomeException e ) {
}

空的 catch 块会使异常达不到应有的目的, 即强迫你处理异常的情况。忽略异常就如同忽略火警信号一样——如果把火警信号器关掉了,当真正有火灾发生时,就没有人能看到火警信号了。或许你会侥幸逃过劫难,或许结果将是灾难性的。每当见到空的 catch 块时,应该让警钟长鸣。

有些情形可以忽略异常。比如,关闭 FileinputStream 的时候。因为你还没有改变文件的状态,因此不必执行任何恢复动作,并且已经从文件中读取到所需要的信息,因此不必终止正在进行的操作。即使在这种情况下,把异常记录下来还是明智的做法,因为如果这些异常经常发生,你就可以调查异常的原因。 如果选择忽略异常, catch 块中应该包含一条注释,说明为什么可以这么做,并且变量应该命名为 ignored:

1
2
3
4
5
6
7
Future<Integer> f = exec.submit(planarMap::chromaticNumber);
int numColors = 4; // Default: guaranteed sufficient for any map
try {
numColors = f.get( 1L, TimeUnit.SECONDS );
} catch ( TimeoutException | ExecutionException ignored ) {
// Use default: minimal coloring is desirable, not required
}

本条目中的建议同样适用于受检异常和未受检异常。不管异常代表了可预见的异常条件,还是编程错误,用空的 catch 块忽略它,都将导致程序在遇到错误的情况下悄然地执行下去。然后,有可能在将来的某个点上,当程序不能再容忍与错误源明显相关的问题时,它就会失败。正确地处理异常能够彻底避免失败。只要将异常传播给外界,至少会导致程序迅速失败,从而保留了有助于调试该失败条件的信息。

04. 使用私有构造方法执行非实例化

4. 使用私有构造器执行非实例化

偶尔你会想写一个只包含静态方法和静态字段的类。 这些类的名声非常不好,因为有些人滥用这些类从而避免以面向对象方式思考从而编写过程化的程序,但是它们确实有着特殊的用途。 它们可以用来按照 java.lang.Mathjava.util.Arrays 的方式,把基本类型的值或数组类型上的相关方法组织起来。我们也可以通过 java.util.Collections 的方式,把实现特定接口上面的静态方法进行分组,也包括工厂方法(详见第 1 条)。 (从 Java 8 开始,你也可以将这些方法放在接口中,假定该接口是你编写的并可以进行修改。)最后,这样的类可以用于在 final 类上对方法进行分组,因为不能将它们放在子类中。

这样的工具类(utility classes)不是设计用来被实例化的,因为实例化对它没有任何意义。然而,在没有显式构造器的情况下,编译器提供了一个公共的、无参的默认构造器。对于用户来说,该构造器与其他构造器没有什么区别。在已发布的 API 中经常看到无意识的被实例的类。

试图通过创建抽象类来强制执行非实例化是行不通的。 该类可以被子类化,并且子类可以被实例化。此外,它误导用户认为该类是为继承而设计的(详见第 19 条)。不过,有一个简单的方法来确保非实例化。只有当类不包含显式构造器时,才会生成一个默认构造器,因此可以通过包含一个私有构造器来实现类的非实例化:

1
2
3
4
5
6
7
8
// Noninstantiable utility class
public class UtilityClass {
// Suppress default constructor for noninstantiability
private UtilityClass() {
throw new AssertionError();
}
... // Remainder omitted
}

因为显式构造器是私有的,所以不可以在类的外部访问它。AssertionError 异常不是严格要求的,但是它可以避免不小心在类的内部调用构造器。它保证类在任何情况下都不会被实例化。这个习惯用法有点违反直觉,好像构造器就是设计成不能调用的一样。因此,如前面所示,添加注释是种明智的做法。

这种习惯有一个副作用,就是使得一个类不能子类化。所有的构造器都必须显式或隐式地调用父类构造器,而在这种情况下子类则没有可访问的父类构造器来调用。

63. 当心字符串连接引起的性能问题

63. 当心字符串连接引起的性能问题

字符串连接操作符 (+) 是将几个字符串组合成一个字符串的简便方法。对于生成单行输出或构造一个小的、固定大小的对象的字符串表示形式,它是可以的,但是它不能伸缩。使用 字符串串联运算符重复串联 n 个字符串需要 n 的平方级时间。 这是字符串不可变这一事实导致的结果(详见第 17 条)。当连接两个字符串时,将复制这两个字符串的内容。

例如,考虑这个方法,它通过将每个账单项目重复连接到一行来构造账单语句的字符串表示:

1
2
3
4
5
6
7
// Inappropriate use of string concatenation - Performs poorly!
public String statement() {
String result = "";
for (int i = 0; i < numItems(); i++)
result += lineForItem(i); // String concatenation
return result;
}

如果项的数量很大,则该方法的性能非常糟糕。要获得能接受的性能,请使用 StringBuilder 代替 String 来存储正在构建的语句:

1
2
3
4
5
6
public String statement() {
StringBuilder b = new StringBuilder(numItems() * LINE_WIDTH);
for (int i = 0; i < numItems(); i++)
b.append(lineForItem(i));
return b.toString();
}

自 Java 6 以来,为了使字符串连接更快,已经做了大量工作,但是这两个方法在性能上的差异仍然很大:如果 numItems 返回 100,lineForItem 返回 80 个字符串,那么第二个方法在我的机器上运行的速度是第一个方法的 6.5 倍。由于第一种方法在项目数量上是平方级的,而第二种方法是线性的,所以随着项目数量的增加,性能差异会变得越来越大。注意,第二个方法预先分配了一个足够大的 StringBuilder 来保存整个结果,从而消除了自动增长的需要。即使使用默认大小的 StringBuilder,它仍然比第一个方法快 5.5 倍。

道理很简单:不要使用字符串连接操作符合并多个字符串,除非性能无关紧要。否则使用 StringBuilder 的 append 方法。或者,使用字符数组,再或者一次只处理一个字符串,而不是组合它们。

35. 使用实例属性替代序数

35. 使用实例属性替代序数

许多枚举通常与单个 int 值关联。所有枚举都有一个 ordinal 方法,它返回每个枚举常量类型的数值位置。你可能想从序数中派生一个关联的 int 值:

1
2
3
4
5
6
7
// Abuse of ordinal to derive an associated value - DON'T DO THIS
public enum Ensemble {
SOLO, DUET, TRIO, QUARTET, QUINTET,
SEXTET, SEPTET, OCTET, NONET, DECTET;

public int numberOfMusicians() { return ordinal() + 1; }
}

虽然这个枚举能正常工作,但对于维护来说则是一场噩梦。如果常量被重新排序,numberOfMusicians 方法将会中断。 如果你想添加一个与你已经使用的 int 值相关的第二个枚举常量,则没有那么好运了。 例如,为双四重奏(double quartet)添加一个常量可能会很好,它就像八重奏一样,由 8 位演奏家组成,但是没有办法做到这一点。

此外,如果没有给所有中间的 int 值添加常量,就不能为这个 int 值添加一个常量。例如,假设你想要添加一个常量,表示一个由 12 位演奏家组成的三重四重奏(triple quartet)。对于由 11 个演奏家组成的合奏曲,并没有标准的术语,因此你不得不为未使用的 int 值(11)添加一个虚拟常量(dummy constant)。最多看起来就是有些不好看。如果许多 int 值是未使用的,则是不切实际的。

幸运的是,这些问题有一个简单的解决方案。 永远不要从枚举的序号中得出与它相关的值; 请将其保存在实例属性中:

1
2
3
4
5
6
7
8
9
10
public enum Ensemble {
SOLO(1), DUET(2), TRIO(3), QUARTET(4), QUINTET(5),
SEXTET(6), SEPTET(7), OCTET(8), DOUBLE_QUARTET(8),
NONET(9), DECTET(10), TRIPLE_QUARTET(12);

private final int numberOfMusicians;

Ensemble(int size) { this.numberOfMusicians = size; }
public int numberOfMusicians() { return numberOfMusicians; }
}

枚举规范对此 ordinal 方法说道:“大多数程序员对这种方法没有用处。 它被设计用于基于枚举的通用数据结构,如 EnumSetEnumMap。“除非你在编写这样数据结构的代码,否则最好避免使用 ordinal 方法。

74. 每个方法抛出的异常都需要创建文档

74. 每个方法抛出的异常都需要创建文档

描述一个方法所抛出的异常,是正确使用这个方法时所需文档的重要组成部分。因此,花点时间仔细地为每个方法抛出的异常建立文档是特别重要的。

始终要单独地声明受检异常, 并且利用 Javadoc 的 @throws 标签, 准确地记录下抛出每个异常的条件。 如果一个公有方法可能抛出多个异常类,则不要使用“快捷方式”声明它会抛出这些异常类的某个超类。永远不要声明一个公有方法直接「throws Exception」,或者更糟糕的是声明它直接「throws Throwable」,这是非常极端的例子。这样的声明不仅没有为程序员提供关于“这个方法能够抛出哪些异常”的任何指导信息,而且大大地妨碍了该方法的使用,因为它实际上掩盖了该方法在同样的执行环境下可能抛出的任何其他异常。这条建议有一个例外,就是 main 方法它可以被安全地声明抛出 Exception ,因为它只通过虚拟机调用。

虽然 Java 语言本身并没有要求程序员为一个方法声明它可能会抛出的未受检异常,但是,如同受检异常一样,仔细地为它们建立文档是非常明智的。未受检异常通常代表编程上的错误(详见第 70 条),让程序员了解所有这些错误都有助于帮助他们避免犯同样的错误。对于方法可能抛出的未受检异常,如果将这些异常信息很好地组织成列表文档,就可以有效地描述出这个方法被成功执行的前提条件。每个方法的文档应该描述它的前提条件(详见第 56 条),这是很重要的,在文档中记录下未受检异常是满足前提条件的最佳做法。

对于接口中的方法,在文档中记录下它可能抛出的未受检异常显得尤为重要。这份文档构成了该接口的通用约定(general contract)的一部分,它指定了该接口的多个实现必须遵循的公共行为。

使用 Javadoc 的 ​​**@throws​ 标签记录下一个方法可能抛出的每个未受检异常,但是不要使用 throws 关键字将未受检的异常包含在方法的声明中。** 使用 API 的程序员必须知道哪些异常是需要受检的,哪些是不需要受检的,因为他们有责任区分这两种情形。当缺少由 throws 声明产生的方法标头时,由 Javadoc 的 @throws 标签所产生的文档就会提供明显的提示信息,以帮助程序员区分受检异常和未受检异常。

应该注意的是,为每个方法可能抛出的所有未受检异常建立文档是很理想的,但是在实践中并非总能做到这一点。当类被修订之后,如果有个导出方法被修改了,它将会抛出额外的未受检异常,这不算违反源代码或者二进制兼容性。假设一个类调用了另一个独立编写的类中的方法。第一个类的编写者可能会为每个方法抛出的未受检异常仔细地建立文档,但是,如果第二个类被修订了,抛出了额外的未受检异常,很有可能第一个类(它并没有被修订)就会把新的未受检异常传播出去,尽管它并没有声明这些异常。

如果一个类中的许多方法出于同样的原因而抛出同一个异常,在该类的文档注释中对这个异常建立文档,这是可以接受的, 而不是为每个方法单独建立文档。一个常见的例子是 NullPointerException。 若类的文档注释中有这样的描述:「All methods in this class throw a NullPointerException if a null object reference is passed in any parameter 」(如果 null 对象引用被传递到任何一个参数中,这个类中的所有方法都会抛出 NullPointerException),或者有其他类似的语句,这是可以的。

总而言之,要为你编写的每个方法所能抛出的每个异常建立文档。对于未受检异常和受检异常,以及抽象的方法和具体的方法一概如此。这个文档在文档注释中应当采用 @throws 标签的形式。要在方法的 throws 子句中为每个受检异常提供单独的声明,但是不要声明未受检的异常。如果没有为可以抛出的异常建立文档,其他人就很难或者根本不可能有效地使用你的类和接口。

0%