Java基础知识大全
1. JAVA基础
1.1 Java基本类型有哪些?它们分别占用多少字节?
Java中的基本类型包括:
- byte(1字节)
- short(2字节)
- char(2字节)
- int(4字节)
- float(4字节)
- double(8字节)
- long(8字节)
- boolean(未定,和具体Java虚拟机实现有关)
关于boolean的占用大小,事实上,1 bit是计算机存储的最小单位,用它来表示boolean的语义也完全足够。但是考虑到计算机处理数据的最小单位是1byte,因此实际的存储空间至少为1byte。而在《Java虚拟机规范》一书中描述,对于当下32位的处理器(CPU)来说,一次处理数据是32位,32 位 CPU 使用 4 个字节是最为节省的,哪怕你是1 bit信息也需要占用 4 个字节。因为 CPU 寻址系统只能 32 位 32 位地寻址,具有高效存取的特点。
1.2 什么是Java装箱和拆箱?其原理?
Java中每一种基本数据类型都对应着一种包装器类型。装箱是指Java能够自动将基本数据类型自动转化成包装器类型。拆箱是指Java能够自动将包装器类型转化为基本数据类型。例如:
Integer i = 10; //装箱
int j = i; //拆箱
装箱是自动调用了Integer.valueOf(int i)
方法。拆箱则是调用了Integer.intValue(Integer i)
方法。
阅读下面一段代码:
public class Main {
public static void main(String[] args) {
Integer i1 = 100;
Integer i2 = 100;
Integer i3 = 200;
Integer i4 = 200;
System.out.println(i1==i2);
System.out.println(i3==i4);
}
}
其输出值为:
true
false
参考Integer.valueOf(int i)
的实现:
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
可以看到当i的范围在[-128, 127]时,Integer直接指向IntegerCache中的对象。否则,创建一个新的Integer对象。
而Double.valueOf(double d)
的实现如下:
public static Double valueOf(double d) {
return new Double(d);
}
因此装箱后的Double对象的比较总是返回false。
下面是Boolean.valueOf(boolean b)
的实现:
public static Boolean valueOf(boolean b) {
return (b ? TRUE : FALSE);
}
因此装箱后的Boolean对象的比较总是返回true。
1.3 String,StringBuffer和StringBuilder的区别
String类是不可变类,任何对于String对象的改变都会生成一个新的对象。StringBuffer和StringBuilder都是可变类,任何修改都会造成其内部的char数组的改变。StringBuffer是线程安全的,而StringBuilder则是线程不安全的。
1.4 Java类加载机制
1.5 LocalThread
1.6 引用类型
从JDK 1.2版本开始,把对象的引用分为4种级别,从而使程序能更加灵活地控制对象的生命周期。这4种级别由高到低依次为:强引用、软引用、弱引用和虚引用。
- 强引用(StrongReference):强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。
- 软引用(SoftReference):如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。
- 弱引用(WeakReference):只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。
- 虚引用(PhantomReference):虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
1.7 hashcode的设计
Java 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;
}
关于hashcode的计算为何选取质数31,下面是一个解释:https://www.zhihu.com/question/24381016/answer/160483854
因此,一个常见的重写对象的hashcode的方法如下:
A、初始化一个整形变量,为此变量赋予一个非零的常数值,比如int result = 17;
B、选取equals方法中用于比较的所有域,然后针对每个域的属性进行计算:
- 如果是boolean值,则计算 f ? 1:0
- 如果是byte\char\short\int,则计算 (int)f
- 如果是long值,则计算
(int)(f ^ (f >>> 32))
- 如果是float值,则计算
Float.floatToIntBits(f)
- 如果是double值,则计算
Double.doubleToLongBits(f)
,然后返回的结果是long,再用计算long值hashcode的方法去处理long,得到hashcode - 如果是对象应用,如果equals方法中采取递归调用的比较方式,那么hashCode中同样采取递归调用hashCode的方式。否则需要为这个域计算一个范式,比如当这个域的值为null的时候,那么hashCode 值为0
- 如果是数组,那么需要为每个元素当做单独的域来处理。
java.util.Arrays.hashCode
方法包含了8种基本类型数组和引用数组的hashCode计算,算法同上。
C、最后,把每个域的散列码合并到对象的哈希码中。
下面是一个例子:
public class Person {
private String name;
private int age;
private boolean gender;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public boolean isGender() {
return gender;
}
public void setGender(boolean gender) {
this.gender = gender;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age &&
gender == person.gender &&
Objects.equals(name, person.name);
}
@Override
public int hashCode() {
int hash = 17;
hash = hash * 31 + getName().hashCode();
hash = hash * 31 + getAge();
return hash;
}
1.8 重写和重载
重写(Override)是子类对父类的允许访问的方法的实现过程进行重新编写, 返回值和形参都不能改变。
而重载(overloading) 是指在一个类里面,方法名字相同,而参数不同。返回类型可以相同也可以不同。
1.9 Happens-Before规则
1.10 NIO
2. Java集合类
2.1 ArrayList
ArrayList本质上是一个动态数组,它是线程不安全的,读写时间为O(1)。
它的内部数据结构主要为:
//默认初始数组大小:10
private static final int DEFAULT_CAPACITY = 10;
//用于存储数据的数组
transient Object[] elementData;
//记录size的变量
private int size;
transient表示elementData数组不参与序列化。然而在
writeObject
方法中,elementData数组又参与了序列化过程,这是为什么呢?因为elementData数组的length往往并不等于ArrayList.size,因此直接使用默认的序列化过程,会将elementData数组中的额外空间也序列化到输出流中。因此采用自定义writeObject
方法,手动从elementData数组中取出size个元素进行序列化。具体实现如下:
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException{
// Write out element count, and any hidden stuff
int expectedModCount = modCount;
s.defaultWriteObject();
// Write out size as capacity for behavioural compatibility with clone()
s.writeInt(size);
// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
add(E element), addAll(Collection<E> elements)方法总结:1. 首先判断是否越界,是否需要扩容。 2. 如果需要扩容,默认扩容当前大小的1.5倍。如果还不够,则直接扩容为所需要的最小size。然后复制数组。3. 修改modCount变量。
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;//在数组末尾追加一个元素,并修改size
return true;
}
private static final int DEFAULT_CAPACITY = 10;//默认扩容容量 10
private void ensureCapacityInternal(int minCapacity) {
//利用 == 可以判断数组是否是用默认构造函数初始化的
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;//如果确定要扩容,会修改modCount
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
//需要扩容的话,默认扩容一半
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);//默认扩容一半
if (newCapacity - minCapacity < 0)//如果还不够 ,那么就用 能容纳的最小的数量。
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
elementData = Arrays.copyOf(elementData, newCapacity);//拷贝,扩容,构建一个新数组
}
E Remove(int index)方法总结:1. 首先判断是否越界 2. 修改modCount,读出要删除的值,然后将[index + 1, size]内的所有元素复制到[index, size - 1]位置 3. 将下标为size-1的元素置为null 4. 返回被删除的元素
public E remove(int index) {
rangeCheck(index);//判断是否越界
modCount++;//修改modeCount 因为结构改变了
E oldValue = elementData(index);//读出要删除的值
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
modCount变量用于记录List被修改的次数,因此在使用Iterator遍历时调用List.remove(E element)时会抛出java.util.ConcurrentModificationException。原因在于调用List.remove(E element)后会修改modCount变量,而Iterator遍历时会判断当前modCount值是否和expectedModCount(第一次遍历时的modCount)相同,如果不相同,则抛出java.util.ConcurrentModificationException。
增删改查中, add(E element)会导致扩容,因此会修改modCount,remove(E element)也会修改数组,因此也会修改modCount。 set(int index, E element)和get(int index)则不会修改modCount。
Vector内部也通过数组实现,它和ArrayList别在于Vector在API上都加了synchronized,所以它是线程安全的,并且Vector扩容时,是翻倍size,而ArrayList则是扩容50%。
2.2 LinkedList
LinkedList是基于双向链表实现的有序序列,它可以在任何位置进行高效的插入和删除。
它的内部数据结构如下:
// 链表长度
transient int size = 0;
// 链表头节点
transient Node<E> first;
// 链表尾节点
transient Node<E> last;
其中链表节点的数据结构如下:
private static class Node<E> {
E item; // 此节点包含的数据
Node<E> next; // 下一个节点的引用
Node<E> prev; // 前一个节点的引用
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
下面是boolean add(E element)
方法的实现:
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final Node<E> l = last; // 获取尾部元素
final Node<E> newNode = new Node<>(l, e, null);
last = newNode; // 更新尾部节点为需要插入的节点
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
boolean add(E element)
方法总结:1. 创建一个新节点,将last节点设置为其前节点,后节点为null 2. 如果last节点为null,将first节点设置为当前节点 3. 将当前节点设置为last节点,并将当前节点设置为原有last节点的后节点 4. 链表长度加1
下面是E get(int index)
方法的实现:
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
Node<E> node(int index) {
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
由于链表不存在下标索引,因此要找出指定位置的元素,就需要遍历整个链表。这儿使用了一种加速手段,当index处于链表的前半段时,那么就从首节点开始遍历,反之则从尾节点遍历。
下面是E remove(int index)
方法的实现:
public E remove(int index) {
// 移除元素索引的合理性检查
checkElementIndex(index);
// 将节点删除
return unlink(node(index));
}
E unlink(Node<E> x) {
final E element = x.item; // 得到指定节点的值
final Node<E> next = x.next; // 得到指定节点的后继节点
final Node<E> prev = x.prev; // 得到指定节点的前继节点
// 如果prev为null表示删除是头节点,否则就不是头节点
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null; // 置空需删除的指定节点的前置节点(null)
}
// 如果next为null,则表示删除的是尾部节点,否则就不是尾部节点
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null; // 置空需删除的指定节点的后置节点
}
// 置空需删除的指定节点的值
x.item = null;
size--; // 数量减1
modCount++;
return element;
}
LinkedList与ArrayList在性能上各有优缺点,都有各自适用的地方,总结如下:
- ArrayList是实现了基于动态数组的数据结构,LinkedList基于链表的数据结构。
- LinkedList不支持高效的随机元素访问。
- ArrayList的空间浪费主要体现在在list列表的结尾预留一定的容量空间,而LinkedList的空间花费则体现在它的每一个元素都需要消耗相当的空间,就存储密度来说,ArrayList是优于LinkedList的。
- LinkedList能够或得更好的插入和删除表现,而ArrayList的随机读取表现更好。
2.3 HashMap
HashMap是一种底层为数组的哈希表实现。它是线程不安全的,允许key为null,value为null,遍历时无序。
它的内部数据结构如下:
transient Node<K,V>[] table; //哈希桶,存放链表。初始大小为16
transient int size; // HashMap中实际存在的Node数量
final float loadFactor; //加载因子,用于计算哈希表元素数量的阈值。默认值为0.75。
int threshold; //哈希表内元素数量的阈值,当元素数量超过阈值时,会触发扩容。threshold = 哈希桶.length * loadFactor
threshold是HashMap在此Load factor和length(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。
下面是哈希表数据类的定义,它的本质是一个单向链表:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //用来定位数组索引位置
final K key;
V value;
Node<K,V> next; //链表的下一个node
}
HashMap确定某个key所在哈希桶的位置方法如下:
//计算key的hash值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//根据hash值确定哈希桶位置
static int indexFor(int h, int length) {
return h & (length-1);
}
这里的Hash算法本质上就是三步:
(1) 取key的hashCode值:h = key.hashCode()
(2) 高位参与运算:h ^ (h >>> 16)
(3) 取模运算获得数组下标:h & (length-1)
首先我们先看HashMap如何根据一个hash值定位数组下标,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。
而为什么不直接让key.hashCode()作为hash值呢?因为计算h & (table.length -1)
时,由于length为2的n次方,因此table.length -1高位全部为0,以16为例,这样计算出来的index完全只保留hash值的低4位,所有hash的高位部分都丢失了。因此通过计算(h = key.hashCode()) ^ (h >>> 16)
,将自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性,从而尽可能的减少碰撞。这也被称为扰动函数。
更多关于hash函数的解释,请参考:https://www.zhihu.com/question/20733617
关于put函数的流程图如下:
put(K key, V value)函数流程图
扩容机制:
HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,然后对所有的元素进行rehash。
一个最简单的rehash思路是遍历所有的元素,一个一个重新计算hash值和数组下标,然后进行分配。然而Java8对rehash进行了优化。请看下面一个例子:Hash桶初始大小为4,现在有两个hash值为1和5的元素,它们都被分配到了table[1]中。计算下标的过程如下:
1:(0000 0001)&(0000 0011)= 1
5:(0000 0101)&(0000 0011)= 1
现在HashMap进行resize,那么哈希桶的大小变成了8,那么它们计算下标的过程如下:
1:(0000 0001)&(0000 0111)= 1
5:(0000 0101)&(0000 0111)= 5
我们可以看到,事实上由于HashMap进行resize,是原有size * 2,因此元素下标位置只可能产生两种变化,即元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置(即加上原有桶大小)。
因此,可以产生如下伪代码:
if (hash & oldCapacity == 0) {
//下标不变
} else {
//下标 = 原有下标 + oldCapacity
}
更多关于resize的内容,请参考:https://blog.csdn.net/login_sonata/article/details/76598675
2.4 LinkedHashMap
LinkedHashMap是HashMap的子类,它在HashMap的基础上维护了一个双向队列,因此能够按照顺序遍历Map。它的内部数据结构如下:
public class LinkedHashMap<K, V> extends HashMap<K, V> {
//链表头节点
transient LinkedHashMap.Entry<K,V> head;
//链表尾节点
transient LinkedHashMap.Entry<K,V> tail;
//遍历顺序,false表示按照插入顺序,true表示按照最近最少使用顺序
final boolean accessOrder;
}
下面是LinkedHashMap节点的数据结构:
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after; //前继节点和后继节点
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
可以看到LinkedHashMap节点本质上是一个双向链表。
LinkedHashMap在HashMap的基础上重写了其钩子方法,其中主要的钩子方法如下:
// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
//在取值后对参数accessOrder进行判断,如果为true,执行afterNodeAccess
if (accessOrder)
afterNodeAccess(e);
return e.value;
}
afterNodeAccess(Node<K,V> p)
方法用于将当前节点移动到双向队列尾部,因此accessOrder = true
时,通过afterNodeAccess(Node<K,V> p)
方法,就实现了LRU cache。
afterNodeRemoval(Node<K,V> e)
则是由remove方法调用的,它主要用于更新LinkedHashMap内部维护的双向队列。
afterNodeInsertion(boolean evict)
方法则是由put方法调用的,它的实现如下:
void afterNodeInsertion(boolean evict) {
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
我们可以看到,afterNodeInsertion(boolean evict)
方法用于删除队首元素。默认情况下removeEldestEntry(Map.Entry<K,V> eldest)
方法返回false,因此afterNodeInsertion(boolean evict)
方法不会删除任何元素。
我们可以通过重写removeEldestEntry(Map.Entry<K,V> eldest)
方法来实现一个LRU Cache:
class LRUCache extends LinkedHashMap<Integer, Integer> {
private int capacity;
public LRUCache(int capacity) {
super(capacity, 1, true);
this.capacity = capacity;
}
public int get(int key) {
if (containsKey(key)) {
return get(key);
}
return -1;
}
public void put(int key, int value) {
put(key, value);
}
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > capacity;
}
}
这样,每当这个数据量超出这个LRU Cache的capacity时,LinkedHashMap会删除队首元素。由于super(capacity, 1, true)
将accessOrder设置为了true,因此队首元素就是最近最少用元素,从而实现了一个简易的LRU Cache。
2.5 TreeMap
TreeMap是基于红黑树实现的key-value集合,它的元素是有有序的。
它的内部数据结构如下:
// 比较器对象
private final Comparator<? super K> comparator;
// 根节点
private transient Entry<K,V> root;
// 集合大小
private transient int size = 0;
// 树结构被修改的次数
private transient int modCount = 0;
// 静态内部类用来表示节点类型
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;
V value;
Entry<K,V> left; // 指向左子树的引用(指针)
Entry<K,V> right; // 指向右子树的引用(指针)
Entry<K,V> parent; // 指向父节点的引用(指针)
boolean color = BLACK; // 红黑flag
}
2.6 ConcurrentHashMap
ConcurrentHashMap是线程安全的HashMap,在Java 1.7中使用Segment分段锁实现。下面是ConcurrentHashMap在Java 1.7中的数据结构:
public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
implements ConcurrentMap<K, V>, Serializable {
// 将整个hashmap分成几个小的map,每个segment都是一个锁;与hashtable相比,这么设计的目的是对于put, remove等操作,可以减少并发冲突,对
// 不属于同一个片段的节点可以并发操作,大大提高了性能
final Segment<K,V>[] segments;
// 本质上Segment类就是一个小的hashmap,里面table数组存储了各个节点的数据,继承了ReentrantLock, 可以作为互拆锁使用
static final class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile HashEntry<K,V>[] table;
transient int count;
}
// 基本节点,存储Key, Value值
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
}
}
Java 1.7中,ConcurrentHashMap将数据分段存储,一个ConcurrentHashMap由多个Segment组成,每个Segment都有把锁,同时一个Segment下包含许多Node。一个Segment本质上就是一个继承了ReentrantLock的小的HashMap,因此锁粒度是以Segment为单位的,即以Hash桶的每个位置为单位进行锁操作。这样处于不同Hash段的元素可以并发操作。与HashTable相比,大大提高了性能。
在Java 1.8中,使用CAS操作对ConcurrentHashMap的实现进行了优化。其主要数据结构如下:
transient volatile Node<K,V>[] table; //存储Node
private transient volatile Node<K,V>[] nextTable; //扩容时存放数据,大小为table的2倍
private transient volatile int sizeCtl; //控制标志,具有多种用途
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
}
sizeCtl变量是一个用于同步多个线程的共享变量。当sizeCtl < 0时,表明当前HashMap正在被初始化(-1表示正在初始化)或者正在扩容中(-N表示有N-1个线程正在参与扩容)。如果为正数,则代表了需要扩容时的阀值(即capcity * 0.75)。
下面时HashMap初始化的源码:
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
我们可以看到,ConcurrentHashMap通过CAS支持了多个线程同步操作,初始化步骤如下:
1. 如果当前table为null或table.length==0,开始进行初始化。
2. 读取sizeCtl变量,如果小于0,则表明此HashMap正在进行初始化,此时调用Thread.yield()方法让出时间片。
3. 如果sizeCtl变量不小于0,那么通过CAS操作将sizeCtl置为-1,CAS操作成功后,对table数组进行初始化,并将sizeCtl设置为Capcity * 0.75,即下次扩容触发的阀值。
4. 此时其他希望进行初始化的线程读取到table不为null了,即初始化完毕。
put方法主要流程如下:
1. 首先通过(n - 1) & hash
获得下标位置,如果该位置为空,则使用CAS操作直接插入。
2. 如果该位置不为空,且该位置节点的hash值为-1,则代表该链表正在处于扩容阶段,此时放弃插入,直接调用helpTransfer(Node<K,V>[] tab, Node<K,V> f)
方法帮助扩容。
3. 否则,对该位置节点加锁(Synchronized),执行add操作。
4. 如果链表长度超过8个,则调用treeifyBin(Node<K,V>[] tab, int index)
方法将链表转化为红黑树。
扩容时ConcurrentHashMap会将一个ForwardingNode(hash值为-1)放置在原Node位置。这个ForwardingNode存储了
Node<K,V>[] nextTable
的引用。
treeifyBin
方法并不一定会将链表转化为红黑树。如果当前table.length < 64(MIN_TREEIFY_CAPACITY),此时调用tryPresize(n << 1)
方法,对table进行扩容。如果table.length >= 64,此时才会进行红黑树转化操作。
在
tryPresize(int size)
方法中,并没有加锁,允许多个线程进入,如果数组正在扩张,则当前线程也去帮助其扩容。扩容的核心逻辑位于transfer(Node<K,V>[] tab, Node<K,V>[] nextTab)
方法中。
默认情况下,每个线程处理 16 个桶。因此,如果table长度是 16 的时候,扩容的时候只会有一个线程扩容。如果table长度是 64 ,每个线程可以分到 16 个桶,各自处理,不会互相影响。
concurrenthashmap.jpg
下面时get方法的主要代码:
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;
}
get方法主要流程如下:
1. 首先计算出记录的key的hashCode,然后通过(n - 1) & hash
获得下标位置,如果该位置为null,则直接返回。
2. 如果该位置不为null,并且key与我们先要查找的key相等,则直接返回该位置节点的值。
3. 如果该节点的是TreeNode,则说明该位置上是一颗红黑树,则调用红黑树搜索逻辑返回节点值。
4. 否则说明该节点是一个链表,遍历链表,找到要查找的key对应节点,返回该节点的值。
计算Map大小的size方法实现如下:
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
我们看到,size()
方法调用了sumCount()
方法返回Map的大小。下面是sumCount()
方法的实现:
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;
}
static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
首先ConcurrentHashMap中维护了一个baseCount
变量用于记录size,每一次put新的元素时,ConcurrentHashMap都会通过addCount()
方法CAS更新baseCount
变量。然而在高并发情况下,很有可能出现put成功,而CAS更新baseCount
失败,那么这些节点虽然已经被添加到哈希表中了,但是数量却没有被统计。
因此,addCount()
方法在更新 baseCount
失败的时候,会调用fullAddCount()
方法将这些失败的结点包装成一个CounterCell
对象,并保存在private transient volatile CounterCell[] counterCells;
数组中。那么整张表实际的 size 其实是baseCount
加上CounterCell
数组中元素的值之和。
更过关于ConcurrentHashMap的源码分析,请参考:
- https://www.cnblogs.com/zerotomax/p/8687425.html#go7
- https://juejin.im/entry/59fc786d518825297f3fa968
- https://juejin.im/post/5b00160151882565bd2582e0
3. Java并发编程
3.1 Java对象头
Java对象头由8字节组成(数组对象则有12字节)。主要包含三部分:
1. Mark Word:4字节。存储hashcode或锁信息。
2. Class Metadata Address: 4字节。存储了指向对象数据类型的指针。
3. Array Length(数组对象才包含此部分):存储数组长度。
3.2 Synchronized关键字
synchronized锁升级过程synchronized锁升级过程
synchronized锁流程如下:
1. 检查MarkWord里面是不是自己的ThreadId ,如果是,表示当前线程是处于 “偏向锁”。
2. 如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,之前线程将Markword的内容置为空。
3. 两个线程都把对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作,把共享对象的MarKword的内容修改为自己新建的记录空间的地址的方式竞争MarkWord。
4. 第三步中成功执行CAS的获得资源,失败的则进入自旋。
5. 自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败。
6. 进入重量级锁的状态,这个时候,自旋的线程进行阻塞状态,等待前一个线程释放资源。
3.3 线程状态转化
Java线程状态图.JPGJava中的线程状态主要有6种:
1. 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
2. 运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
线程对象调用start()方法后,该线程就处于就绪(ready)状态。此时它等待被线程调度选中,获取CPU的使用权。就绪(ready)状态的线程在获得CPU时间片后才真正变为运行中状态(running)。
3. 阻塞(BLOCKED):表示线程由于无法获得锁而处于阻塞状态。此时所有被阻塞的线程处于一个同步队列中。
4. 等待(WAITING):线程同样处于阻塞状态,然而此时被阻塞的线程处于等待队列中。正在运行的线程调用Object.wait(),Thread.join()方法后,线程就会处于等待状态。
5. 超时等待(TIMED_WAITING):可以在指定的时间后自行返回的等待状态。
6. 终止(TERMINATED):表示该线程已经执行完毕。
Java中常用线程状态转换API的区别:
1. Thread.sleep(long millis):当前线程进入TIMED_WAITING状态,但不释放对象锁,millis后线程自动苏醒进入就绪状态。
2. Thread.yield():当前线程让出CPU时间片,但不释放锁资源,由运行状态变为就绪状态,此时由OS来分配此线程让出的时间片(可能依旧分配给此线程)。
3. thread.join()/thread.join(long millis):当前线程里调用其它线程t的join方法,当前线程进入WAITING/TIMED_WAITING状态,当前线程不会释放已经持有的对象锁。线程t执行完毕或者millis时间到,当前线程一般情况下进入RUNNABLE状态,也有可能进入BLOCKED状态(因为join是基于wait实现的)。
4. object.wait():当前线程调用对象的wait()方法,当前线程释放对象锁,进入等待队列。依靠notify()/notifyAll()唤醒或者wait(long timeout) timeout时间到自动唤醒。
5. LockSupport.park()/LockSupport.parkNanos(long nanos),LockSupport.parkUntil(long deadlines),:当前线程进入WAITING/TIMED_WAITING状态。对比wait方法,不需要获得锁就可以让线程进入WAITING/TIMED_WAITING状态,需要通过LockSupport.unpark(Thread thread)唤醒。
3.4 Lock
Java中的Lock接口提供了与synchronized关键字类似的功能,只是在使用时需要显示的获取和释放锁。下面是Lock接口的主要方法:
public interface Lock {
void lock(); //获取锁
void lockInterruptibly() throws InterruptedException; //可中断的获取锁;此方法会被Thread.interrupt()方法中断
boolean tryLock(); //非阻塞的获取锁;此方法会立刻返回,true表示成功获取了锁
boolean tryLock(long time, TimeUnit unit) throws InterruptedException; //在指定时间内获取锁
void unlock(); //释放锁
Condition newCondition(); //构造与Lock关联的condition,用于实现等待唤醒
虽然Lock接口缺少了(synchronized块或方法所提供的)隐式获取和释放锁的便捷性,但是却拥有了锁获取与释放的可操作性,可中断的获取锁以及超时获取锁等synchronized关键字所不具备的同步特性。
常用的Lock接口实现类包括ReentrantLock(可重入锁),ReentrantReadWriteLock.ReadLock和ReentrantReadWriteLock.WriteLock。
3.5 AbstractQueuedSynchronizer
队列同步器AbstractQueuedSynchronizer,使用来构建锁或者其他同步组件的基础框架。Java中Lock的实现类都是通过聚合一个重写的AbstractQueuedSynchronizer来实现的。
AbstractQueuedSynchronizer中常用的可重写的方法如下:
protected boolean tryAcquire(int arg):独占式的获取同步状态。实现该方法需要判断同步状态是否符合预期,然后进行CAS设置同步状态。
protected boolean tryRelease(int arg):独占式释放同步状态。等待获取同步状态的线程将有机会获取同步状态。
protected boolean tryAcquireShared(int arg):共享式获取同步状态。返回大于0的值,表示获取成功,反之,获取失败。
protected boolean tryReleaseShared(int arg):共享式释放同步状态
protected boolean isHeldExclusively():是否被当前线程独占
同步器提供的方法主要分为3类:独占式获取和释放同步状态,共享式获取和释放同步状态,查询同步队列中的等待线程情况。通过重写这些方法,就能实现一个同步组件。下面是ReentrantLock的实现:
public class ReentrantLock implements Lock {
private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer {
abstract void lock();
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
//omit...
}
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
/**
* Performs lock. Try immediate barge, backing up to normal
* acquire on failure.
*/
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
public void lock() {
sync.lock();
}
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
public boolean tryLock() {
return sync.nonfairTryAcquire(1);
}
//omit...
}
ReentrantLock内部通过NonfairSync,FairSync重写了AbstractQueuedSynchronizer,从而实现了公平可重入锁和非公平可重入锁。
重入锁(ReentrantLock),就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁还支持获取锁时的公平和非公平性选择。如果在绝对时间上,先对锁进行请求的锁先被满足,那么这个锁就是公平的,反之,就是不公平的。公平的获取锁,也就是等待时间最长的线程最优先获得锁,也可以说锁的获取是顺序的。事实上,公平锁往往没有非公平锁的效率高(公平锁造成大量的线程切换开销),但是公平锁能够减少“饥饿”发生的概率。非公平锁虽然可能造成线程饥饿,但极少的线程切换,保证了其更大的吞吐量。
下面是AbstractQueuedSynchronizer内部的主要数据结构:
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer {
private transient volatile Node head;
private transient volatile Node tail;
private volatile int state;
//omit...
static final class Node {
volatile Node prev;
volatile Node next;
volatile int waitStatus;
volatile Thread thread;
//omit...
}
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
//独占式获取锁
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
//独占式释放锁
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
//共享式获取锁
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg); // 执行获取锁失败的逻辑
}
//共享式释放锁
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared(); // 执行释放锁
return true;
}
return false;
}
//独占式获取锁方法,应通过子类重写
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
//独占式释放锁方法,应通过子类重写
protected boolean tryRelease(int arg) {
throw new UnsupportedOperationException();
}
//共享式获取锁方法,应通过子类重写
protected int tryAcquireShared(int arg) {
throw new UnsupportedOperationException();
}
//共享式释放锁方法,应通过子类重写
protected boolean tryReleaseShared(int arg) {
throw new UnsupportedOperationException();
}
}
AbstractQueuedSynchronizer的主要数据结构有:
1. volatile变量state,来进行多个线程间的数据共享,客户端通过boolean compareAndSetState(int expect, int update)
来控制boolean tryAcquire(int arg)
,tryAcquireShared(int arg)
等行为。
2. 内部维护了一个等待队列,通过volatile Node head
和volatile Node tail
头尾指针来访问。
AbstractQueuedSynchronizer还提供了一些模板方法和抽象方法,来屏蔽底层的线程排队和唤醒等待等操作。用户仅仅需要重写boolean tryAcquire(int arg)
,boolean tryRelease(int arg)
或tryAcquireShared(int arg)
,boolean tryReleaseShared(int arg)
就可以实现一个排他锁或共享锁。
下面是独占锁获取方法void acquire(int arg)
的主要实现:
1. 通过 tryAcquire(int arg) 方法尝试获取锁,这个方法需要实现类进行实现。返回值为true则方法直接结束,返回值为false则执行后面加入等待队列的逻辑。
**
- 如果tryAcquire(int arg) 方法返回false,则执行 addWaiter(Node.EXCLUSIVE) 方法将当前线程封装成一个 Node 节点对象,并加入队列尾部。**
3. 把当前线程执行封装成 Node 节点后,继续执行 acquireQueued 的逻辑。acquireQueued方法判断该节点的前置节点是否为head,如果是head,则重新调用 tryAcquire(int arg) 方法尝试获取锁。如果前置节点不为head或者它尝试获取锁失败,则通过LockSupport.park(this)
方法阻塞当前线程,从而实现线程等待。
如果当前节点的前置节点为head,那么很有可能head节点已经释放锁了。因此不直接进入等待队列,而是再调用一次tryAcquire(int arg) 尝试获取锁,获取失败再进入等待队列。
而独占锁释放方法boolean release(int arg)
的逻辑就比较简单了:
1. 首先调用boolean tryRelease(int arg)
方法,这个方法需要实现类进行实现。
2. 如果boolean tryRelease(int arg)
方法返回true,则通过unparkSuccessor(h)
开始唤醒等待队列中下一个节点。
下面是共享锁获取方法void acquireShared(int arg)
的实现:
1. 通过 tryAcquire(int arg) 方法尝试获取锁,这个方法需要实现类进行实现。返回值小于0则表示获取失败,执行doAcquireShared(int arg)
方法加入队列中。
2. doAcquireShared(int arg)
方法首先调用addWaiter(Node.SHARED)方法将当前线程封装成一个 Node 节点对象,并加入队列尾部。然后判断判断此节点的前置节点是否为head。如果是head节点,则调用tryAcquireShared(arg)
方法再次尝试获取锁,如果获取成功,则会调用 setHeadAndPropagate 方法同时唤醒后继节点,从而实现共享模式。如果获取锁失败,则通过LockSupport.park(this)
方法阻塞当前线程,从而实现线程等待。
setHeadAndPropagate 方法会将当前节点设置为新的头节点,再调用doReleaseShared方法唤醒后继节点,这是共享锁与独占锁最大的区别。独占锁获取锁之后就结束了,而共享锁则则会唤醒后继节点,后继节点继续尝试获取锁。而独占锁的释放也只会唤醒后继节点,而共享锁的释放则会遍历整个Node队列,然后通过
LockSupport.park(node.thread)
唤醒所有等待线程,被唤醒线程再重新开始新的锁竞争。
下面是共享锁释放方法boolean releaseShared(int arg)
的实现如下:
1. 首先调用boolean tryReleaseShared(int arg)
方法,这个方法需要实现类进行实现。
2. 如果boolean tryReleaseShared(int arg)
方法返回true,则执行doReleaseShared()
方法进行队列修改和线程唤醒操作。
3. doReleaseShared()
方法会从head节点开始遍历整个Node队列,通过unparkSuccessor(node)
方法依次唤醒队列中的等待线程。
更多关于AQS的实现原理,请参考:http://objcoding.com/2019/05/05/aqs-exclusive-lock/
3.6 ReadWriteLock
ReentrantLock是一种排它锁,它在同一时刻都只允许一个线程进行访问。而ReadWriteLock在同一时刻可以允许多个读线程进行访问,但在写线程访问时,所有的其他读线程和写线程均被阻塞。
ReadWriteLock内部维护了一个重写的队列同步器,一个读锁和一个写锁。读锁lock()时调用同步器的tryAcquireShared()方法,写锁lock()时调用同步器tryAcquire()方法。
下面是通过ReadWriteLock实现的一个线程安全的Cache:
public final class Cache {
private static final Map<String, Object> container = new HashMap<>();
private static final ReadWriteLock lock = new ReentrantReadWriteLock();
public static Object get(String key) {
lock.readLock().lock();
try {
return container.get(key);
} finally {
lock.readLock().unlock();
}
}
public static void put(String key, Object value) {
lock.writeLock().lock();
try {
container.put(key, value);
} finally {
lock.writeLock().unlock();
}
}
}
3.7 Condition
我们知道,任意一个Java对象都有一组监视器方法(wait,notify),这些方法与synchronized关键字配合,可以实现等待/通知模式。而在Lock接口中,Condition提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式。
下面是使用Condition await/signal实现的一个有界队列:
public class BoundedQueue<T> {
private Object[] elements;
private int addIndex;
private int removeIndex;
private int size;
private Lock lock = new ReentrantLock();
private Condition notFull = lock.newCondition();
private Condition notEmpty = lock.newCondition();
public void offer(T t) throws InterruptedException {
lock.lock();
try {
while (size == elements.length) {
notFull.await();
}
elements[addIndex] = t;
if (++addIndex == elements.length) {
addIndex = 0;
}
size++;
notEmpty.signal();
} finally {
lock.unlock();
}
}
public T poll() throws InterruptedException {
lock.lock();
try {
while (size == 0) {
notEmpty.await();
}
Object removed = elements[removeIndex];
if (++removeIndex == elements.length) {
removeIndex = 0;
}
size--;
notFull.signal();
return (T) removed;
} finally {
lock.unlock();
}
}
}
Condition与Object监视器方法相比,拥有两个额外特性:
1. 支持在等待状态中不响应中断:void awaitUninterruptibly()
2. 支持等待直至将来某个时间点:boolean awaitUntil(Date deadline) throws InterruptedException
3.8 ContdownLatch
CountDownLatch允许一个或多个线程等待其他线程完成操作。
CountDownLatch主要提供了两个API:
public CountDownLatch(int count);
public void countDown();
public void await() throws InterruptedException();
CountDownLatch通过构造器传入一个count值,每次调用countDown()方法count的值就会减一。在count值不为0时,所有调用了await方法的线程都会被阻塞,直到count的值为0为止。
CountDownlatch是通过聚合一个AQS实现的,下面是它的主要实现:
public class CountDownLatch {
/**
* Synchronization control For CountDownLatch.
* Uses AQS state to represent count.
*/
private static final class Sync extends AbstractQueuedSynchronizer {
private static final long serialVersionUID = 4982264981922014374L;
Sync(int count) {
setState(count);
}
int getCount() {
return getState();
}
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
protected boolean tryReleaseShared(int releases) {
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c-1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
}
private final Sync sync;
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
this.sync = new Sync(count);
}
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
public void countDown() {
sync.releaseShared(1);
}
public long getCount() {
return sync.getCount();
}
3.9 Semaphore
Semaphore(信号量)用来控制同时访问特定资源的线程数量。它通过协调各个线程,以保证多线程合理的使用公共资源。
Semaphore主要API如下:
public Semaphore(int permits);
public void acquire() throws InterruptedException;
public void release(int permits);
Semaphore通过构造器传入一个初始许可证数量,每次通过void acquire()
方法尝试访问同步资源,访问成功则计数器值减一;通过release(int permits)
退出同步资源,退出成功则计数器值加一。因此通过Semaphore,我们可以控制并行访问资源的线程数量。
下面是一个使用Semaphore的例子:
public class DatabaseWriter {
private static final int IO_THREAD_COUNT = 20;
private static final int MAX_DB_CONNECTION_COUNT = 10;
private static final ExecutorService threadPool = Executors.newFixedThreadPool(IO_THREAD_COUNT);
private static final Semaphore semaphore = new Semaphore(MAX_DB_CONNECTION_COUNT);
public static void main(String[] args) {
for (int i=0; i< IO_THREAD_COUNT; i++) {
threadPool.execute(() -> {
try {
semaphore.acquire();
System.out.println(Thread.currentThread() + " connect to database...");
semaphore.release();
} catch (Exception e) {
e.printStackTrace();
}
});
}
threadPool.shutdown();
}
}
Semaphore可以用作流量控制,特别是公共资源有限的应用场景。比如有一个需求,要读取几万个文件的数据,因为都是IO密集型任务,我们可以启动及时个线程并发的读取。但是读入内存后,要存储到数据库中,而数据库连接只有10个,这时我们必须控制只有10个线程可以同时获取到数据库连接保存数据。
Semaphore底层也是通过聚合一个AQS实现的,下面是它的主要实现:
public void acquire(int permits) throws InterruptedException {
if (permits < 0) throw new IllegalArgumentException();
sync.acquireSharedInterruptibly(permits);
}
public void release(int permits) {
if (permits < 0) throw new IllegalArgumentException();
sync.releaseShared(permits);
}
abstract static class Sync extends AbstractQueuedSynchronizer {
Sync(int permits) {
setState(permits);
}
protected boolean tryReleaseShared(int releases) {
for (;;) {
int current = getState();
int next = current + releases;
if (next < current) // overflow
throw new Error("Maximum permit count exceeded");
if (compareAndSetState(current, next))
return true;
}
}
protected int tryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
}
3.10 ThreadPoolExecutor
Java中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序都可以使用线程池。在开发过程中,合理使用线程池能够带来3个好处:
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立刻执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配,调优和监控。
下面是线程池的构造函数定义:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler);
创建一个线程池的主要参数有:
- int corePoolSize:线程池的基本大小。当提交一个任务到线程池时,即使存在空闲的线程,线程池也会创建一个新的线程来执行任务,除非当前线程池的线程数量已经超过corePoolSize。如果调用了prestartAllCoreThreads()方法,线程池会提前创建并启动所有的基本线程。
- int maximumPoolSize:线程池允许创建的最大线程数量。如果队列已满,并且创建的线程数量小于maximumPoolSize,则线程池会再创建新的线程执行任务。值得注意的是,如果使用了无界队列,那么这个参数就没有什么意义。
- long keepAliveTime(多余线程保持活动的时间):当线程池线程数量大于corePoolSize时,线程池的工作线程空闲后,保持存活的时间。
- TimeUnit unit:多余线程保持活动的时间的单位。
-
BlockingQueue<Runnable> workQueue:用于保存等待执行的任务的阻塞队列。可以选择如下几个阻塞队列:
- ArrayBlockingQueue:是一个基于数组的有界阻塞队列。按FIFO对元素进行排序。
- LinkedBlockingQueue:基于链表的阻塞队列。按FIFO对元素进行排序。吞吐量通常高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列。
- SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等待另一个线程调用移除操作,否则插入操作一直处于阻塞状态。吞吐量通常高于LinkedBlockingQueue。静态工厂方法Executors.newCachedThreadPool()使用了这个队列。
- PriorityBlockingQueue:一个具有优先级的无限阻塞队列。
-
ThreadFactory threadFactory:用于设置创建线程的工厂。ThreadFactory是一个仅仅拥有一个方法的接口:
Thread newThread (Runnable r);
-
RejectedExecutionHandler handler(饱和策略):当队列和线程池都满了,说明线程池处于饱和状态,那么必须采取一种策略处理新提交的任务。这个策略默认是AbortPolicy。JDK1.5提供了如下四种策略:
- AbortPolicy:直接抛出异常。
- CallerRunsPolicy:使用调用者所在的线程来运行任务。
- DiscardOldestPolicy:丢弃掉队列里最近的一个任务,并执行当前任务。
- DiscardPolicy:不处理,丢弃掉。
- 当然也可以根据应用场景来实现RejectedExceptionHandler接口自定义策略。如记录日志或者持久化不能存储的任务。
通常可以使用两种API向线程池提交任务:
//用于提交不需要返回值的任务
void execute(Runnable command);
//返回Future接口,通过调用其get()方法来获取返回值,get()方法会阻塞当前线程直到任务返回。
Future<T> submit(Callable<T> task);
另外,我们可以通过void shuntdown()
或者List<Runnable> shutdownNow()
方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后调用interrupt方法来中断线程,所以无法响应中断的任务可能永远无法停止。
void shuntdown()
方法将线程池的状态设为SHUTDOWN状态,可以达到拒绝任何新的任务的效果。但是不会对已经提交了的任务造成影响。
而List<Runnable> shutdownNow()
方法在void shuntdown()
方法的基础上,会尝试中断已经提交了的任务。如果任务忽略中断,那么效果和void shuntdown()
方法一样。List<Runnable> shutdownNow()
方法会返回线程池中所有处于等待状态的任务。
3.11 Executors
Executor框架主要由3大部分组成:
1. 任务。包括被执行的任务需要实现的接口:Runnable和Callable。
2. 任务的执行。包括任务执行机制的核心接口Executor,以及继承自Executor的ExecutorService接口。Executor框架有两个关键类实现了ExecutorService接口:ThreadPoolExecutor和ScheduledThreadPoolExecutor。
3. 异步计算的结果。包括接口Future和实现Future接口的FutureTask类。
ThreadPoolExecutor是线程池的核心实现类,用来执行被提交的任务。
ScheduledThreadPoolExecutor可以在给定的时间延迟后运行命令,或者定期执行命令。
Executors工具类可以创建3中不同类型的ThreadPoolExecutor:SingleThreadExecutor,FixedThreadPool和CachedThreadPool。
1. Executors.newFixedThreadPool(int nThreads):创建使用固定线程数的线程池。它适用于为了满足资源管理的需求,而需要限制当前线程数量的应用场景,适合负载较重的服务器。
2. Executors.newSingleThreadExecutor():创建使用单个线程的线程池。适用于需要保证顺序的执行各个任务,并且在任意时间点,不会有多个线程活动的应用场景。
3. Executors.newCachedThreadPool():创建一个大小无界的线程池。适用于执行大量短期的异步执行的任务,适合负载较轻的服务器。由于使用SynchronousQueue,并且maximumPoolSize无限,keepAliveTime为60s,因此吞吐量最好。与FixedThreadPool不同,当新的任务到来,如果有线程空闲,那么空闲的线程会直接接受该任务,如果没有空闲的线程,线程池可以无限创建新的线程。
3.11.1 Executors.newFixedThreadPool(int nThreads)的实现
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
可以看到,FixedThreadPool 的核心线程数和最大线程数都是指定值,也就是说当线程池中的线程数超过核心线程数后,任务都会被放到阻塞队列中。
此外 keepAliveTime 为 0,也就是多余的空余线程会被立即终止(由于这里没有多余线程,这个参数也没什么意义了)。
这里选用的阻塞队列是 LinkedBlockingQueue,使用的是默认容量 Integer.MAX_VALUE,相当于没有上限。因此maximumPoolSize是一个无效参数。
下面是FixedThreadPool的任务处理流程:
1. 对于一个新的task,如果当前线程数少于核心线程数,新建新的线程执行任务。
2. 如果当前线程数等于核心线程数后,将任务加入阻塞队列。
3. 执行完任务的线程反复去队列中取任务执行。
FixedThreadPool适用于为了满足资源管理的需求,而需要限制当前线程数量的应用场景,适合负载较重的服务器。
3.11.2 Executors.newSingleThreadExecutor()的实现
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
SingleThreadExecutor的corePoolSize和maximumPoolSize被设置为1,其他参数和FixedThreadPool相同。
SingleThreadExecutor适用于需要保证顺序的执行各个任务,并且在任意时间点,不会有多个线程活动的应用场景。
3.11.3 Executors.newCachedThreadPool()的实现
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
可以看到,CachedThreadPool 没有核心线程,非核心线程数无上限,也就是全部使用外包,但是每个外包空闲的时间只有 60 秒,超过后就会被回收。
CachedThreadPool 使用的队列是 SynchronousQueue,这个队列的作用就是传递任务,并不会保存。
因此当提交任务的速度大于处理任务的速度时,每次提交一个任务,就会创建一个线程。极端情况下会创建过多的线程,耗尽 CPU 和内存资源。
下面是CachedThreadPool的任务处理流程:
1. 对于一个新的task,由于没有核心线程数量为0,因此首先执行Synchronous.offer(Runnable task),如果线程池中有空闲线程正在执行Synchronous.pool(timeAlive),那么主线程执行offer操作与空闲线程执行poll操作匹配成功,任务交给空闲线程执行,execute方法执行完成。
2. 当线程池为空或者线程池中没有空闲线程时,这种情况下offer操作失败,此时线程池创建新的线程执行任务,execute方法执行完成。
3. 执行任务的线程执行完毕后,会执行Synchronous.pool(timeAlive)操作,这个poll操作会让空闲线程最多等待60秒,如果60秒内没有获得新的任务执行,那么这个空闲线程将被终止。
因此,CachedThreadPool适用于执行大量短期的异步执行的任务,适合负载较轻的服务器。
3.11.4 ScheduledThreadPoolExecutor
Executors同样可以创建两种类型的ScheduledThreadPoolExecutor:
- Executors.newScheduledThreadPool(int corePoolSize)
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize)
创建固定线程数量的ScheduledThreadPool。适用于需要多个后台线程执行周期性任务,同时为了满足资源管理的需求而限制后台的线程数量。
- Executors.newSingleThreadScheduledExecutor()
public static ScheduledExecutorService newSingleThreadScheduledExecutor()
适用于需要单个后台线程执行周期性任务,同时需要保证顺序的执行各个任务的场景。
ScheduledThreadPoolExecutor的执行主要分为两步:
1. 调用ScheduledThreadPoolExecutor的scheduleAtFixedRate()
方法或者scheduleWithFixedDelay()
方法,会向ScheduledThreadPoolExecutor的DelayQueue(无界阻塞队列)添加一个ScheduledFutureTask。
2. 线程池从DelayQueue中获取ScheduledFutureTask,然后执行任务。
ScheduledFutureTask主要包含三个关键变量:
- long time:任务将要被执行的时间点
- long sequenceNumber:任务ID
- long period:任务执行的间隔周期
DelayQueue封装了一个PriorityQueue,这个PriorityQueue会对队列中的ScheduledFutureTask排序,time小的排在前面。
下面是ScheduledThreadPoolExecutor的某个线程执行周期性任务的步骤:
1. 线程从DelayQueue中获取已到期的ScheduledFutureTask,并执行。
2. 执行完毕后线程修改这个task的time为下次要执行的时间。
3. 把修改后的task放回DelayQueue中。
下面是ScheduledThreadPoolExecutor的某个线程从DelayQueue中获取 ScheduledFutureTask的过程:
1. 获取Lock。
2. 如果PriorityQueue为空,则当前线程到Condition中等待。
3. 如果PriorityQueue的头元素的time比当前时间大,则在Condition中等待到time时间点。
4. 获取PriorityQueue的头元素,如果PriorityQueue不为空,则唤醒在Condition中等待的所有线程。
5. 释放Lock。
下面是ScheduledThreadPoolExecutor向DelayQueue中添加 ScheduledFutureTask的过程:
1. 获取Lock。
2. 向PriorityQueue中添加任务。
3. 如果添加的任务是头元素,唤醒所有等待在Condition中的线程。
4. 释放Lock。
3.12 常见问题
4.Java虚拟机
4.1 JVM内存模型
运行时数据区.jpgJava虚拟机运行时数据区包括:
- 程序计数器:线程私有的数据区,是当前线程所执行的字节码的行号指示器。字节码解释器在工作时通过修改这个计数器的值来选取下一条需要执行的字节码指令。
- 虚拟机栈:线程私有的数据区,虚拟机栈描述的虚拟机在执行Java方法时的内存模型:每一个方法在执行时都会创建一个栈帧(Stack Frame,方法运行时的基础数据结构),用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法从调用到完成的过程,就对应着一个栈帧从入栈到出栈的过程。
- 本地方法栈:本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,区别在于虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为执行虚拟机使用到的native方法服务。
- 堆:Java堆(Java Heap)是被所有线程共享的一块内存区域,在虚拟机启动时创建。Java堆唯一的目的就是存放对象实例,所有的对象以及数组都在堆上分配。
- 方法区:方法区(Method Area)是各个线程共享的内存区域。它用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译后的代码等数据。
- 运行时常量池:行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放编译期间和运行期间生成的各种字面量和符号引用。
字面量是指字符串或者数值。例如
int a = 8; String s = "hello";
这里的8
和"hello"
都是字面量。而符号引用是用于无歧义的定位到一个目标的。例如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language来表示Language类的地址。在程序运行时,JVM会去运行时常量池中查找这个符号引用并将其替换为直接引用(实际存储在内存中的地址)。
4.2 垃圾回收算法
JVM在清理堆内存时,首先要判断是否应该回收该对象。常见的判断算法有两种:引用计数法和可达性分析法。
- 引用计数法:为每一个对象添加一个引用计数器。每当有对象引用它时,计数器的值加1;当引用失效时,计数器的值减1。任何时刻计数器值为0的对象就是需要被回收的对象。缺点:无法解决循环引用的问题。
- 可达性分析法:通过一系列称为GC Roots的对象作为起点,从这些节点向下搜索,搜索走过的路径称为引用链(Reference Chain)。当一个对象到GC Roots 没有任何引用链相连时(就是从GC Roots 到这个对象不可达),则证明此对象是不可用的。
在Java语言中,可作为GC Roots的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(即Native方法)引用的对象。
JVM常见的垃圾回收算法包括:标记-清除算法,复制算法和标记-整理算法。
4.2.1 标记-清除算法
标记-清除算法,顾名思义分为标记和清除两个步骤:首先标记出所有需要回收的对象,然后统一清除待回收对象。它的缺点主要有两点:
- 效率问题。标记和清除两个操作效率都不高。
- 内存碎片问题。标记清除之后会产生大量的内存碎片,空间碎片太多可能导致无法分配较大的对象,从而再次触发新一轮的垃圾收集动作。
4.2.2 复制算法
为了解决内存碎片问题,出现了复制算法。它的思想是将堆内存分为两块,每次只使用其中一块。当第一块内存空间用完,就将所有存活的对象复制到另一块上,然后把已经使用过的内存一次性清理掉。
在真实的商业虚拟机中,又将堆内存分为了新生代(占用1/3堆内存)和老年代(占用2/3堆内存)。通常在新生代上使用复制算法。由于新生代中98%的对象都是朝生夕死的,所以并不需要按照1比1的比例来划分内存空间,而是将内存划分为一块较大的Eden空间和两块较小的Survivor空间。每次使用Eden和其中一块Survivor空间。当回收时,将Eden和Survivor中还存活着的对象一次性复制到另一块Survivor中,最后清理掉使用过的Enden和Survivor空间。这意味着只有大约10%的内存会被浪费。当然,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不足时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
分配担保:如果另一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。
为什么要使用两块Survivor?答案是为了减少内存碎片。假如只有一块Eden和一块Survivor,那么垃圾回收时,Eden和Survivor区各自拥有一些存活对象,在清理了Survivor区后,此时将Eden区存活对象复制到Survivor区,必然造成了内存的不连续性。
Minor GC:从新生代(Eden和Survivor)空间回收内存。Eden区域满了就会触发Minor GC。
Major GC:从老年代空间回收内存。
Full GC:清理整个堆空间包括新生代和老年代。
4.2.3 标记-整理算法
复制收集算法在对象存活率较高的情况下就要进行较多的复制操作,效率将变低。更关键的是,需要有额外的内存空间进行分配担保,所以老年代一般不选用这种算法,而是采用了标记-整理算法。
标记-整理算法:标记过程与标记-清除算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
4.2.4 分代收集算法
分代收集算法根据对象存活周期的不同将内存划分为几块,一般是将Java堆分为新生代和老年代。在新生代中,每次垃圾收集时都有大批对象死去,只有少量存活,因此可以选用复制算法。而老年代中的对象存活率高,没有额外空间对它进行分配担保,就必须使用标记-清理或者标记-整理算法来进行垃圾收集。
4.3 CMS和G1
CMS和G1的区别如下:
1.堆(Heap)空间分配不同
- CMS 将堆逻辑上分成Eden,Survivor(S0,S1),Old 三块,并且他们是固定大小,JVM启动的时候就已经设定不能改变,并且是连续的内存块。
- G1 将堆分成多个大小相同的Region(区域),默认2048个,在1Mb到32Mb之间大小,逻辑上仍然是Eden,Survivor,Old三块,但这三块不是固定大小,会根据每次GC的信息做出调整。
- CMS本质上采用标记-清除算法,因此会产生大量的内存碎片。而G1则采用标记-复制算法,不会产生内存碎片。并且CMS仅仅用于老年代的垃圾回收(新生代使用ParNew回收器,采用标记-复制算法),而G1则可以完成所有内存区域的垃圾回收。
Stop the World机制,简称STW,即在执行垃圾收集算法时,Java应用程序的其他所有除了垃圾收集收集器线程之外的线程都被挂起。在垃圾回收器标记阶段,JVM会执行Stop the World操作。
CMS.PNG[图片上传中...(G1_2.PNG-e0c368-1571715147359-0)]
G1_2.PNG ZGC.PNG
参考文章:http://huzb.me/2019/02/21/CMS-G1%E5%92%8CZGC/
参考文章:https://www.cnblogs.com/littleLord/p/5380624.html#initialMark
5. Spring
5.1 IOC源码
5.2 AOP源码
5.3 循环依赖问题
循环依赖其实就是循环引用,也就是两个或则两个以上的 bean 互相持有对方,最终形成闭环。比如 A 依赖于 B,B 又依赖于A。在 Spring 中这样的场景有很多,比如构造器参数中的循环依赖(Spring无法解决,会抛出exception),属性注入时的循环依赖。而其中,只有单例对象的属性注入循环依赖是可以被解决的。
Spring主要通过三级缓存来解决属性注入的循环依赖问题:
/** Cache of singleton objects: bean name --> bean instance */
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<String, Object>(256);
/** Cache of early singleton objects: bean name --> bean instance */
private final Map<String, Object> earlySingletonObjects = new HashMap<String, Object>(16);
/** Cache of singleton factories: bean name --> ObjectFactory */
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<String, ObjectFactory<?>>(16);
- 一级缓存
singletonObjects
存放所有已经实例化完成的Bean。 - 二级缓存
earlySingletonObjects
存放刚被创建但是属性还未被填充的Bean。 - 三级缓存
singletonFactories
并不存放Bean,它存放着创建Bean实例的ObjectFactory对象。
Spring 创建Bean实例的过程是:
- 首先实例化Bean对象
- 接着填充Bean实例的属性
- 调用BeanPostProcessor,其中包括4步:(1)调用Bean前置处理器
BeanPostProcessor.postProcessBeforeInitialization
(2)检测Bean是否实现了InitializingBean
并调用其afterPropertiesSet
方法(3)检测Bean是否配置了init-method并调用init-method(4)调用Bean后置处理器BeanPostProcessor.postProcessAfterInitialization
- Bean创建最终完成,并添加到一级缓存
singletonObjects
中
而对于刚实例化完成却还未填充属性的Bean,Spring会将其保存在二级缓存earlySingletonObjects
中,来解决循环依赖的问题。
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
// 从 singletonObjects 获取 beanA 的实例,因为还没完全创建成功,所以获取不到
Object singletonObject = this.singletonObjects.get(beanName);
// 判断 beanA 是否正在创建中,在第 4 步已经把 beanA 标记为正在创建
if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
synchronized (this.singletonObjects) {
// 从 earlySingletonObjects 中获取提前曝光的 beanA,这里依旧没有
singletonObject = this.earlySingletonObjects.get(beanName);
if (singletonObject == null && allowEarlyReference) {
// 从 singletonFactories 获取 beanA 的对象工厂,在第 5 步已经把 beanA 的对象工厂添加进去
ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
if (singletonFactory != null) {
// 通过对象工厂获取 beanA 的早期引用
singletonObject = singletonFactory.getObject();
// 将 beanA 的早期引用放入缓存 earlySingletonObjects 中
this.earlySingletonObjects.put(beanName, singletonObject);
// 将 beanA 的对象工厂从缓存 singletonFactories 中移除
this.singletonFactories.remove(beanName);
}
}
}
}
// 返回 beanA 的早期引用
return (singletonObject != NULL_OBJECT ? singletonObject : null);
}
getSingleton
方法由doGetBean
方法调用,doGetBean
首次调用getSingleton
方法会返回null,因此doGetBean
方法会调用createBean
方法并将该Bean对应的ObjectFactory放入三级缓存singletonFactory
中。此时doGetBean
方法再次调用getSingleton
方法时,首先从一级缓存和二级缓存中查找对应Bean,查找不到再从三级缓存中查找,并调用三级缓存中保存的ObjectFactory.getObject
方法(内部本质上是实例化Bean的过程)获得Bean,然后将获得Bean加入二级缓存,并将对应Bean从三级缓存中移除。
对于prototype作用域Bean,Spring容器无法完成依赖注入,因为“prototype”作用域的Bean,Spring容
器不进行缓存,因此无法提前暴露一个创建中的Bean。
5.3 Spring MVC执行流程
- 用户发送请求至前端控制器DispatcherServlet
- DispatcherServlet收到请求调用HandlerMapping,HadnlerMapping是一个接口,主要用于根据url找到对应的Bean,其实现类包括RequestMappingHandlerMapping(用于处理@RequestMapping注解)和BeanNameUrlHandlerMapping(通过对比url和bean的name找到对应的对象)等等。
- 处理器映射器根据请求url找到具体的Handler,生成处理器执行链HandlerExecutionChain(包括处理器对象和处理器拦截器)一并返回给DispatcherServlet。
- DispatcherServlet根据Handler获取HandlerAdapter并执行HandlerAdapter处理一系列的操作,如:参数封装,数据格式转换,数据验证等操作。常见的HandlerAdapter包括RequestMappingHandlerAdapter(和上面的RequestMappingHandlerMapping配对使用,针对@RequestMapping)和SimpleControllerHandlerAdapter等等。
SimpleControllerHandlerAdapter的主要方法
ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler)
会直接调用return ((Controller) handler).handleRequest(request, response)
,因此handlerAdapter通常在handler的基础上增加了一些额外的功能,如参数封装,数据格式转换,数据验证等。
- 执行处理器Handler(Controller,也叫页面控制器)。
- Handler执行完成返回ModelAndView
- HandlerAdapter将Handler执行结果ModelAndView返回到DispatcherServlet
- DispatcherServlet将ModelAndView传给ViewReslover视图解析器
- ViewReslover解析后返回具体View(Handler返回的ModelAndView中不包含真正的视图,只返回一 个逻辑视图名称,ViewResolver会把该逻辑视图名称解析为真正的视图View对象)
- DispatcherServlet对View进行渲染视图(即将模型数据model填充至视图中)。
- DispatcherServlet响应用户。