一、Java基础
平台无关性
.java 文件 -> 编译成 .class 文件 -> 经过平台相应的 JVM 解析成对应的机器码执行
这样做的优点是:
- 避免每次执行都需要的各种检查
- 可以将别的 JVM 上的语言编译成 .class 文件,具有兼容性
谈谈反射
Java 反射机制是在运行状态中,对于任意的类,都可以知道它的方法和属性,也可以调用相应的方法和属性,这种动态获取类的信息与调用方法叫做反射机制。
final 关键字
- final 修饰的类叫最终类,该类不能被继承。
- final 修饰的方法不能被重写。
- final 修饰的变量不可更改,其不可更改指的是其引用不可修改,对于引用类型值还是可能改变的,举个列子:String 内部对于 value 的定义;而对于基本类型来说就叫做常量了。
final、finally、finalize 有什么区别?
- final可以修饰类、变量、方法,修饰类表示该类不能被继承、修饰方法表示该方法不能被重写、修饰变量表示该变量是一个常量不能被重新赋值。
- finally一般作用在try-catch代码块中,在处理异常的时候,通常我们将一定要执行的代码方法finally代码块中,表示不管是否出现异常,该代码块都会执行,一般用来存放一些关闭资源的代码。
- finalize是一个方法,属于Object类的一个方法,而Object类是所有类的父类,该方法一般由垃圾回收器来调用,当我们调用System的gc()方法的时候,由垃圾回收器调用finalize(),回收垃圾。
transient 关键字
transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient修饰的变量值不会被持久化和恢复。transient 只能修饰变量,不能修饰类和方法。
ArrayList 中存储数据的数组 elementData 是用 transient 修饰的,因为这个数组是动态扩展的,并不是所有的空间都被使用,因此就不需要所有的内容都被序列化。通过重写序列化和反序列化方法,使得可以只序列化数组中有内容的那部分数据。
String 是如何实现不可变的?
首先要明确:String 不可变的是字符串的值不变。
让我们看 String 源码:
private final char value[];
从源码来看, String 类内部是用 char 数组来保存字符串的值, 并且 char[] 是 final 的
- value 必须在构造时为其赋值
- 赋值后 value 的引用不能再变
当我们实例化一个 String 对象并得到其引用后, 构造已经结束了, 即 value 的引用已经不能再变了。那么 value 的值呢,理论上是可以改变的,只要我们拿到 value 的引用,可以直接通过下标改变他的值。
然而,因为 String 并没有提供接口来改变 value 的值,所以value 的值我们从 String 外部获取不到,也改变不了。这才是 String 才是不可变的真正原因,并不仅仅是使用 final 修饰了 value 数据。
补充:并不是真正的完全不能获取,利用反射可以直接获取类内部属性。
String 为什么设置为不可变?
- 为了实现字符串常量池(只有当字符是不可变的,字符串池才有可能实现)
- 为了线程安全(字符串自己便是线程安全的)
- 为了保证同一个对象调用 hashCode() 都产生相同的值,String 设置为不可变可以对这个条件有很好的支持,这也是 Map 类的 key 使用 String 的原因。
String、StringBuilder 和 StringBuffer 区别
-
可变与不可变
String 类中使用字符数组保存字符串,如下就是,因为有 final 修饰符并且没有提供支持修改的接口,所以可以知道 String 对象是不可变的。
private final char value[];
String 为不可变对象,一旦被创建就不能修改它的值。对于已经存在的 String 对象的修改都是重新创建一个新的对象,然后把新的值保存进去。
StringBuilder 与 StringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串,如下可知这两种对象都是可变的。
char[] value;
StringBuffer 是一个可变对象,当对其进行修改时不会像 String 那样重新建立对象。它只能通过构造函数来建立。
-
多线程安全
String 中的对象是不可变的,也就可以理解为常量,线程安全。
StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的
StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的
-
StringBuilder 与 StringBuffer 共同点
StringBuilder 与 StringBuffer 有公共父类 AbstractStringBuilder(抽象类)
StringBuilder 与 StringBuffer 的方法都会调用 AbstractStringBuilder 中的公共方法,如 super.append()
异常体系
RuntimeException
不可预知的,程序应当自行避免
- NullPointerException
- ClassCastException:类型强制转换异常
- IllegalArgumentException
- IndexOutOfBoundsException
- NumberFormatException
非 RuntimException
可预知的,从编译器校验异常
- ClassNotFoundException:找不到指定 class 的异常
- IOException
Error
- NoClassDefFoundError:找不到 class 定义的异常。例如:类依赖的 jar 包不存在;类文件存在但是作用域不同
- StackOverflowError:深递归导致栈被用尽
- OutOfMemoryError:内存溢出异常
Exception、Error 运行时异常与一般异常有何异同
所有的异常都是从 Throwable 继承而来的
Error 是程序无法处理的错误,对于所有的编译时期的错误以及系统错误都是通过 Error 抛出的。
Exception 它规定的异常是程序本身可以处理的异常,捕获后可能恢复。
checked exception 可检查的异常,这是编码时非常常用的,所有 checked exception 都是需要在代码中处理的。它们的发生是可以预测的,正常的一种情况,可以合理的处理。比如 IOException,或者一些自定义的异常。除了 RuntimeException 及其子类以外,都是 checked exception。
UncheckedException、RuntimeException 及其子类都是 unchecked exception。比如 NPE 空指针异常,除数为 0 的算数异常 ArithmeticException 等等,这种异常是运行时发生,无法预先捕捉处理的。比如 NullPointerException 、SQLException、NumberFormatException、FileNotFoundException和NoSuchMethodException。
Java 异常处理原则
- 具体明确:抛出的异常能够通过异常名词和 message 准确说明异常的类型和产生异常的原因
- 提早抛出:尽可能的早发现异常并抛出,便于精准定位问题
- 延迟捕获:异常的捕获和处理应当尽可能延迟,让掌握更多信息的作用域处理
try-catch 性能问题
- try-catch 块影响 JVM 的优化
- 异常对象实例需要保存栈快照,开销大
接口和抽象类的区别
- 接口的方法默认是 public,所有方法在接口中不能有实现(Java 8 开始接口方法可以有默认实现),而抽象类可以有非抽象的方法。
- 接口中除了 static、final 变量,不能有其他变量,而抽象类中则不一定。
- 一个类可以实现多个接口,但只能实现一个抽象类。接口自己本身可以通过 extends 关键字扩展多个接口。
- 从设计层面来说,抽象是对类的抽象,是一种模板设计,而接口是对行为的抽象,是一种行为的规范。
hashCode 与 equals
hashCode() 介绍
hashCode()的作用是获取哈希码,也称为散列码;它实际上是返回一个 int 整数。这个哈希码的作用是确定该对象在哈希表中的索引位置。
散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码(可以快速找到所需要的对象)
为什么要有 hashCode
我们先以 HashSet 如何检查重复为例子来说明为什么要有 hashCode: 当你把对象加入 HashSet 时,HashSet 会先计算对象的 hashcode 值来判断对象加入的位置,同时也会与其他已经加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用 equals()
方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速度。
通过我们可以看出:hashCode()
的作用就是获取哈希码,也称为散列码;它实际上是返回一个 int 整数。这个哈希码的作用是快速确定该对象在哈希表中的索引位置。
hashCode()在哈希表中才有用,在其它情况下没用。
hashCode()与 equals()的相关规定
- 如果两个对象相等,则 hashcode 一定也是相同的
- 两个对象相等,对两个对象分别调用 equals 方法都返回 true
- 两个对象有相同的 hashcode 值,它们也不一定是相等的
- 因此,equals 方法被覆盖过,则 hashCode 方法也必须被覆盖
- hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写 hashCode(),则该 class 的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)
哪些场景下,子类需要重写 equals 和 hashCode 方法?
需要判断两个对象状态的相等性的时候。
为什么要重写 hashcode()还要重写 equals()?
重写 equals 方法是为了按我们自己的想法来比较两个对象是否相等。如果不重写 hashCode 方法,可能出现具有相同含义的不同对象(他们的 hashCode 不同)的情况。而如果只重写 hashCode 不重写 equals 方法,因为 equals 其实就是 == ,只是判断两个对象是否是同一个对象,所以不能得到我们想要的结果。所以需要同时重写 equals 和 hashCode 方法,目的是为了准确定位到我们期望的 key。
在 HashMap 中考虑:
**通过阅读源码得知,在 HashMap 的 put 方法中,寻址找到的桶位如果上面已经有元素了,就判断 hash 值是否相同的同时也要通过 equals 判断(equals 是判断 map 的key 值),都为 true 才覆盖原来的值。如果只重写其中任意一个就会造成值的重复。 **
String 中 hashcode 的实现
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
以 31 为权,每一位字符的 ASCII 值进行运算,用自然溢出来等效取模。
哈希计算公式可以计为s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
为什么以 31 为质数
主要是因为 31 是一个奇质数,所以31*i = 32*i - i = (i << 5) - i
,这种位移与减法结合的计算相比一般的运算快很多。
==和 equals 的区别
==
对于基本类型和引用类型 == 的作用效果是不同的,如下所示: 基本类型:比较的是值是否相同; 引用类型:比较的是引用是否相同。
equals
Object 类的 equals 方法:
public boolean equals(Object obj) {
return (this == obj);
}
可以看出其实就是==而 String 类中重写了父类 Object 的 equals 方法:
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
可以看出此方法就是先使用==比较,如果不同再把对象转换为字符串逐一字符比较。
总结 :== 对于基本类型来说是值比较,对于引用类型来说是比较的是引用;而 equals 默认情况下是==比较,只是很多类重写了 equals 方法,比如 String、Integer 等把它变成了值比较,所以一般情况下 equals 比较的是值是否相等。
Java 集合框架
- HashMap:它根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap 最多只允许一条记录的键为 null,允许多条记录的值为 null。HashMap 非线程安全,即任一时刻可以有多个线程同时写 HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections 的 synchronizedMap 方法使 HashMap 具有线程安全的能力,或者使用 ConcurrentHashMap。
- Hashtable:Hashtable 是遗留类,很多映射的常用功能与 HashMap 类似,不同的是它承自 Dictionary 类,并且是线程安全的,任一时间只有一个线程能写 Hashtable,并发性不如 ConcurrentHashMap,因为ConcurrentHashMap 引入了分段锁。Hashtable 不建议在新代码中使用,不需要线程安全的场合可以用HashMap 替换,需要线程安全的场合可以用 ConcurrentHashMap 替换。
- LinkedHashMap:LinkedHashMap 是 HashMap的一个子类,保存了记录的插入顺序,在用 Iterator 遍历LinkedHashMap 时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
- TreeMap:TreeMap 实现 SortedMap 接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator遍历 TreeMap 时,得到的记录是排过序的。如果使用排序的映射,建议使用 TreeMap。在使用 TreeMap 时,key 必须实现 Comparable 接口或者在构造 TreeMap 传入自定义的 Comparator,否则会在运行时抛出 java.lang.ClassCastException 类型的异常。
对于上述四种 Map 类型的类,要求映射中的 key 是不可变对象。不可变对象是该对象在创建后它的哈希值不会被改变。如果对象的哈希值发生变化,Map 对象很可能就定位不到映射的位置了。
HashMap
Java8 之前:数组+链表
Java8 之后:数组+链表+红黑树
为什么 HashMap 不用 LinkedList 而选用数组?
因为用数组效率最高。 在 HashMap 中,定位桶的位置是利用元素的 key 的哈希值对数组长度取模得到。此时,我们已得到桶的位置。显然数组的查找效率比 LinkedList 大。
为什么不用 ArrayList?
因为采用基本数组结构,扩容机制可以自己定义,HashMap 中数组扩容刚好是 2 的次幂,在做取模运算的效率高。 而 ArrayList 的扩容机制是 1.5 倍扩容。
为什么扩容是 2 的次幂?
HashMap 为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;
这个算法实际就是取模,hash % length。 但是,大家都知道这种运算不如位移运算快。
因此,源码中做了优化 hash & (length-1)。 也就是说 hash % length == hash & (length-1)
HashMap 扩容问题
- 多个线程环境下,调整大小可能会存在条件竞争,造成死锁
- rehashing 是比较耗时操作
hashCode()确定哈希桶数组索引位置
)]
方法一:
static final int hash(Object key) { //jdk1.8 & jdk1.7
int h;
// h = key.hashCode() 为第一步 取hashCode值
// h ^ (h >>> 16) 为第二步 高位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
方法二:
static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样
return h & (length-1); //第三步 取模运算
}
为什么要先高 16 位异或低 16 位再取模运算?
- 可以在数组 table 的 length 比较小的时候,也能保证考虑到高低 Bit 都参与到 Hash 的计算中,同时不会有太大的开销。
- 降低 hash 冲突的几率
打个比方, 当我们的 length 为 16 的时候,哈希码(字符串“abcabcabcabcabc”的 key 对应的哈希码)对(16-1)与操作,对于多个 key 生成的 hashCode,只要哈希码的后 4 位为 0,不论不论高位怎么变化,最终的结果均为 0。
如下所示
1954974080(HashCode) | 111 0100 1000 0110 1000 1001 1000 0000 |
---|---|
2^4 - 1 = 15(length - 1) | 000 0000 0000 0000 0000 0000 0000 1111 |
&运算 | 000 0000 0000 0000 0000 0000 0000 0000 |
而加上高 16 位异或低 16 位的“扰动函数”后,结果如下
源HashCode | 1954974080 | 111 0100 1000 0110 1000 1001 1000 0000 |
---|---|---|
(>>> 16)无符号右移16位 | 29830 | 000 0000 0000 0000 0111 0100 1000 0110 |
^运算 | 1955003654 | 111 0100 1000 0110 1111 1101 0000 0110 |
2^4 - 1 = 15 (length - 1) | 15 | 000 0000 0000 0000 0000 0000 0000 1111 |
&运算 | 6 | 000 0000 0000 0000 0000 0000 0000 0110 |
可以看到: 扰动函数优化前:1954974080 % 16 = 1954974080 & (16 - 1) = 0 扰动函数优化后:1955003654 % 16 = 1955003654 & (16 - 1) = 6 很显然,减少了碰撞的几率。
Hashmap 的 get/put 的过程
put 过程
①.判断键值对数组 table[i] 是否为空或为 null,否则执行 resize() 进行扩容;
②.根据键值 key 计算 hash 值得到插入的数组索引 i,如果 table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断 table[i] 的首个元素是否和 key 一样,如果相同直接覆盖 value,否则转向④,这里的相同指的是 hashCode 以及 equals;
④.判断 table[i] 是否为 treeNode,即 table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.遍历 table[i],判断链表长度是否大于 8,大于 8 的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现 key 已经存在直接覆盖 value 即可;
⑥.插入成功后,判断实际存在的键值对数量 size 是否超多了最大容量 threshold,如果超过,进行扩容。
public V put(K key, V value) {
// 对key的hashCode()做hash
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 步骤①:tab为空则创建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步骤②:计算index,并对null做处理
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 步骤③:节点key存在,直接覆盖value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 步骤④:判断该链为红黑树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 步骤⑤:该链为链表
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key,value,null);
//链表长度大于8转换为红黑树进行处理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// key已经存在直接覆盖value
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 步骤⑥:超过最大容量 就扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
get 过程
对 key 的 hashCode()做 hash 运算,计算 index;如果在 bucket 里的第一个节点里直接命中,则直接返回;如果有冲突,则通过 key.equals(k) 去查找对应的 Node;
- 若为树,则在树中通过 key.equals(k) 查找,O(logn);
- 若为链表,则在链表中通过 key.equals(k) 查找,O(n)。
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first; //如果是第一个节点直接返回
if ((e = first.next) != null) {
if (first instanceof TreeNode) //红黑树里查找
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do { //链表里遍历查找
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
加载因子为什么是 0.75/转红黑树的节点数为什么是 8?
理想情况下,在随机哈希码下,bucket 中的节点频率服从泊松分布
泊松分布适合于描述单位时间内随机事件发生的次数
- 0: 0.60653066
- 1: 0.30326533
- 2: 0.07581633
- 3: 0.01263606
- 4: 0.00157952
- 5: 0.00015795
- 6: 0.00001316
- 7: 0.00000094
- 8: 0.00000006
当桶中元素到达 8 个的时候,概率已经变得非常小[6*10^(-8)],也就是说用 0.75 作为加载因子,每个碰撞位置的链表长度超过 8 个是几乎不可能的。当桶中元素到达 8 个的时候,概率已经变得非常小,也就是说用 0.75 作为加载因子,每个碰撞位置的链表长度超过 8 个是几乎不可能的。
扩容机制
扩容(resize)就是重新计算容量,向 HashMap 对象里不停的添加元素,而 HashMap 对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然 Java 里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。
我们分析下 resize 的源码,鉴于 JDK1.8 融入了红黑树,较复杂,为了便于理解我们仍然使用 JDK1.7 的代码,好理解一些,本质上区别不大,具体区别后文再说。
1 void resize(int newCapacity) { //传入新的容量
2 Entry[] oldTable = table; //引用扩容前的Entry数组
3 int oldCapacity = oldTable.length;
4 if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
5 threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
6 return;
7 }
8
9 Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
10 transfer(newTable); //!!将数据转移到新的Entry数组里
11 table = newTable; //HashMap的table属性引用新的Entry数组
12 threshold = (int)(newCapacity * loadFactor);//修改阈值
13 }
这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer() 方法将原有 Entry 数组的元素拷贝到新的 Entry 数组里。
1 void transfer(Entry[] newTable) {
2 Entry[] src = table; //src引用了旧的Entry数组
3 int newCapacity = newTable.length;
4 for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
5 Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
6 if (e != null) {
7 src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
8 do {
9 Entry<K,V> next = e.next;
10 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
11 e.next = newTable[i]; //标记[1]
12 newTable[i] = e; //将元素放在数组上
13 e = next; //访问下一个Entry链上的元素
14 } while (e != null);
15 }
16 }
17 }
newTable[i]的引用赋给了 e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到 Entry 链的尾部(如果发生了 hash 冲突的话),这一点和 JDK1.8有区别,下文详解。在旧数组中同一条 Entry 链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。
下面举个例子说明下扩容过程。假设了我们的hash算法就是简单的用 key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组 table 的 size=2, 所以 key = 3、7、5,put 顺序依次为 5、7、3。在 mod 2以后都冲突在 table[1] 这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小 size 大于 table 的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize 成 4,然后所有的 Node 重新 rehash 的过程。
下面我们讲解下 JDK1.8 做了哪些优化。经过观测可以发现,我们使用的是 2 次幂的扩展(指长度扩为原来 2 倍),所以,元素的位置要么是在原位置,要么是在原位置再移动 2 次幂的位置。看下图可以明白这句话的意思,n 为 table 的长度,图(a)表示扩容前的 key1 和 key2 两种key确定索引位置的示例,图(b)表示扩容后 key1 和 key2 两种 key 确定索引位置的示例,其中 hash1 是 key1 对应的哈希与高位运算结果。
元素在重新计算 hash 之后,因为 n 变为 2 倍,那么 n-1 的 mask 范围在高位多 1bit(红色),因此新的 index 就会发生这样的变化:
因此,我们在扩充 HashMap 的时候,不需要像 JDK1.7 的实现那样重新计算 hash,只需要看看原来的 hash 值新增的那个 bit 是 1 还是 0 就好了,是 0 的话索引没变,是 1 的话索引变成“原索引+oldCap”,可以看看下图为 16 扩充为 32 的 resize 示意图:
这个设计确实非常的巧妙,既省去了重新计算 hash 值的时间,而且同时,由于新增的 1bit 是 0 还是 1 可以认为是随机的,因此 resize 的过程,均匀的把之前的冲突的节点分散到新的 bucket 了。这一块就是 JDK1.8 新增的优化点。有一点注意区别,JDK1.7 中 rehash 的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8 不会倒置。
JDK1.8 的 HashMap 与 1.7 的区别
- 由数组+链表的结构改为数组+链表+红黑树。
- 优化了高位运算的 hash 算法:h ^ (h >>> 16)
- 扩容后,元素要么是在原位置,要么是在原位置再移动2次幂的位置,且链表顺序不变。
HashMap1.7 为什么不安全?
- HashMap 在 rehash 的时候,这个会重新将原数组的内容重新 hash 到新的扩容数组中,在多线程的环境下,存在同时其他 put 操作,如果 hash 值相同,把值插入同一个链表,会因为头插法的特性造成闭环,导致在 get 时会出现死循环,所以 HashMap 是线程不安全的。
- 在多线程下,存在数据被覆盖导致不一致的情况。
hash 冲突你还知道哪些解决办法?
-
开放定址法:当关键字 key 的哈希地址 p=H(key)出现冲突时,以p为基础,产生另一个哈希地址 p1,如果 p1 仍然冲突,再以 p 为基础,产生另一个哈希地址 p2,…,直到找出一个不冲突的哈希地址 pi ,将相应元素存入其中。
-
链地址法:这种方法的基本思想是将所有哈希地址为 i 的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第 i 个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。
-
再哈希法:这种方法是同时构造多个不同的哈希函数:
Hi=RH1(key) i=1,2,…,k
当哈希地址 Hi=RH1(key)发生冲突时,再计算 Hi=RH2(key)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。
-
公共溢出区域法:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。
ConccurentHashMap
早期提高效率,将锁细粒度化,将每个 Segment 上锁,因此比 SychronizedMap 和 HashTable 这种效率要高
JDK1.8 之后用 CAS+synchronized 使锁更细化,结构和 HashMap 一样,采用数组+链表+红黑树,对每个table[]的首结点上锁
- 使用无锁操作 CAS 插入头结点,失败则循环重试
- 如果头结点已经存在,那么就将头结点(table[])上锁,再进行操作
HashMap、HashTable、ConccurentHashMap的区别
- HashMap 线程不安全的,数组+链表+红黑树
- HashTable 是线程安全的,数组+链表
- ConccurentHashMap 是线程安全的,CAS+同步锁,数组+链表+红黑树
- HashMap 的 key 可以是 null 的,而HashTable 和 ConccurentHashMap 不支持
IO机制
BIO
Block-IO:InputStream 和 OutputStream(字节流), Reader 和 Writer(字符流)
扩展阅读:BIO编程与其局限性
NIO
NonBlock-IO:构建多路服用的,同步非阻塞的 IO 操作
用户进程第一个阶段不是阻塞的,需要不断的主动询问kernel数据好了没有;第二个阶段依然总是阻塞的。
扩展阅读:1. NIO Buffer
多路复用
IO复用同非阻塞IO本质一样,不过利用了新的select系统调用,由内核来负责本来是请求进程该做的轮询操作。看似比非阻塞IO还多了一个系统调用开销,不过因为可以支持多路IO,才算提高了效率。
它的基本原理就是select /epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:
当用户进程调用了select
,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个 socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。(多说一句。所以,如果处理的连接数不是很高的话,使用 select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。
select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被 block的。只不过process是被select这个函数block,而不是被socket IO给block。
select、poll、epoll 的区别
用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都 完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。 在这整个过程中,进程完全没有被block。
AIO
这类函数的工作机制是告知内核启动某个操作,并让内核在整个操作(包括将数据从内核拷贝到用户空间)完成后通知我们。如图:
属性\模型 | BIO | NIO | AIO |
---|---|---|---|
Blocking | 阻塞并同步 | 非阻塞但同步 | 非阻塞并异步 |
线程数(server:client) | 1:1 | 1:N | 0:N |
复杂度 | 简单 | 中等 | 复杂 |
吞吐量 | 低 | 高 | 高 |
扩展阅读:Unix五中IO模型
二、JVM
ClassLoader
作用
CLassLoader 负责在 Class 加载过程中装载的阶段,将系统外部的 Class 二进制流加载到系统内部,然后交给 JVM 进行链接和初始化等操作
种类
- BootStrapClassLoader: C++编写的用于加载核心库
- ExtClassLoader: Java 编写用于加载扩展库
- AppClassLoader: Java 编写用于加载程序所在目录
- 自定义 ClassLoader: Java 编写用于自定义类加载的方法(重写 findClass()和 defineClass())
流程
自底向上检查类是否加载,自顶向下尝试加载类
为什么需要双亲委派模型
- 避免不同类加载器加载同一个类,也就是避免多次保存相同的字节码
- 保证上层比如 Object 的类在不同类加载环境下是同一个
类加载的方式
- 隐式加载:new
- 显式加载:loadClass,forName
- 区别:显式加载通过 newInstance() 来实例化,而 new 不需要 newInstance(),并且隐式加载可以传入构造器参数实例化,显示加载需要用到反射传参得到实例化类
loadClass() 和 forName() 的区别
- forName() 得到的是初始化过的类
- loadClass() 得到的只是完成加载步骤的类,还没有执行链接和初始化步骤
forName 例子:MySQL 的 JDBC 连接过程,Driver 中会有 static 代码块,里面就是注册 JDBC 的代码段
loadClass 例子:Spring 的延时加载,快速加载 Bean
简述强、软、弱和虚引用
强引用
- 最普遍的引用:Object obj = new Object()
- 不会强制回收强引用的内存,即使抛出 OOM 异常
- 通过将 obj = null 来弱化引用,使之被回收
软引用
- 对象处在有用但不是必须的作态
- 内存不足时会被 GC
- 可以用来实现高速缓存
可以用 new SoftReference<>(strong ref)
包装强引用编程软引用
引用类型 | 被垃圾回收时间 | 用途 | 生存时间 |
---|---|---|---|
强引用 | 从来不会 | 对象的一般状态 | JVM运行期间 |
软引用 | 内存不足时 | 对象缓存 | 内存不足时终止 |
弱引用 | 垃圾回收时 | 对象缓存 | GC运行后终止 |
虚引用 | Unknown | 标记、哨兵 | Unknown |
Java内存模型
公有:方法区,堆(包括常量池)
- 方法区:用于存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的结果。
- 运行时常量池:用于存放编译器生成的各种字面量和符号引用
- 堆:存放对象实例,”几乎所有“对象实例都存放在堆中。
私有:程序计数器,虚拟机栈,本地方法栈
- 程序计数器:通过改变计数器来获取下一条字节码指令。分支、循环、跳转、异常处理、线程回复等都依赖计数器
- 虚拟机栈:描述 Java 方法执行的线程内存模型,其中由一个个栈帧组成,每个栈帧包括:
- 局部变量表
- 操作数栈
- 动态链接
- 方法出口
- 本地方法栈:调用本地(Native)方法
堆和栈的联系
JVM 三大性能调优参数-Xmx,-Xms,-Xss
- -Xss:规定每个线程的虚拟机栈的大小
- -Xms:堆的初始值
- -Xmx:堆的最大值
finalize()方法
当系统执行 GC 时会执行的方法,通过重新引用可以给对象不被回收的机会
GC
判断对象是否是垃圾:
- 引用计数法:有个对象引用它计数器就 +1,如果引用失效就 -1,计数器值为 0 就说明是垃圾。但是很难判断两个对象互相引用的情况,导致内存泄露
- 可达性分析算法:判断对象是否有 GC ROOT 的引用链,没有就是垃圾
GC ROOT:
- 方法区中类静态属性引用的对象
- 方法区中的常量引用的对象
- 虚拟机栈中就是本地变量表中所引用的对象
- 本地方法栈中 Native 方法引用的对象
- 被同步锁(synchronized 关键字)持有的对象
- Java 虚拟机内部的引用,比如 Class 对象,常驻异常 NPE,OOM
简述垃圾收集算法以及各自的特点
-
标记-清除算法:标记存活的对象,统一清理垃圾对象
缺点:容易产生垃圾碎片
-
标记-复制算法:分为对象面和空闲面。标记之后,将可存活的对象复制到空闲面,对对象面进行的清理(适用于存活率低的对象——年轻代)
-
标记-整理算法:标记之后,将存活的对象整理到一端,清理边界之外的内存。适用于老年代,因为移动对象的过程需要 STW(Stop the world)过程,耗费时间长。
-
分代-收集理论:分为 Eden 区和 Surivor(From,To) 区,比例是 8:2(1:1)
常见的垃圾收集器
CMS
采用标记-清除算法
垃圾回收的过程
- 初始标记(STW):从 GC ROOT 标记直接节点
- 并发标记:从上次标记过的节点继续向下标记
- 重新标记(STW):标记并发标记过程中引用链变化的对象
- 并发清理:清理垃圾对象
- 重启线程:重置 CMS 垃圾收集器
G1
用复制+标记-整理算法,可以用于年轻代和老年代
将 Region 作为回收的最小单元,G1 跟踪各个 Region 里面的垃圾堆积的”价值“大小(空间大小,所需时间),并维护一个优先列表,优先处理优先级高的垃圾。
- 将 Java 堆内存分为多个大小相同的 Region 区域
- 年轻代和老年代不再物理隔离
垃圾回收的过程:
- 初始标记:从 GC ROOT 标记直接节点
- 并发标记:从上次标记过的节点继续向下标记
- 最终标记:用于处理并发标记过程快照后遗留下来的少量垃圾
- 筛选回收:按照“价值”将 Region 排序并回收
三、Java 并发
进程和线程区别与联系
进程是计算机资源分配的最小单位,线程是 CPU 调度的最小单位
- 一个进程可以有多个线程,并且共享一个线程的资源
- 进程有独立的地址空间,互不影响
- 线程没有独立的地址空间,因此进程的健壮性比线程高
- 进程的切换比线程的开销大
一个进程对应一个 JVM 实例,多个线程共享一个 JVM
Java 用单线程执行模型,一个线程可以创建多个子线程
Thread 中 start()和 run()的区别
- *start()*方法:调用 native 方法——*start0()*来告诉 JVM 创建一个新的子线程并启动
- *run()*方法:调用 Thread 类实现的 Runnable 接口的普通方法启动线程
为什么不直接调用 run() 方法?
***run()*方法负责创建新线程后执行的操作,而 *start()*方法负责创建新线程等一系列工作,也就是说执行 run()方法不能创建新的线程。
Thread 和 Runnable
- Thread 实现了 Runnable 接口的 run() 方法的类,使 run 支持多线程
- 因为 Java 是单继承的,所以更加推荐实现 Runnable 接口
创建线程有哪几种方式
①. 继承 Thread 类创建线程类
- 定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。
- 创建Thread子类的实例,即创建了线程对象。
- 调用线程对象的start()方法来启动该线程。
②. 实现 Runnable 接口创建线程类
- 定义Runnable 接口的实现类,并重写该接口的 run()方法,该 run()方法的方法体同样是该线程的线程执行体。
- 创建 Runnable 实现类的实例,并依此实例作为 Thread 的 target 来创建 Thread 对象,该 Thread 对象才是真正的线程对象。
- 调用线程对象的 start()方法来启动该线程。
③. 通过 Callable 和 Future 创建线程
- 创建 Callable 接口的实现类,并实现 call() 方法,该 call() 方法将作为线程执行体,并且有返回值。
- 创建 Callable 实现类的实例,使用 FutureTask 类来包装 Callable 对象,该 FutureTask 对象封装了该 Callable 对象的 call()方法的返回值。
- 使用 FutureTask 对象作为 Thread 对象的 target 创建并启动新线程。
- 调用 FutureTask 对象的 get() 方法来获得子线程执行结束后的返回值。
④. 通过线程池创建线程
- 调用 Executors 工具类创建线程池。
- Runnable 的匿名内部类创建线程。
- 结束要调用 shutdown 关闭线程池。
实现处理线程的返回值
- 主线程等待法:自己实现等待逻辑,例如一个变量还没有来得及赋值就循环等待一段时间,直至有值后跳出循环。但是没法做到有值时立马执行下面的代码
public class CycleWait implements Runnable {
private String value;
public void run() {
try{
Thread.currentThread().sleep(5000);
}catch(Execption e){
e.printStackTrace();
}
value = "value";
}
public static void main(String[] args) {
Thread thread = new Thread(new CycleWait());
while(thread.value == null) {
Thread.currentThread().sleep(1000);
}
thread.start();
System.out.println(thread.value);
}
}
- join() 方法:阻塞当前线程以等待子线程处理完毕。缺点:还存在值已经被赋值了,不及时返回的情况
- Callable 接口实现:通过 FutureTask 传入实现 Callable 接口的对象或者线程池获取返回值
Runnable 和 Callable 有什么区别
- Runnable 接口中的 run()方法的返回值是 void,它做的事情只是纯粹地去执行 run()方法中的代码
- Callable 接口中的 call()方法是有返回值的,是一个泛型,和 Future、FutureTask 配合可以用来获取异步执行的结果
线程的状态
线程状态 State 用枚举表示
- New:创建还未启动的状态
- Runnable:包括 Ready 和 Running
- Waiting:无限期等待,需要显示唤醒,不会分配 CPU 执行
- 没有设置 Timeout 参数的 Thread.join 方法
- 没有设置 Timeout 参数的 Object.wait 方法
- LockSupport.park() 方法
- Timed Waiting:在执行一段时间后系统自动唤醒,不会分配 CPU 执行
- 调用 Thread.sleep() 方法
- 设置 Timeout 参数的 Thread.join() 方法
- 设置 Timeout 参数的 Object.wait() 方法
- LockSupport.parkNanos()
- LockSupport.parkUntil()
- Blocked:阻塞状态,等待获取排它锁
- Terminated:线程结束状态
线程的各种状态的切换
- 得到一个线程类,new 出一个实例线程就进入 new 状态(新建状态)。
- 调用 start 方法就进入 Runnable(可运行状态)。
- 如果此状态被操作系统选中并获得时间片就进入 Running 状态。
- 如果 Running 状态的线程的时间片用完或者调用 yield 方法就可能回到 Runnable 状态。
- 处于 Running 状态的线程如果在进入同步代码块/方法就会进入 Blocked 状态(阻塞状态),锁被其它线程占有,这个时候被操作系统挂起。得到锁后会回到 Running 状态。
- 处于 Running 状态的线程如果调用了 wait/join/LockSupport.park() 就会进入等待池(无限期等待状态), 如果没有被唤醒或等待的线程没有结束,那么将一直等待。
- 处于 Running 状态的线程如果调用了 sleep(睡眠时间)/wait(等待时间)/join(等待时间)/ LockSupport.parkNanos(等待时间)/LockSupport.parkUntil(等待时间)方法之后进入限时等待状态,等待时间结束后自动回到原来的状态。
- 处于 Running 状态的线程方法执行完毕或者异常退出就会进入 Terminated 状态。
sleep()和 wait()的区别
- wait 会释放对象锁,而 sleep 不会
- sleep 是 Thread 类的方法,wait 是 Object 的方法
- sleep()在任何地方都可以使用,wait()只能在 synchronized 方法或者 synchronized 块中使用
- sleep 需要捕获异常,wait 不需要
- 本质:
- Thread.sleep()不会导致锁行为的改变,只会让出 CPU
- Object.wait()会释放已占用的同步资源锁,也会让出 CPU
notify()和 notifyAll()有什么区别
锁池 EntiyList:当一个线程需要调用调用此方法时必须获得该对象的锁,而该对象的锁被其他线程占用,该线程就需要在一个地方等待锁释放,这个地方就是锁池。(准备抢锁的池子)
等待池 WaitSet:调用了 wait 方法的线程会释放锁并进入等待池,在等待池的线程不会竞争锁。(休息的池子)
- notifyAll 会让所有处于等待池的线程全部进入锁池去竞争获取锁的机会
- notify 只会随机选取一个处于等待池中的线程进入锁池去竞争获取锁的机会
yield()
调用 Thread.yield()
会给线程调度器一个当前线程愿意让出 CPU 的 hint,但是线程调度器也有可能会忽略。yield 不会让出当前占用的锁
中断线程 interrupt()
调用 interrupt()通知线程应该中断了
- 情况一:如果通知的线程正处于阻塞 Blocked 状态,被通知后就立即退出阻塞态并抛出 InterruptException 异常
- 情况二:如果线程正处于运行状态,被通知后就仅仅将阻塞态的标志位置为 true
什么是上下文切换?
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式(程序计数器)。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
i++ 是线程安全的吗?
- 线程私有:局部变量肯定是线程安全的(原因:方法内局部变量是线程私有的)
- 多个线程公有:成员变量多个线程共享时,就不是线程安全的(原因:成员变量是线程共享的,因为 i++ 是三步操作)
synchronized
保证在同一时刻最多只有一个线程执行该代码,以达到保证并发安全的效果。
sychronized 保证互斥性和可见性(保证释放锁之前,对共享变量的修改对其他线程是可见的)
-
对象锁:包括方法锁(默认锁对象为 this)和同步代码块锁(自己指定锁对象),锁是括号内的实例对象
-
同步代码块
//1 synchronized(this) {}
//2 synchronized(object) {}
-
同步非静态方法,锁是当前对象的实例对象
synchronized method(){}
-
-
类锁:synchronized 修饰的静态方法或指定锁为 Class对象。
如果不同实例有相同的 Class 对象,那么即便是 new 出来不同的实例对象也是同步的
-
同步代码块,锁的是括号内的对象
sychronized (Object.class){}
-
同步静态方法,所当前对象的类对象(Class对象)
sychronized static method(){}
-
联系:类锁是一种特殊的对象锁;
区别:类锁和对象锁是互不干扰的
synchronized 的实现原理
对象在内存中分为对象头,实例数据和对齐填充三个区域。在对象头中保存了锁标志位和指向 Monitor 对象的起始地址。当 Monitor 被某个线程占用后就会处于锁定状态。 synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 synchronized 修饰的方法使用是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
JDK1.6 之后的 synchronized 关键字底层做了一些优化
偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化
无锁、偏向锁、轻量级锁、重量级锁
Lock 接口
Lock接口一般是通过同步器(AbstractQueueSysnchronizer)的子类完成线程访问控制,常见的就是ReentrantLock。
Lock 提供 synchronized 不具备的特性
特性 | 描述 |
---|---|
尝试非阻塞地获取锁 | 当前线程尝试获取锁,如果这一刻没有其他线程获取该锁,则成功获取并持有锁 |
能被中断地获取锁 | 获取到锁的线程能够相应中断,当获取到所得线程被中断时,抛出中断异常,同时释放锁 |
超时获取锁 | 在指定的截止时间之前获取锁,如果截止时间还无法获取则返回 |
AbstractQueueSynchronizer
队列同步器是用来构建锁或其他同步组件的基础框架,用一个 int 变量表示同步状态,用 FIFO 队列来完成资源获取线程的排队。同步器简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。
ReentrantLock
- lock():获得锁
- lockInterruptibly():获得锁,但优先响应中断
- tryLock():尝试获得锁
- tryLock(long time, TimeUnit unit):在给定时间内尝试获得锁
- unlock():释放锁
ReentrantLock 如何实现公平和非公平锁
公平锁需要系统维护一个有序队列,获取锁时会判断阻塞队列里是否有线程再等待,若有获取锁就会失败,并且会加入阻塞队列。
非公平锁获取锁时不会判断阻塞队列是否有线程再等待,所以对于已经在等待的线程来说是不公平的,但如果是因为其它原因没有竞争到锁,它也会加入阻塞队列。
进入阻塞队列的线程,竞争锁时都是公平的,因为队列为先进先出(FIFO)。
public ReentrantLock(boolean fair) //设置公平锁
synchronized 和 ReentrantLock 区别
- synchronized 是关键字,后者为类
- synchronized 通过操作 Mark Word 实现同步,ReentrantLock 用 Unsafe 的 park()方法
- synchronized 会自动释放锁,Lock 需在 finally 中手工释放锁(unlock()方法释放锁),否则容易造成线程死锁。
- ReentrantLock 更加灵活,提供了超时获取锁,可中断锁,在获取不到锁的情况会自己结束,而synchronized不可以
- synchronized 是不公平的,ReentrantLock 除了不公平外还可以实现公平的线程调度(按照线程在队列中的排队顺序,先到者先拿到锁)
ReadWriteLock
读写锁允许多个线程同时读,但是写与其他操作任然是互斥的。
读 | 写 | |
---|---|---|
读 | 非阻塞 | 阻塞 |
写 | 阻塞 | 阻塞 |
Atomic类
所谓原子类说简单点就是具有原子/原子操作特征的类。
基本数据类型的原子类
- AtomicLong / AtomicInteger / AtomicBoolean:通过底层工具类 unsafe 类实现,基于 CAS。unsafe 类提供了类似 C 的指针操作,都是本地方法。
- LongAdder / LongAccumulator:基于 Cell 实现,基于分段锁思想,是一种以空间换时间的策略,适合高并发场景。
- AtomicReference:引用类型原子类,用于原子性对象的读写。
- AtomicStampedReference / AtomicMarkableReference:解决 ABA 问题的类
Atomic 类如何保证原子性
CAS,Compare and Swap 即比较并交换。主要利用 CAS + volatile 和 unsafe 类的底层 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。
CAS 可能会导致什么问题?
-
ABA 问题
CAS 需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是 A,后来变成了 B,然后又变成了 A,那么 CAS 进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
- JDK从1.5开始提供了
AtomicStampedReference
类来解决 ABA 问题,具体操作封装在compareAndSet()
中。compareAndSet()
首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
- JDK从1.5开始提供了
-
循环时间长开销大
CAS 操作如果长时间不成功,会导致其一直自旋,给 CPU 带来非常大的开销。
-
只能保证一个共享变量的原子操作
对一个共享变量执行操作时,CAS 能够保证原子操作,但是对多个共享变量操作时,CAS 是无法保证操作的原子性的。
volatile 作用
在多线程开发中保证了共享变量的“可见性”
volatile 告诉编译器它修饰的变量会不断的被修改,编译器就会通过强制主内存读写同步,防止指令重排序来保证原子性,可见性和有序性。但不能代替锁,不能保证 i++ 这种复合操作的原子性。
Java 中是如何实现线程同步的?
- 同步方法 synchronized 关键字修饰的方法(悲观锁)
- 使用特殊域变量(volatile)实现线程同步(保持可见性,多线程更新某一个值时,比如说线程安全单例双检查锁)
- ThreadLocal(每个线程获取的都是该变量的副本)
- 使用重入锁实现线程同步(相对 synchronized 锁粒度更细了,效率高)
- java.util.concurrent.atomic 包 (乐观锁):方便程序员在多线程环境下,无锁的进行原子操作
happens-before
- 如果操作 A happens-before 操作 B,那么 A 的执行结果对 B 一定是可见的,并且 A 在 B 之前
- JVM 重排序后的结果与 happens-before 的结果一样,那么也是合法的
volatile 和 sychronized 区别?
- volatile 本质告诉 JVM 当前变量的值是不确定的,需要到主内存中取;sychronized 是锁定当前变量,只有当前线程可以访问,其他线程访问时会被阻塞直至当前线程的变量操作完成。
- volatile 是变量级别,sychronized 可以用在变量,方法,类级别。
- volatile 仅能实现变量的修改可见性,对于一行代码有多条指令的复合操作不能保证原子性。sychronized可以实现原子性和可见性。
- volatile 不能造成线程的阻塞,sychronized 可以。
- volatile 标记的变量不能被编译器优化,sychronized 的会被优化。
乐观锁和悲观锁的区别?
悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。
Java中,synchronized 关键字和 Lock 的实现类都是悲观锁。
乐观锁
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和 CAS 算法实现。
- 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
- 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
Java 中有哪些锁?
线程池
有哪几种创建线程池的方式?
① newFixedThreadPool(int nThreads)
创建一个固定线程数量的线程池,每当提交一个任务就创建一个线程,直到达到线程池的最大数量,这时线程规模将不再变化,新的任务会暂存在任务队列中,待有线程空闲时便处理任务。
② newCachedThreadPool()
创建一个可缓存的线程池,如果线程池的规模超过了处理需求,将自动回收空闲线程,而当需求增加时,则可以自动添加新线程,线程池的规模不存在任何限制。
③ newSingleThreadExecutor()
该方法返回一个只有一个线程的线程池。若多余的任务提交到线程池中,任务将保存在任务队列中,它的特点是能确保依照任务在队列中的顺序来串行执行,适用于保证异步执行顺序的场景。
④ newScheduledThreadPool(int corePoolSize)
创建了一个固定长度的线程池,以定时的方式来执行任务,适用于定期执行任务的场景。
⑤ newSingleThreadScheduledExecutor()
线程池大小为1,周期性执行某个任务。
⑥ newWorkStealingPool
使用ForkJoinPool ,多任务队列的固定并行度,适合任务执行时长不均匀的场景。
场景:大量短期的任务场景适合使用 Cached 线程池,系统资源比较紧张时使用固定线程池。慎用无界队列,有OOM风险。
线程池有哪些参数?
上述 1, 2, 3 种线程池都是通过 ThreadPoolExecutor
并传入相应参数创建的对象。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
- corePoolSize:指定线程池中的线程数量。
- maximumPoolSize:线程池中最大线程数量
- keepAliveTime:当前线程池线程大于 corePoolSize 时,多余空闲线程的存活时间。
- unit:keepAliveTime 的单位
- workQueue:任务队列,被提交但未被执行的任务。
- threadFactory:线程工厂,用于创建线程。
- handler:拒绝策略。当任务多过来不及处理时,如何拒绝任务。