摘要:
本文对面试过程中经常会被问到的一些关于Java基础问题进行了梳理和总结,包括 JVM虚拟机、常用容器、设计原则与模式以及Java语言特性等基础知识点,一方面方便自己温故知新,另一方面也希望为找工作的同学们提供一个复习参考。考虑到篇幅太长,现将 《面试/笔试第五弹 —— Java面试问题集锦》 一文分为上下两篇:《面试/笔试第五弹 —— Java面试问题集锦(上篇)》和《面试/笔试第五弹 —— Java面试问题集锦(下篇)》。
版权声明:
本文原创作者:书呆子Rico
作者博客地址:http://blog.csdn.net/justloveyou_/
24、String,StringBuilder 以及 StringBuffer
关于这三个字符串类的异同之处主要关注可变不可变、安全不安全两个方面:
StringBuffer(同步的)和String(不可变的)都是线程安全的,StringBuilder是线程不安全的;
String是不可变的,StringBuilder和StringBuffer是不可变的;
String的连接操作的底层是由StringBuilder实现的;
三者都是final的,不允许被继承;
StringBuilder 以及 StringBuffer 都是抽象类AbstractStringBuilder的子类,它们的接口是相同的。
1). 不可变类String的原因
String主要的三个成员变量 char value[], int offset, int count均是private,final的,并且没有对应的 getter/setter;
String 对象一旦初始化完成,上述三个成员变量就不可修改;并且其所提供的接口任何对这些域的修改都将返回一个新对象;
关于Java字符串类String的更多介绍请见笔者的博文《Java String 综述(上篇)》和《Java String 综述(下篇)》。
25、重载,重写,隐藏
重载:类内多态,静态绑定机制(编译时已经知道具体执行哪个方法),方法同名,参数不同
重写:类间多态,动态绑定机制(运行时确定),实例方法,两小两同一大(方法签名相同,子类的方法所抛出的异常、返回值的范围不大于父类的对应方法,子类的方法可见性不小于父类的对应方法)方法签名相同,子类的方法所抛出的异常、返回值的范围不大于父类的对应方法,子类的方法可见性不小于父类的对应方法
隐藏:编译期绑定,静态方法和成员变量
关于重载、重写和隐藏等重要概念的详细解释请参考笔者的博文《Java 继承、多态与类的复用》。
26、抽象,封装,继承,多态
Java 的四大特性总结如下:
封装:把对象的属性和行为(数据)封装为一个独立的整体,并尽可能隐藏对象的内部实现细节;
继承:一种代码重用机制;
多态:分离了做什么和怎么做,从另一个角度将接口和实现分离开来,消除类型之间的耦合关系;表现形式:重载与重写;
抽象:对继承的另一种表述;表现形式:接口(契约)与抽象类(模板)。
27、ArrayList(动态数组)、LinkedList(带头结点的双向链表)
1). ArrayList
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
默认初始容量为 10;
扩容机制:添加元素前,先检查是否需要扩容,一般扩为源数组的 1.5 倍 + 1;
边界检查(即检查 ArrayList 的 Size):涉及到 index 的操作;
调整数组容量(减少容量):将底层数组的容量调整为当前列表保存的实际元素的大小;
在查找给定元素索引值等的方法中,源码都将该元素的值分为null和不为null两种情况处理;
2). LinkedList
LinkedList 不但实现了List接口,还实现了Dequeue接口。因此,LinkedList不但可以当做List来用,还可以当做Stack(push、pop、peek),Queue(offer、poll、peek)来使用。
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
private static class Entry<E> {
E element;
Entry next;
Entry previous;
Entry(E element, Entry next, Entry previous) {
this.element = element;
this.next = next;
this.previous = previous;
}
}
3). ArrayList与LinkedList比较
ArrayList是基于数组的实现,LinkedList是基于带头结点的双向循环链表的实现;
ArrayList支持随机访问,LinkedList不支持;
LinkedList可作队列和栈使用,实现了Dequeue接口,而ArrayList没有;
ArrayList 寻址效率较高,插入/删除效率较低;LinkedList插入/删除效率较高,寻址效率较低
4). Fast-fail机制
Fail-Fast 是 Java 集合的一种错误检测机制,为了防止在某个线程在对Collection进行迭代时,其他线程对该Collection进行结构上的修改。在面对并发的修改时,迭代器很快就会完全失败,而不是冒着在将来某个不确定时间发生任意不确定行为的风险,抛出 ConcurrentModificationException 异常。
private class Itr implements Iterator<E> {
int cursor;
int lastRet = -1;
int expectedModCount = ArrayList.this.modCount;
public boolean hasNext() {
return (this.cursor != ArrayList.this.size);
}
public E next() {
checkForComodification();
/** 省略此处代码 */
}
public void remove() {
if (this.lastRet < 0)
throw new IllegalStateException();
checkForComodification();
/** 省略此处代码 */
}
final void checkForComodification() {
if (ArrayList.this.modCount == this.expectedModCount)
return;
throw new ConcurrentModificationException();
}
}
更多关于 Java List 以及 Fast-fail机制的介绍请参见笔者的博文《Java Collection Framework : List》。
28、Set (HashSet,LinkedHashSet,TreeSet)
Set不包含重复的元素,这是Set最大的特点,也是使用Set最主要的原因。常用到的Set实现有 HashSet,LinkedHashSet 和 TreeSet。一般地,如果需要一个访问快速的Set,你应该使用HashSet;当你需要一个排序的Set,你应该使用TreeSet;当你需要记录下插入时的顺序时,你应该使用LinedHashSet。
HashSet:委托给HashMap进行实现,实现了Set接口
HashSet是采用hash表来实现的,其中的元素没有按顺序排列,add()、remove()以及contains()等方法都是复杂度为O(1)的方法。
public class HashSet<E>
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable {
static final long serialVersionUID = -5024744406713321676L;
// 底层支持,HashMap可以代表一个HashMap,也可以代表一个LinkedHashMap
private transient HashMap map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object(); // 傀儡对象
五个构造方法 = 4(基于HashMap的实现,用于实现HashSet) + 1(基于LinkedHashMap的实现,用于实现LinkedHashSet)
LinkedHashSet:是HashSet的子类,被委托给HashMap的子类LinkedHashMap进行实现,实现了Set接口
LinkedHashSet继承于HashSet,利用下面的HashSet构造函数即可,注意到,其为包访问权限,专门供LinkedHashSet的构造函数调用。LinkedHashSet性能介于HashSet和TreeSet之间,是HashSet的子类,也是一个hash表,但是同时维护了一个双链表来记录插入的顺序,基本方法的复杂度为O(1)。
/**
* Constructs a new, empty linked hash set. (This package private
* constructor is only used by LinkedHashSet.) The backing
* HashMap instance is a LinkedHashMap with the specified initial
* capacity and the specified load factor.
*
* @param initialCapacity the initial capacity of the hash map
* @param loadFactor the load factor of the hash map
* @param dummy ignored (distinguishes this
* constructor from other int, float constructor.)
* @throws IllegalArgumentException if the initial capacity is less
* than zero, or if the load factor is nonpositive
*/
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap(initialCapacity, loadFactor);
}
TreeSet:委托给TreeMap(TreeMap实现了NavigableSet接口)进行实现,实现了NavigableSet接口(扩展的 SortedSet)
TreeSet是采用树结构实现(红黑树算法),元素是按顺序进行排列,但是add()、remove()以及contains()等方法都是复杂度为O(log (n))的方法,它还提供了一些方法来处理排序的set,如first()、 last()、 headSet()和 tailSet()等。此外,TreeSet不同于HashSet和LinkedHashSet,其所存储的元素必须是可排序的(元素实现Comparable接口或者传入Comparator),并且不能存放null值。
public class TreeSet<E> extends AbstractSet<E>
implements NavigableSet<E>, Cloneable, java.io.Serializable
{
/**
* The backing map.
*/
private transient NavigableMap m; // 底层支持
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
// 默认构造函数
public TreeSet() {
this(new TreeMap());
}
29、Map及Map的三种常用实现[链表数组HashMap,LinkedHashMap(HashMap的子类,带头结点的双向链表 + HashMap),基于红黑树的TreeMap(对key排序)]
1)、HashMap:哈希表
(1)、对NULL键的特别处理
HashMap 中可以保存键为NULL的键值对,且该键值对是唯一的。若再次向其中添加键为NULL的键值对,将覆盖其原值。此外,如果HashMap中存在键为NULL的键值对,那么一定在第一个桶中。
(2)、HashMap 中的哈希策略
使用 hash() 方法用于对Key的hashCode进行重新计算,以防止质量低下的hashCode()函数实现。由于hashMap的支撑数组长度总是 2 的倍数,通过右移可以使低位的数据尽量的不同,从而使Key的hash值的分布尽量均匀;
使用 indexFor() 方法进行取余运算,以使Entry对象的插入位置尽量分布均匀
(3)、HashMap 的底层数组长度为何总是2的n次方?
// HashMap 的容量必须是2的幂次方,超过 initialCapacity 的最小 2^n
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
(4)、初始容量16,两倍扩容,先添加再检查
2)、LinkedHashMap:HashMap + 带头结点的双向循环链表
(1)、可以实现LRU算法
public class LRU<K,V> extends LinkedHashMap<K, V> implements Map<K, V>{
private static final long serialVersionUID = 1L;
public LRU(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor, accessOrder);
}
/**
* @description 重写LinkedHashMap中的removeEldestEntry方法,当LRU中元素多余6个时,
* 删除最不经常使用的元素
* @author rico
* @created 2017年5月12日 上午11:32:51
* @param eldest
* @return
* @see java.util.LinkedHashMap#removeEldestEntry(java.util.Map.Entry)
*/
@Override
protected boolean removeEldestEntry(java.util.Map.Entry eldest) {
// TODO Auto-generated method stub
if(size() > 6){
return true;
}
return false;
}
public static void main(String[] args) {
LRU lru = new LRU(
16, 0.75f, true);
String s = "abcdefghijkl";
for (int i = 0; i < s.length(); i++) {
lru.put(s.charAt(i), i);
}
System.out.println("LRU中key为h的Entry的值为: " + lru.get('h'));
System.out.println("LRU的大小 :" + lru.size());
System.out.println("LRU :" + lru);
}
}
(2)、维持插入顺序或者维持访问顺序:双向循环链表头结点header + accessOrder
(3)、利用双向循环链表重写迭代器
3)、TreeMap:红黑树的实现
(1). 排序二叉树 Vs. 红黑树
排序二叉树虽然可以快速检索,但在最坏的情况下:如果插入的节点集本身就是有序的,要么是由小到大排列,要么是由大到小排列,那么最后得到的排序二叉树将变成链表:所有节点只有左节点(如果插入节点集本身是大到小排列);或所有节点只有右节点(如果插入节点集本身是小到大排列)。在这种情况下,排序二叉树就变成了普通链表,其检索效率就会很差。
(2). 红黑树
为了改变排序二叉树存在的不足,Rudolf Bayer 与 1972 年发明了另一种改进后的排序二叉树:红黑树,他将这种排序二叉树称为“对称二叉 B 树”,而红黑树这个名字则由 Leo J. Guibas 和 Robert Sedgewick 于 1978 年首次提出。
红黑树是一个更高效的检索二叉树,因此常常用来实现关联数组。典型地,JDK 提供的集合类 TreeMap 本身就是一个红黑树的实现。红黑树在原有的排序二叉树增加了如下几个要求:
性质 1:每个节点要么是红色,要么是黑色;
性质 2:根节点永远是黑色的;
性质 3:所有的叶节点都是空节点(即 null),并且是黑色的;
性质 4:每个红色节点的两个子节点都是黑色(从每个叶子到根的路径上不会有两个连续的红色节点);
性质 5:从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点。
(3). 红黑树的性质
上面的性质3中指出:红黑树的每个叶子节点都是空节点,而且并叶子节点都是黑色。但 Java 实现的红黑树将使用null来代表空节点,因此遍历红黑树时将看不到黑色的叶子节点,反而看到每个叶子节点都是红色的。根据性质 5,红黑树从根节点到每个叶子节点的路径都包含相同数量的黑色节点,因此从根节点到叶子节点的路径中包含的黑色节点数被称为树的“黑色高度(black-height)”。性质 4 则保证了从根节点到叶子节点的最长路径的长度不会超过任何其他路径的两倍。
假如有一棵黑色高度为 3 的红黑树:从根节点到叶节点的最短路径长度是 2,该路径上全是黑色节点(黑节点 - 黑节点 - 黑节点)。最长路径也只可能为 4,在每个黑色节点之间插入一个红色节点(黑节点 - 红节点 - 黑节点 - 红节点 - 黑节点),性质 4 保证绝不可能插入更多的红色节点。由此可见,红黑树中最长路径就是一条红黑交替的路径。由此我们可以得出结论:对于给定的黑色高度为 N 的红黑树,从根到叶子节点的最短路径长度为 N-1,最长路径长度为 2 * (N-1)。
(4). 红黑树的查找、插入与删除操作
由于红黑树只是一个特殊的排序二叉树,因此对红黑树上的只读操作与普通排序二叉树上的只读操作完全相同,只是红黑树保持了大致平衡,因此检索性能比排序二叉树要好很多。
但在红黑树上进行插入操作和删除操作会导致树不再符合红黑树的特征,因此插入操作和删除操作都需要进行一定的维护,以保证插入节点、删除节点后的树依然是红黑树。
(5). 红黑树和平衡二叉树
我们知道,在AVL树中,任何节点的两个子树的最大高度差为1,所以它也被称为高度平衡树;而红黑树并不追求 完全平衡,它只要求大致地达到平衡要求,降低了对旋转的要求,从而提高了性能。实际上,由于红黑树的设计,任何不平衡都会在三次旋转之内解决。
红黑树能够以O(log2 n) 的时间复杂度进行搜索、插入、删除操作。此外,由于它的设计,任何不平衡都会在三次旋转之内解决。当然,还有一些更好的,但实现起来更复杂的数据结构,能够做到一步旋转之内达到平衡,但红黑树能够给我们一个比较“便宜”的解决方案。红黑树的算法时间复杂度和AVL相同,但统计性能比AVL树更高。
红黑树并不是真正的平衡二叉树,但在实际应用中,红黑树的算法时间复杂度和AVL相同,但统计性能比AVL树更高。
排序二叉树的深度直接影响了检索的性能,正如前面指出,当插入节点本身就是由小到大排列时,排序二叉树将变成一个链表,这种排序二叉树的检索性能最低:N 个节点的二叉树深度就是 N-1。
红黑树通过上面这种限制来保证它大致是平衡的——因为红黑树的高度不会无限增高,这样保证红黑树在最坏情况下都是高效的,不会出现普通排序二叉树的情况。
更多关于HashMap的介绍请参见笔者的博文《Map 综述(一):彻头彻尾理解 HashMap》;更多关于LinkedHashMap的介绍请参见笔者的博文《 Map 综述(二):彻头彻尾理解 LinkedHashMap》;更多关于TreeMap以及红黑树的内容请参考博文通过分析 JDK 源代码研究 TreeMap 红黑树算法实现和从排序二叉树到红黑树与AVL树(转)。
30、容器中判断两个对象是否相等的步骤
判断两个对象的hashCode是否相等:如果不相等,认为两个对象也不相等,完毕;如果相等,转入2);
判断两个对象用equals运算是否相等:如果不相等,认为两个对象也不相等;如果相等,认为两个对象相等。
31、ConcurrentHashMap
1). HashMap 线程不安全的典型表现
2). ConcurrentHashMap 原理图
3). 写操作:put在链头加元素,remove/clear不会对原有链进行修改
put:定位段 —> 对段加锁 ——> 扩容检查 ——> Key检查 ——> 链头插入
先检查key是否不为空,然后对key.hashcode进行再哈希,并根据该值与并发级别(2^n)的高n位定位至某一段,然后将put操作委托给该段。该段在put一条记录时,会首先定位至段中某一特定的桶并对该段上锁,然后检查该段是否需要扩容,并查找该单链表中是否已经存在指定Key,若存在,则更新Value值;否则,将其插入都桶中第一个节点,并更新该段的节点的个数Count,解锁。
/**
* Removes all of the mappings from this map.
*/
public void clear() {
for (int i = 0; i < segments.length; ++i)
segments[i].clear();
}
4). 为什么可以不加锁进行读?
具体分两种情况对待:对ConcurrentHashMap做非结构性修改和对ConcurrentHashMap做结构性修改。
对ConcurrentHashMap做非结构性修改
非结构性修改操作只是更改某个HashEntry的value字段的值。由于对Volatile变量的写入操作将与随后对这个变量的读操作进行同步,所以当一个写线程修改了某个HashEntry的value字段后,Java内存模型能够保证读线程一定能读取到这个字段更新后的值。所以,写线程对链表的非结构性修改能够被后续不加锁的读线程看到。
对ConcurrentHashMap做结构性修改
对ConcurrentHashMap做结构性修改时,实质上是对某个桶指向的链表做结构性修改。如果能够确保在读线程遍历一个链表期间,写线程对这个链表所做的结构性修改不影响读线程继续正常遍历这个链表,那么读/写线程之间就可以安全并发访问这个ConcurrentHashMap。在ConcurrentHashMap中,结构性修改操作包括put操作、remove操作和clear操作,下面我们分别分析这三个操作:
(1). clear操作只是把ConcurrentHashMap中所有的桶置空,每个桶之前引用的链表依然存在,只是桶不再引用这些链表而已,而链表本身的结构并没有发生任何修改。因此,正在遍历某个链表的读线程依然可以正常执行对该链表的遍历。
(2). 关于put操作的细节我们在上文已经单独介绍过,我们知道put操作如果需要插入一个新节点到链表中时会在链表头部插入这个新节点,此时链表中的原有节点的链接并没有被修改。也就是说,插入新的健/值对到链表中的操作不会影响读线程正常遍历这个链表。
(3). 在执行remove操作时,原始链表并没有被修改,也就是说,读线程不会受同时执行 remove 操作的并发写线程的干扰。
综合上面的分析我们可以知道,无论写线程对某个链表进行结构性修改还是非结构性修改,都不会影响其他的并发读线程对这个链表的访问。
5). 跨段操作, size、contains
/**
* Returns the number of key-value mappings in this map. If the
* map contains more than Integer.MAX_VALUE elements, returns
* Integer.MAX_VALUE.
*
* @return the number of key-value mappings in this map
*/
public int size() {
final Segment[] segments = this.segments;
long sum = 0;
long check = 0;
int[] mc = new int[segments.length];
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
for (int k = 0; k < RETRIES_BEFORE_LOCK; ++k) {
check = 0;
sum = 0;
int mcsum = 0;
for (int i = 0; i < segments.length; ++i) {
sum += segments[i].count;
mcsum += mc[i] = segments[i].modCount; // 在统计size时记录modCount
}
if (mcsum != 0) {
for (int i = 0; i < segments.length; ++i) {
check += segments[i].count;
if (mc[i] != segments[i].modCount) { // 统计size后比较各段的modCount是否发生变化
check = -1; // force retry
break;
}
}
}
if (check == sum)// 如果统计size前后各段的modCount没变,且两次得到的总数一致,直接返回
break;
}
if (check != sum) { // Resort to locking all segments // 加锁统计
sum = 0;
for (int i = 0; i < segments.length; ++i) // 先对获取各个段的锁
segments[i].lock();
for (int i = 0; i < segments.length; ++i) // 获取所有段的锁后,进行求和操作
sum += segments[i].count;
for (int i = 0; i < segments.length; ++i) // 求和完成后,释放锁
segments[i].unlock();
}
if (sum > Integer.MAX_VALUE)
return Integer.MAX_VALUE;
else
return (int)sum;
}
size方法主要思路是先在没有锁的情况下对所有段大小求和,这种求和策略最多执行RETRIES_BEFORE_LOCK次(默认是两次):在没有达到RETRIES_BEFORE_LOCK之前,求和操作会不断尝试执行(这是因为遍历过程中可能有其它线程正在对已经遍历过的段进行结构性更新);在超过RETRIES_BEFORE_LOCK之后,如果还不成功就在持有所有段锁的前提下再对所有段大小求和。
那么,ConcurrentHashMap是如何判断在统计的时候容器的段发生了结构性更新了呢?我们在前文中已经知道,Segment包含一个modCount成员变量,在会引起段发生结构性改变的所有操作(put操作、 remove操作和clean操作)里,都会将变量modCount进行加1,因此,JDK只需要在统计size前后比较modCount是否发生变化就可以得知容器的大小是否发生变化。
更多关于ConcurrentHashMap的介绍请参见笔者的博文《 Map 综述(三):彻头彻尾理解 ConcurrentHashMap》。
32、ConcurrentHashMap,HashMap 与 HashTable
本质:三者都实现了Map接口,ConcurrentHashMap和HashMap是AbstractMap的子类,HashTable是Dictionary的子类;
线程安全性:HashMap 是线程不安全的,但 ConcurrentHashMap 和 HashTable 是线程安全的,但二者保证线程安全的策略不同;前者采用的是分段锁机制,默认理想情况下,可支持16个线程的并发写和任意线程的并发读,效率较高;HashTable 采用的是同步操作,效率较低
键值约束:HashMap 允许键、值为 null,但 ConcurrentHashMap 和 HashTable 既不允许键为null,也不允许值为 null;
哈希策略:三者哈希策略不同,HashTable是key.hashCode取余;ConcurrentHashMap与HashMap都是先对hashCode进行再哈希,然后再与(桶数 - 1)进行取余运算,但是二者的再哈希算法不同;
扩容机制:扩容检查机制不同,ConcurrentHashMap 和 HashTable 在插入元素前检查,HashMap 在元素插入后检查;
初始容量:HashTable 初始容量 11,扩容 2倍 + 1;HashMap初始容量16,扩容2倍
33、什么是CopyOnWrite容器 (弱一致性)
CopyOnWrite 容器即写时复制的容器,从JDK1.5开始,Java并发包里提供了两个使用CopyOnWrite机制实现的并发容器 —— CopyOnWriteArrayList 和 CopyOnWriteArraySet,它们适用于 读操作远多于写操作 的并发场景中。关于写时复制容器,通俗的理解是,当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是,我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以,CopyOnWrite容器也是一种 读写分离思想,读和写不同的容器。写时复制容器也存在一些缺点,比如:
容器对象的复制需要一定的开销,如果对象占用内存过大,可能造成频繁的YoungGC和Full GC,对内存的消耗太大;
CopyOnWriteArrayList不能保证数据严格的实时一致性,只能保证最终一致性。
// volatile 数组,其可见性在于array是否指向了新的对象/数组从而保证可见性,而对数组中元素的变化不起作用。
private volatile transient Object[] array;
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
1). CopyOnWriteArraySet 与 CopyOnWriteArrayList 的对比
正如Set一般由Map实现一样,CopyOnWriteArraySet容器也是基于CopyOnWriteArrayList容器实现的,只需要在写操作时判断元素不可重复即可。
public class CopyOnWriteArraySet<E> extends AbstractSet<E>
implements java.io.Serializable {
private static final long serialVersionUID = 5457747651344034263L;
private final CopyOnWriteArrayList al;
2). 并发容器(CopyOnWriteArrayList/CopyOnWriteArraySet)与同步容器(Collections.synchronizedList / Collections.synchronizedSet)的区别
CopyOnWriteArrayList和Collections.synchronizedList是实现线程安全的列表的两种方式。两种实现方式分别针对不同情况有不同的性能表现,其中CopyOnWriteArrayList的写操作性能较差(复制数组),而多线程的读操作性能较好;而Collections.synchronizedList的写操作性能比CopyOnWriteArrayList在多线程操作的情况下要好很多,而读操作因为是采用了synchronized关键字的方式,其读操作性能并不如CopyOnWriteArrayList。因此在不同的应用场景下,应该选择不同的多线程安全实现类。
3). CopyOnWrite容器的读写操作
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
public E get(int index) {
return get(getArray(), index);
}
34、BIO, NIO, AIO
按照《Unix网络编程》的划分,IO模型可以分为:阻塞IO、非阻塞IO、IO复用、信号驱动IO 和 异步IO。按照POSIX标准来划分只分为两类:同步IO和异步IO。如何区分呢?首先,一个IO操作其实分成了两个步骤: 发起IO请求 和 处理IO请求。
阻塞IO和非阻塞IO的区别在于发起IO请求是否阻塞:发起IO请求是否会被阻塞,如果阻塞直到完成那么就是传统的阻塞IO;如果不阻塞,那么就是非阻塞IO。
同步IO和异步IO的区别就在于处理IO请求是否阻塞:如果实际的IO请求处理由应用内线程完成,那么就是同步IO,因此阻塞IO、非阻塞IO、IO复用、信号驱动IO都是同步IO;如果实际的IO请求处理由操作系统帮你做完IO操作再将结果返回给你,那么就是异步IO。
更多关于BIO、NIO与AIO之间的区别参见博文BIO | NIO | AIO释疑。
1). BIO
BIO是同步阻塞IO(一个线程处理一个链接,双方阻塞等待数据准备好,因此会受到网络延迟等因素的影响),服务端通过一个独立的Acceptor线程监听客户端的连接请求,当它收到客户端的连接请求后,就为每一个客户端创建一个新的线程去处理请求,处理结束后,将处理结果返回给客户端。可以通过线程池技术改良,避免了为每个请求都创建一个新的线程造成的线程资源耗尽问题,但仍然是同步阻塞模型,解决不了根本问题。由于IO请求的处理工作是由应用内线程完成的,因此是同步的。适用于链接数目比较小的架构。
2). NIO
NIO是同步非阻塞IO(数据准备好了再通知我,因此不会受到网络延迟等因素的影响),客户端发起的每个IO连接都注册到多路复用器Selector上,同时Selector会不断轮询注册在其上的Channel,当其中的某些Channel发生读或者写事件时,这些channel就会处于就绪状态,多路复用器Selector就会将这些Channel选择出来逐个处理请求,处理结束后,将处理结果返回到客户端。NIO模型中只需要一个线程负责Selector的轮询就可以接入成千上万的客户端,这相对于BIO来说无疑是巨大的进步。由于IO请求的处理工作是由应用内线程完成的,因此是同步的。适用于链接数目多且短的架构。更多关于NIO的阐述参见博文《Selector及SelectionKey》。
Channel
类似于BIO中的Stream,但是Channel是非阻塞的、双向的。可以将NIO中的Channel同传统IO中的Stream来类比,但是要注意,传统IO中,Stream是单向的,比如InputStream只能进行读取操作,OutputStream只能进行写操作。而Channel是双向的,既可用来进行读操作,又可用来进行写操作。
Selector
Selector的作用就是轮询每个注册的Channel,一旦发现Channel有注册的读/写事件发生,便获取事件然后进行处理。
3). AIO
AIO是异步非阻塞IO (IO请求处理完成后再通知我):服务器端异步的处理客户端的连接,并且客户端的IO请求都是由操作系统先完成了,再通知应用内线程进行处理,因此更适合连接数多且连接比较长的架构,例如相册服务器。
4). BIO、NIO 及 AIO 对比
35、引用的种类及其定义 : 弹性的垃圾回收机制
1). 引入多种引用的目的
无论采用的是引用计数法还是可达性分析算法,判断对象是否可以回收都与其引用有关。在JDK1.2之前,一个对象只有引用和被引用两种状态,即只有“被引用”和“未被引用”,但是这样太过狭隘,我们希望有一种弹性的垃圾回收机制,当内存足够时,将一些对对象保留在内存中;内存比较紧张时,将这些对象回收,类似于缓存的功能,提高效率。
2). 引用的种类
强引用:在程序代码之中普遍存在的,用来描述new出来的对象那种。对于类似“Object obj = new Object()”这类引用。 只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。
软引用:软引用用来描述一些有用但并非必需的对象,内存泄露之前回收。
对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中并进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实现软引用。
弱引用:弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,其所关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了WeakReference类来实现弱引用。
虚引用:是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。
36、System.gc() 与 Object.finalli()ze
System.gc()方法的作用是发出通知让垃圾回收器启动垃圾回收,但垃圾回收器不一定会真正工作,实际上调用的是Runtime.getRuntime.gc()方法。
Object.finallize()方法的作用为,当对象没有与GC Roots相连接的引用链时,若该对象重写了该方法并且还没有被垃圾回收器调用过,垃圾回收器将调用此方法,这也是对象垃圾回收前最后一次自我解救(重新与GC Roots连上)的方式。实际上,每个对象的finallize方法只会被垃圾回收器调用一次。其方法签名为
protected void finalize() throws Throwable { }
37、函数式接口
函数式接口(functional interface,也叫功能性接口),简单来说,函数式接口是只有一个抽象方法的接口,其用于支持Lamda表达式,是Lamda表达式的类型。比如,Java标准库中的java.lang.Runnable、java.util.concurrent.Callable、java.util.Comparator都是典型的函数式接口。
Java 8提供注解@FunctionalInterface标识函数式接口,但这个注解是非必须的,只要接口符合函数式接口的标准(即只包含一个方法的接口),虚拟机会自动判断,但最好在接口上使用注解@FunctionalInterface进行声明,以免团队的其他人员错误地往接口中添加新的方法。
Java中的lambda无法单独出现,它需要一个函数式接口来支持,lambda表达式方法体其实就是函数接口的实现。
38、JDK 8 新特性
JDK 8 引进了许多新特性,其中最重要的有以下三点:
1). jdk1.8接口支持静态方法与默认方法(通过default关键字实现);
2). lambda表达式
lambda表达式允许你通过表达式来代替函数式接口,lambda表达式就和方法一样,它提供了一个正常的参数列表和一个方法体(body,可以是一个表达式或一个代码块)。Lamda表达式是由函数式接口所支持的,函数式接口是只有一个抽象方法的接口,是Lamda表达式的类型。一个lambda包括三部分:
一个括号内用逗号分隔的形式参数,参数是函数式接口里面方法的参数;
一个箭头符号:->
方法体,可以是表达式和代码块,方法体是函数式接口里面方法的实现:如果是代码块,则必须用{}来包裹起来,且需要一个return返回值,但有个例外,若函数式接口里面方法返回值是void,则无需{}。
public class TestLambda {
// 新的使用方式
public static void runThreadUseLambda() {
//Runnable是一个函数接口,只包含了有个无参数的,返回void的run方法;
//所以lambda表达式左边没有参数,右边也没有return,只是单纯的打印一句话
new Thread(() ->System.out.println("lambda实现的线程")).start();
}
// 老的使用方式
public static void runThreadUseInnerClass() {
//这种方式就不多讲了,以前旧版本比较常见的做法
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("内部类实现的线程");
}
}).start();
}
public static void main(String[] args) {
TestLambda.runThreadUseLambda();
TestLambda.runThreadUseInnerClass();
}
}
使用Lambda表达式可以带来以下好处:
更加紧凑的代码,可读性更强。比如,Java中现有的匿名内部类以及监听器(listeners)和事件处理器(handlers)都显得很冗长。
修改方法的能力。比如,Collection接口的contains方法,当且仅当传入的元素真正包含在集合中,才返回true。而假如我们想对一个字符串集合,传入一个字符串,只要这个字符串出现在集合中(忽略大小写)就返回true。简单地说,我们想要的是传入“一些我们自己的代码”到已有的方法中,已有的方法将会执行我们传入的代码。Lambda表达式能很好地支持这点。
更好地支持多核处理。例如,通过Java 8新增的Lambda表达式,我们可以很方便地并行操作大集合,充分发挥多核CPU的潜能。
3). StreamAPI + Lamda
Stream API更像具有Iterable的集合类,但行为和集合类又有所不同,它是对集合对象功能的增强,专注于对集合对象进行各种非常便捷、高效的聚合操作或大批量数据操作。
在Stream API中,一个流基本上代表一个元素序列,Stream API提供了丰富的操作函数来计算这些元素。以前我们在开发业务应用时,通常很多操作的实现是这样做的:我们使用循环对集合做遍历,针对集合中的元素实现各种操作,定义各种变量来实现目的,这样我们就得到了一大堆丑陋的顺序代码。如果我们使用StreamAPI做同样的事情,使用Lambda表达式和其它函数进行抽象,可以使得代码更易于理解、更为干净。有了这些抽象,还可以做一些优化,比如实现并行等,例如:
public static void main(String[] args) {
List numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Stream stream = numbers.stream();
stream.filter((x) -> {
return x % 2 == 0; // 将偶数过滤出来
}).map((x) -> {
return x * x; // 将偶数平方
}).forEach(System.out::println); // 打印结果
}
更多关于 Java 8 的新特性可以才参考《Java基础知识总结之1.8新特性lambda表达式》 一文。
39、双亲委派模型 与 反双亲委派模型
1). 双亲委派模型:基础类的同一加载问题
双亲委派模型工作过程为:如果一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把加载请求委派给父类加载器去完成,每一个层次的类加载器都是如此。因此,所有的加载请求最终都应该传送到顶层的启动类加载器当中,只有当父加载器反馈自己无法完成这个加载请求后,子加载器才会尝试自己去加载。
使用双亲委派模型的好处是,Java类随着它的类加载器一起具备了一种带有优先级的层次关系。比如Object类,它位于rt.jar包中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是使用同一类。相反,如果没有使用双亲委派模型,由各个类加载器自行加载的话,如果一个用户自己写了java.lang.Object类并放到程序的ClassPath中,那么系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将一片混乱。
2). 违反双亲委派模型
双亲委派模型很好地解决了各个类加载器的基础类的统一问题,越是基础的类由越上层的加载器加载。基础类之所以称为基础,就是因为他们总是作为被用户代码调用的API,但如果基础类又要回调用户代码,该怎么办?
(1). 基础类回调用户类(SPI接口与SPI实现的加载问题)
前面提到的类加载器的代理模式并不能解决 Java 应用开发中会遇到的类加载器的全部问题。Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现,常见的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。这些 SPI 的接口由 Java 核心库来提供,如 JDBC的 SPI 接口定义包含在 javax.sql包中;而这些 SPI 的实现代码很可能是作为 Java 应用所依赖的 jar 包被包含进来,可以通过类路径(CLASSPATH)来找到,如实现了 JDBC SPI 的 Mysql所包含的 jar 包。SPI 接口中的代码经常需要加载具体的实现类,如SPI中的 javax.sql.DriverManager类需要在com.mysql.jdbc.Driver的实例基础上获得和MySQL的连接对象Connection实例,比如:
static {
try {
Class.forName("com.mysql.jdbc.Driver").newInstance(); // (1)
} catch (Exception e) {
e.printStackTrace();
}
}
public static Connection getConnection(String url) throws SQLException {
return DriverManager.getConnection(url); // (2)
}
这里的实例的真正的类是继承自 javax.sql.Driver(接口),由 SPI 的实现所提供的。而问题在于:SPI 的接口是 Java 核心库的一部分,是由引导类加载器来加载的;SPI 实现的 Java 类一般是由系统类加载器来加载的。引导类加载器是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库,而且它也不能委托给系统类加载器,因为它是系统类加载器的祖先类加载器。也就是说,类加载器的双亲委派模式无法解决这个问题。
线程上下文类加载器正好解决了这个问题。线程上下文类加载器(context class loader)是从 JDK 1.2 开始引入的。类 Java.lang.Thread中的方法 getContextClassLoader()和 setContextClassLoader(ClassLoader cl)用来获取和设置线程的上下文类加载器。如果没有通过 setContextClassLoader(ClassLoader cl)方法进行设置的话,线程将继承其父线程的上下文类加载器。Java 应用运行的初始线程的上下文类加载器是系统类加载器。在 SPI 接口的代码中使用线程上下文类加载器,就可以成功的加载到 SPI 实现的类,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上已经违背了双亲委派模型的一般性原则。线程上下文类加载器在很多 SPI 的实现中都会用到。
更多关于SPI接口与SPI实现的加载问题的介绍请关注博文《违反ClassLoader双亲委派机制三部曲之首部——JDBC驱动加载》 和 《深入理解Java类加载器(二):线程上下文类加载器》。
(2). OSGi(Open Service Gateway initiative)
OSGi是以Java为技术平台的动态模块化规范,业界Java模块化标准,常用于模块热部署。所谓模块热部署是指将项目部署到Web容器后,我们可以将某些功能模块拿下来或放上去,并且不会对其他模块造成影响。模块化热部署的实现关键在于自定义类加载器机制的实现:每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bunble连同类加载器一起换掉以实现代码的热替换。
在OSGi中,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构。
3). Tomcat 中的反双亲委派模型
WebappClassLoader内部重写了loadClass和findClass方法,实现了绕过“双亲委派”直接加载web应用内部的资源,当然可以通过在Context.xml文件中加上如下代码片段开启正统的“双亲委派”加载机制。关于更多Tomcat中的反双亲委派模型的内容请参见博文《违反ClassLoader双亲委派机制三部曲第二部——Tomcat类加载机制》。
<Loader delegate = "true">
40、为什么新生代内存需要有两个Survivor区?
这个问题可以分为两个子问题:“为什么要有Survivor区?” 以及“为什么要设置两个Survivor区?”。
1). 为什么要有Survivor区
如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,从而触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。由于老年代的内存空间一般是新生代的2倍,因此进行一次Full GC消耗的时间比Minor GC长得多,这样,频繁的Full GC消耗的时间是非常可观的,这一点会影响大型程序的执行和响应速度。Survivor的存在意义就在于,减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。
2). 为什么要有两个Survivor区
设置两个Survivor区最大的好处就是解决了碎片化。在第一部分中,我们知道了必须设置Survivor区。假设现在只有一个survivor区,我们来模拟一下流程:刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。
同时,我们也知道,现在大多数JVM虚拟机将新生代与老年代按照如下比例来分配:
Eden :Survivor(to,from) = 8 :2(1:1)
在这里,Eden区理所当然大一些,否则新建对象很快就导致Eden区满,进而触发Minor GC,有悖于初衷。
41、JDK 1.8相对于之前版本中HashMap中的实现的变化?
JDK 1.8 以后哈希表的添加、删除、查找、扩容方法都增加了一种节点为TreeNode的情况:
添加时,当桶中链表个数超过 8 时会转换成红黑树;
删除、扩容时,如果桶中结构为红黑树,并且树中元素个数太少的话,会进行修剪或者直接还原成链表结构;
查找时即使哈希函数不优,大量元素集中在一个桶中,由于有红黑树结构,性能也不会差(O(lgn))。
更多关于HashMap在JDK 8中的实现请参见博文《Java 集合深入理解(17):HashMap 在 JDK 1.8 后新增的红黑树结构》。
42、ThreadLocal 的内存泄漏问题
ThreadLocal的实现是这样的:每个Thread 维护一个 ThreadLocalMap 映射表,这个映射表的 key 是 ThreadLocal 实例本身,value 是真正需要存储的 Object。也就是说,ThreadLocal 本身并不存储值,它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value。值得注意的是图中的虚线,表示 ThreadLocalMap 是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收。
那么,ThreadLocal为什么会内存泄漏呢?原因是,ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统 GC 的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value。如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。
综合上面的分析,ThreadLocal 的最佳实践为:每次使用完ThreadLocal,都调用它的remove()方法,清除数据。
在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理。
更多还有ThreadLocal 的内存泄漏的内容请参见博文《深入分析 ThreadLocal 内存泄漏问题 》。