面经问题整理
- HashMap
- ArrayList底层原理,ArrayList和Vector的区别,LinkedList和ArrayList的区别
- fast-fail、fail—safe
- ConcurrentHashMap的底层数据结构
- synchronized和lock分别在什么情况下使用,使用的理由?
- 各种锁的概念
- AQS(AbstractQueuedSynchronizer)队列同步器
- CountDownLatch](https://blog.csdn.net/qq_36184373/article/details/105608561)、[CyclicBarrier
- 多线程在运行过程中抛出异常怎么捕获
- ThreadLocal原理、应用
- ReenTrantLock中的condition有什么作用?condition的await和signal和Object的wait和notify有什么区别?
- 总线嗅探、[高速缓存一致性协议](https://blog.csdn.net/m18870420619/article/details/82431319)、总线风暴
- 类加载问题
HashMap
1. 重写equals()方法为什么要重写hashcode()
首先, equals() 方法和 hashcode() 方法间的关系是这样的:
- 如果两个对象相同(即:用 equals 比较返回true),那么它们的 hashCode 值一定要相同;
- 但如果两个对象的 hashCode 相同,它们并不一定相同(即:用 equals 比较返回 false);这时就需要用equals进一步比较。
上面这两句话,如果明白【散列表】的结构,就一定会很明白,这里只简单提一句:散列表同时运用了数组和链表。
《Effective java》一书中这样说到:在每个覆盖了 equals() 方法的类中,也必须覆盖 hashCode() 方法,如果不这样做的话,就会违反 Object.hashCode 的通用的约定,从而导致该类无法结合所有基于散列的集合一起正常运作,这样的集合包括HashMap,HashSet 和 HashTable。
总而言之一句话:实现了“两个对象 equals 相等,那么地址也一定相同”的概念!
2. HashMap底层:
结构:
- 1.7:数组+单向链表
- 1.8:数组+单向链表/红黑树
java8中为什么链表长度超过8才变成红黑树
Hashmap的源码里面写道:因为红黑树的树节点是常规节点的几乎两倍大,所以我们只有在一个桶里面有足够多元素时才使用它,并且当节点数量变小时,会变回链表结构。
当hashcode的分布均匀时,红黑树的桶很少会用到,理想情况下,在完全随机的hashcode下,桶中的节点的个数服从参数为 0.5 的泊松分布:
// 桶中元素个数的概率
* 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
* more: less than 1 in ten million(小于千万分之一)
因为理想情况下链表长度极少超过8,所以才将8设置为链表与红黑树的分界线,红黑树只是为了在极端情况下来保证性能,更低成本的维护时间和空间的效率。
为什么JDK1.8采用了尾插法?
JDK1.7时采用的是头插法,它在扩容后rehash,会使得链表的顺序颠倒,引用关系发生了改变,那么在多线程的情况下,会出现链表成环而死循环的问题,而尾插法就不会有这样的问题,rehash后链表顺序不变,引用关系也不会发生改变,也就不会发生链表成环的问题
数组的长度为什么一定要是2^n
因为求数组下标的时候,我们会用 元素的hash值 &(数组的length - 1),在二进制中,由于数组的长度为2^n,所以 length - 1 一定是连续的 1(比如16 => 010000 - 1 = 001111),这样我们在按位与 元素的hash值时,我们可以非常快速的拿到该hash值在数组中的下标(并且该下标完全取决于hashcode),并且分布还是均匀的。
如果数组的长度不是 2^n 的话,我们在按位与求元素的数组下标时,因为 length - 1 不一定是连续的1 有可能中间会出现0,如果是这种情况的话那么我们会发现有些hash桶永远是空的,这样就分布不均匀。
为什么树退化成链表的阈值是6
避免频繁的进行树退化为链表的操作,因为退化也是有开销的,当我们移除一个红黑树上的值的时候,如果只有阈值8的话,那么它会直接退化,我们若再添加一个值,它有可能又需要变为红黑树了,添加阈值6相当于添加了一个缓冲
JDK1.7先扩容后插入 JDK1.8先插入后扩容 原因?
JDK1.7:
- 头插法,添加前先判断扩容,当前准备插入的位置不为空并且容量大于等于阈值才进行扩容,是两个条件!
- 扩容后可能会重新计算hash值。
JDK1.8:
- 尾插法,初始化时,添加节点结束之后和判断树化的时候都会去判断扩容。我们添加节点结束之后只要size大于阈值,就一定会扩容,是一个条件。
- 由于hash是final修饰,通过 e.hash&oldCap==0 来判断新插入的位置是否为原位置。
JDK1.8:源码已经很清楚的表达的扩容原因,调用put不一定是新增数据,还可能是覆盖掉原来的数据,这里就存在了一个key的比较问题。以先扩容为例,先比较是否是新增的数据,在比较是否需要增加数据后扩容,这样比较会浪费时间,而后扩容,就在中途直接通过return返回了,根本执行不到是否扩容,这样可以提高效率的。
java7 hashmap的问题
- 并发环境中容易死锁
https://coolshell.cn/articles/9606.html - 可以通过精心构造的恶意请求进行DOS攻击
https://www.freebuf.com/articles/web/14199.html
tomcat等web服务器在处理用户提交的参数时,直接把参数“键值对”放入了hashmap中。攻击者录入大量的数据,并且这些数据的hashcode一致,当线上服务器执行到这段代码的时候,就会造成DOS。
ArrayList底层原理,ArrayList和Vector的区别,LinkedList和ArrayList的区别
1. ArrayList底层原理:
ArrayList是用数组实现的,并且它是动态数组,也就是它的容量是可以自动增长的,看下面的类声明可知道它实现了众多接口,比如List,RandomAccess,Serializable,Cloneable
- 实现RandomAccess接口:所以ArrayList支持快速随机访问,本质上是通过下标序号随机访问
- 实现Serializable接口:使ArrayList支持序列化,通过序列化传输
- 实现Cloneable接口:使ArrayList能够克隆
底层关键:
transient Object[] elementData;
ArrayList底层本质上是一个数组,用该数组来保存数据
transient:Java关键字,变量修饰符,如果用transient声明一个实例变量,当对象存储时,它的值不需要维持。换句话来说就是,用transient关键字标记的成员变量不参与序列化过程。
工具类:
trimToSize(): 将当前容量值设为实际元素个数
public void trimToSize() {
modCount++;
if (size < elementData.length) {
//相当于缩小了容量
elementData = Arrays.copyOf(elementData, size);
}
}
public void ensureCapacity(int minCapacity):扩容方法,ArrayList每次新增元素都会进行容量大小检测判断,若新增的后元素的个数会超过ArrayList的容量,就会进行扩容满足新增元素的需求
public void ensureCapacity(int minCapacity) {
int minExpand = (elementData != EMPTY_ELEMENTDATA)
? 0
//默认构造情况下,elementData 是空的,所以minExpand为10
: DEFAULT_CAPACITY;
if (minCapacity > minExpand) {
ensureExplicitCapacity(minCapacity);
}
}
实际上扩容的方法grow()
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
// 新的容量大小 = 原容量大小的1.5倍,右移1位并相加本身近似于1.5倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
//溢出判断
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
1.5倍的神秘规律:因为一次性扩容太大(例如2.5倍)可能会浪费更多的内存
1.5倍:最多浪费33%
2.5倍:最多会浪费60%
3.5倍:则会浪费71%
但是一次性扩容太小,需要多次对数组重新分配内存,对性能消耗比较严重。所以1.5倍刚刚好,既能满足性能需求,也不会造成很大的内存消耗。
补充知识:fail-fast机制
2. ArrayList和Vector的区别:
相同点:
-
底层实现都是基于数组
-
理论存储最大元素容量是Integer.MAX_VALUE(2^31-1)
-
他们继承的类和实现的接口都是一样的
-
他们的默认初始化大小都是10
区别:
-
ArrayList是线程不安全的,相对速度快一些,Vector是线程安全的,Vector的线程安全是使用同步关键字(synchronized)实现的
-
当其容量超过初始值时,扩容大小不一致。ArrayList默认扩容原有初始容量的50%,而Vector默认扩容原有初始容量的100%
3. LinkedList和ArrayList的区别:
ArrayList底层是一个数组,允许add null 值,会自动扩容,其中size(),isEmpty(),get(),add()方法的复杂度为O(1)。初始化时容量是0,当调用add方法时默认变为10,扩容机制是每次扩容为原来的1.5倍。它的特点是查询比较快(因为底层数组支持随机访问,根据脚标查询),删除效率是比较低的(每次删除完一个元素都需要将该元素后面的元素前移)。
LinkedList底层结构是一个带有头结点和尾结点的双向链表,插入方式有两种,一个是头插LinkedFirst,一个是尾插LinkedLast。删除也提供了了头删和尾删。LinkedList适用于频繁插入和删除的场景,不过当查询量比较大的时候会比较慢(因为底层是链表,不支持随机访问,查询时需要从头部一个一个遍历比较)。
fast-fail、fail—safe
fail-fast(快速失败)产生的原因:
fail-fast产生的原因就在于程序在对 collection 进行迭代时,某个线程对该 collection 在结构上对其做了修改,这时迭代器就会抛出 ConcurrentModificationException 异常信息,从而产生 fail-fast。
要了解fail-fast机制,我们首先要对ConcurrentModificationException 异常有所了解。当方法检测到对象的并发修改,但不允许这种修改时就抛出该异常。同时需要注意的是,该异常不会始终指出对象已经由不同线程并发修改,如果单线程违反了规则,同样也有可能会抛出该异常。
PS:java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)
如果集合发生变化时修改 modCount 值刚好又设置为了 expectedmodCount 值,则异常不会抛出。因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的 bug。
从前面我们知道fail-fast是在操作迭代器时产生的。现在我们来看看ArrayList中迭代器的源代码:
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();
}
}
从上面的源代码我们可以看出,迭代器在调用next()、remove()方法时都是调用checkForComodification()方法,该方法主要就是检测modCount == expectedModCount ? 若不等则抛出ConcurrentModificationException 异常,从而产生fail-fast机制。
modCount是在 AbstractList 中定义的,为全局变量:
protected transient int modCount = 0;
那么他什么时候因为什么原因而发生改变呢?请看ArrayList的源码:
public boolean add(E paramE) {
ensureCapacityInternal(this.size + 1);
/** 省略此处代码 */
}
private void ensureCapacityInternal(int paramInt) {
if (this.elementData == EMPTY_ELEMENTDATA)
paramInt = Math.max(10, paramInt);
ensureExplicitCapacity(paramInt);
}
private void ensureExplicitCapacity(int paramInt) {
this.modCount += 1; //修改modCount
/** 省略此处代码 */
}
ublic boolean remove(Object paramObject) {
int i;
if (paramObject == null)
for (i = 0; i < this.size; ++i) {
if (this.elementData[i] != null)
continue;
fastRemove(i);
return true;
}
else
for (i = 0; i < this.size; ++i) {
if (!(paramObject.equals(this.elementData[i])))
continue;
fastRemove(i);
return true;
}
return false;
}
private void fastRemove(int paramInt) {
this.modCount += 1; //修改modCount
/** 省略此处代码 */
}
public void clear() {
this.modCount += 1; //修改modCount
/** 省略此处代码 */
}
从上面的源代码我们可以看出,ArrayList中无论add、remove、clear方法只要是涉及了改变ArrayList元素的个数的方法都会导致modCount的改变。所以我们这里可以初步判断由于expectedModCount 得值与modCount的改变不同步,导致两者之间不等从而产生fail-fast机制。
直到这里我们已经完全了解了fail-fast产生的根本原因了。知道了原因就好找解决办法了。
fail-fast解决办法:
-
方案一:在遍历过程中所有涉及到改变modCount值得地方全部加上synchronized或者直接使用Collections.synchronizedList,这样就可以解决。但是不推荐,因为增删造成的同步锁可能会阻塞遍历操作。
-
方案二:使用CopyOnWriteArrayList来替换ArrayList。推荐使用该方案。CopyOnWriteArrayList为何物?ArrayList 的一个线程安全的变体,其中所有可变操作(add、set 等等)都是通过对底层数组进行一次新的复制来实现的。 该类产生的开销比较大,但是在两种情况下,它非常适合使用。
- 在不能或不想进行同步遍历,但又需要从并发线程中排除冲突时。
- 当遍历操作的数量大大超过可变操作的数量时。
遇到这两种情况使用CopyOnWriteArrayList来替代ArrayList再适合不过了。那么为什么CopyOnWriterArrayList可以替代ArrayList呢?
- CopyOnWriterArrayList的无论是从数据结构、定义都和ArrayList一样。它和ArrayList一样,同样是实现List接口,底层使用数组实现。在方法上也包含add、remove、clear、iterator等方法。
- CopyOnWriterArrayList根本就不会产生ConcurrentModificationException异常,也就是它使用迭代器完全不会产生fail-fast机制。
请看:
private static class COWIterator<E> implements ListIterator<E> {
/** 省略此处代码 */
public E next() {
if (!(hasNext()))
throw new NoSuchElementException();
return this.snapshot[(this.cursor++)];
}
/** 省略此处代码 */
}
CopyOnWriterArrayList的方法根本就没有像ArrayList中使用checkForComodification方法来判断expectedModCount 与 modCount 是否相等。它为什么会这么做,凭什么可以这么做呢?我们以add方法为例:
public boolean add(E paramE) {
ReentrantLock localReentrantLock = this.lock;
localReentrantLock.lock(); // 加锁
try {
Object[] arrayOfObject1 = getArray();
int i = arrayOfObject1.length;
Object[] arrayOfObject2 = Arrays.copyOf(arrayOfObject1, i + 1);
arrayOfObject2[i] = paramE;
setArray(arrayOfObject2);
int j = 1;
return j;
} finally {
localReentrantLock.unlock();
}
}
final void setArray(Object[] paramArrayOfObject) {
this.array = paramArrayOfObject;
}
CopyOnWriterArrayList的add方法与ArrayList的add方法有一个最大的不同点就在于,下面三句代码:
Object[] arrayOfObject2 = Arrays.copyOf(arrayOfObject1, i + 1);
arrayOfObject2[i] = paramE;
setArray(arrayOfObject2);
就是这三句代码使得CopyOnWriterArrayList不会抛ConcurrentModificationException异常。他们所展现的魅力就在于copy原来的array,再在copy数组上进行add操作,这样做就完全不会影响COWIterator中的array了。
所以CopyOnWriterArrayList所代表的核心概念就是:任何对array在结构上有所改变的操作(add、remove、clear等),CopyOnWriterArrayList都会copy现有的数据,再在copy的数据上修改,这样就不会影响COWIterator中的数据了,修改完成之后改变原有数据的引用即可。同时这样造成的代价就是产生大量的对象,同时数组的copy也是相当有损耗的。
fail-safe(安全失败):
采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
-
原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。
-
缺点:基于拷贝内容的优点是避免了Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。
-
场景:java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。
ConcurrentHashMap的底层数据结构
jdk1.7的ConcurrentHashMap:
- 底层数据结构: 分段的数组+链表。ConcurrentHashMap是由Segment数据结构和HashEntry数据结构组成。Segment实现了ReentrantLock,所以Segment是一种可重入锁,扮演锁的角色。HashEntry用于存储键值对数据。一个ConcurrentHashMap包含一个Segment数组。Segment数组中的每个元素包含一个HashEntry数组,HashEntry数组中的每个元素是一个链表结构的元素。Segment数组的每个元素各守护着一个HashEntry数组中的素有元素。当对HashEntry数组的数据进行修改时,必须首先获得对应的Segment数组元素的锁。
- 实现线程安全的方式: 首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问,提高并发访问率。
jdk1.8的ConcurrentHashMap:
-
底层数据结构: ConcurrentHashMap取消了Segment分段锁,采用CAS(Compare-and-Swap,比较并替换)和synchronized来保证并发安全。数据结构跟HashMap1.8的结构类似, 是数组+链表/红黑树,还加了一个转移节点用来扩容(jdk1.6以后对synchronized锁做了很多优化,比如偏向锁、轻量级锁、自旋锁、锁消除、锁粗化等)
-
实现线程安全的方式:
①在jdk1.7的时候,ConcurrentHashMap采用分段锁,对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分,多线程访问容器里不同数据段的数据,就不会产生锁竞争,提高并发访问率。
②到了jdk1.8的时候 ,synchronized只锁定当前链表或者红黑树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。
put方法的过程:
数组中每个元素进行put都是有一个不同的锁,刚开始进行put的时候,如果两个线程都是在数组[5]这个位置进行put,这个时候,对数组[5]这个位置进行put的时候,采取的是CAS策略。
同一时间,只有一个线程能成功执行CAS,其他线程都会失败。
如果多个线程对同一个位置进行操作,CAS失败的线程,就会在这个位置基于链表+红黑树来进行处理,synchronized([5]),进行加锁。
table数组被volatile修饰,其中有一个比较重要的字段,sizeCtl= -1 则代表table正在初始化(这是操作table的线程会礼让,等待初始化);table未初始化时,代表需要初始化的大小;table初始化完成,表示table的容量,默认为0.75table大小
put方法:
key和value都是不能为空的,否则会产生空指针异常,之后会进入自旋(for循环自旋),如果当前数组为空,那么进行初始化操作,初始化完成后,计算出数组的位置,如果该位置没有值,采用CAS操作进行添加;如果当前位置是转移节点,那么会调用helptransfer方法协助扩容;如果当前位置有值,那么用synchronized加锁,锁住该位置,如果是链表的话,采用的是尾插法,如果是红黑树,则采用红黑树新增的方法,新增完成后需要判断是否需要扩容,大于sizeCtl的话,那么执行扩容操作 。
初始化:
在进行初始化操作的时候,会将sizeCtl利用CAS操作设置为-1,CAS成功之后,还会判断数组是否完成初始化,有一个双重检测的过程
过程:进入自旋,如果sizeCtl < 0, 线程礼让(Thread.yield())等待初始化;否则CAS操作将sizeCtl设置为-1,再次检测是否完成了初始化,若没有则执行初始化操作
synchronized和lock分别在什么情况下使用,使用的理由?
总结来说,Lock和synchronized有以下几点不同:
1)Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;
2)synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
3)Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
4)通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
5)Lock可以提高多个线程进行读操作的效率。
在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。
各种锁的概念
扩展:synchronized锁升级过程:
初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。
在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。
长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。
显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。在JDK1.6之前,synchronized直接加重量级锁,很明显现在得到了很好的优化。
一个锁只能按照 偏向锁、轻量级锁、重量级锁的顺序逐渐升级(也有叫锁膨胀的),不允许降级。
问题:偏向锁的一个特性是,持有锁的线程在执行完同步代码块时不会释放锁。那么当第二个线程执行到这个synchronized代码块时是否一定会发生锁竞争然后升级为轻量级锁呢?
线程A第一次执行完同步代码块后,当线程B尝试获取锁的时候,发现是偏向锁,会判断线程A是否仍然存活。如果线程A仍然存活,将线程A暂停,此时偏向锁升级为轻量级锁,之后线程A继续执行,线程B自旋。但是如果判断结果是线程A不存在了,则线程B持有此偏向锁,锁不升级。
AQS(AbstractQueuedSynchronizer)队列同步器
什么是AQS:
aqs全称为AbstractQueuedSynchronizer,它提供了一个FIFO队列,可以看成是一个用来实现同步锁以及其他涉及到同步功能的核心组件,常见的有:ReentrantLock、CountDownLatch等。
AQS是一个抽象类,主要是通过继承的方式来使用,它本身没有实现任何的同步接口,仅仅是定义了同步状态的获取以及释放的方法来提供自定义的同步组件。
AQS的两种功能:
从使用层面来说,AQS的功能分为两种:独占和共享
- 独占锁,每次只能有一个线程持有锁,比如前面给大家演示的ReentrantLock就是以独占方式实现的互斥锁
- 共享锁,允许多个线程同时获取锁,并发访问共享资源,比如ReentrantReadWriteLock
AQS的内部实现:
AQS的实现依赖内部的同步队列,也就是FIFO的双向队列,如果当前线程竞争锁失败,那么AQS会把当前线程以及等待状态信息构造成一个Node加入到同步队列中,同时再阻塞该线程。当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点(线程)。
释放锁以及添加线程对于队列的变化:
添加节点:
当出现锁竞争以及释放锁的时候,AQS同步队列中的节点会发生变化,首先看一下添加节点的场景。
这里会涉及到两个变化
- 新的线程封装成Node节点追加到同步队列中,设置prev节点以及修改当前节点的前置节点的next节点指向自己
- 通过CAS将tail重新指向新的尾部节点
释放锁移除节点:
head节点表示获取锁成功的节点,当头结点在释放同步状态时,会唤醒后继节点,如果后继节点获得锁成功,会把自己设置为头结点,节点的变化过程如下
这个过程也是涉及到两个变化
- 修改head节点指向下一个获得锁的节点
- 新的获得锁的节点,将prev的指针指向null
这里有一个小的变化,就是设置head节点不需要用CAS,原因是设置head节点是由获得锁的线程来完成的,而同步锁只能由一个线程获得,所以不需要CAS保证,只需要把head节点设置为原首节点的后继节点,并且断开原head节点的next引用即可
CountDownLatch、CyclicBarrier
CountDownLatch:
countDownLatch这个类使一个线程等待其他线程各自执行完毕后再执行。
类有两个核心的方法, countDown()和await()方法.在使用CountDownLatch的时候, 必须要传入一个数值型参数作为计数器的值. 调用countDown()方法可以将值减1. 而调用了await()方法的线程,在计数器的值为0之前, 会被一直阻塞. 直到计数器的值为0时才会被唤醒.
三个重要的方法:
//调用await()方法的线程会被挂起,它会等待直到count值为0才继续执行
public void await() throws InterruptedException { };
//和await()类似,只不过等待一定的时间后count值还没变为0的话就会继续执行
public boolean await(long timeout, TimeUnit unit) throws InterruptedException { };
//将count值减1
public void countDown() { };
CyclicBarrier :
从字面上的意思可以知道,这个类的中文意思是“循环栅栏”。大概的意思就是一个可循环利用的屏障。
它的作用就是会让所有线程都等待完成后才会继续下一步行动。
举个例子,就像生活中我们会约朋友们到某个餐厅一起吃饭,有些朋友可能会早到,有些朋友可能会晚到,但是这个餐厅规定必须等到所有人到齐之后才会让我们进去。这里的朋友们就是各个线程,餐厅就是 CyclicBarrier。
构造方法:
// parties 是参与线程的个数
public CyclicBarrier(int parties)
// 第二个构造方法有一个 Runnable 参数,这个参数的意思是最后一个到达线程要做的任务
public CyclicBarrier(int parties, Runnable barrierAction)
方法:
// 线程调用 await() 表示自己已经到达栅栏
public int await() throws InterruptedException, BrokenBarrierException
// BrokenBarrierException 表示栅栏已经被破坏,破坏的原因可能是其中一个线程 await() 时被中断或者超时
public int await(long timeout, TimeUnit unit) throws InterruptedException, BrokenBarrierException, TimeoutException
CyclicBarrier 与 CountDownLatch 区别:
-
CountDownLatch 是一次性的,CyclicBarrier 是可循环利用的
-
CountDownLatch 参与的线程的职责是不一样的,有的在倒计时,有的在等待倒计时结束。CyclicBarrier 参与的线程职责是一样的。
多线程在运行过程中抛出异常怎么捕获
理解Future:
当处理一个任务时,总会遇到以下几个阶段:
- 提交任务
- 执行任务
- 任务完成的后置处理
以下我们简单定义,构造及提交任务的线程为生产者线程; 执行任务的线程为消费者线程; 任务的后置处理线程为后置消费者线程。
根据任务的特性,会衍生各种各样的线程模型。其中包括 Future 模式。
接下来我们先用最简单的例子迅速对 Future 有个直观理解,然后再对其展开讨论。
ExecutorService executor = Executors.newFixedThreadPool(3);
Future future = executor.submit(new Callable<String>() {
@Override
public String call() throws Exception {
//do some thing
Thread.sleep(100);
return "i am ok";
}
});
println(future.isDone());
println(future.get());
在本例中首先创建一个线程池,然后向线程池中提交了一个任务, submit 提交任务后会被立即返回,而不会等到任务实际处理完成才会返回, 而任务提交后返回值便是 Future, 通过 Future 我们可以调用 get() 方法阻塞式的获取返回结果, 也可以使用 isDone() 获取任务是否完成
生产者线程在提交完任务后,有两个选择:关注处理结果和不关注处理结果。处理结果包括任务的返回值,也包含任务是否正确完成,中途是否抛出异常等等。
Future 模式提供一种机制,在消费者异步处理生产者提交的任务的情况下,生产者线程也可以拿到消费者线程的处理结果,同时通过 Future 也可以取消掉处理中的任务。
在实际的开发中,我们经常会遇到这种类似需求。任务需要异步处理,同时又关心任务的处理结果,此时使用 Future 是再合适不过了。
CompletableFuture :
CompletableFuture 为我们提供了非常多的方法, 笔者将其所有方法按照功能分类如下:
-
对一个或多个 Future 合并操作,生成一个新的 Future, 参考 allOf,anyOf,runAsync, supplyAsync。
-
为 Future 添加后置处理动作, thenAccept, thenApply, thenRun。
-
两个人 Future 任一或全部完成时,执行后置动作:applyToEither, acceptEither, thenAcceptBothAsync, runAfterBoth,runAfterEither 等。
-
当 Future 完成条件满足时,异步或同步执行后置处理动作: thenApplyAsync, thenRunAsync。所有异步后置处理都会添加 Async 后缀。
-
定义 Future 的处理顺序 thenCompose 协同存在依赖关系的 Future,thenCombine。合并多个 Future的处理结果返回新的处理结果。
-
异常处理 exceptionally ,如果任务处理过程中抛出了异常。
我们需要明白 CompletableFuture 提供了一些方法组合新的 Future,组合条件依赖顺序执行或并行执行。
ThreadLocal原理、应用
ThreadLocal的定义:
TheadLocal提供了线程内部的局部变量:每个线程都有自己的独立的副本;ThreadLocal实例通常是类中的private static字段,该类一般与线程状态相关(或线程上下文)中使用。只要线程处于活动状态且ThreadLocal实例时可访问的状态下,每个线程都持有对其线程局部变量的副本的隐式引用,在线程消亡后,ThreadLocal实例的所有副本都将进行垃圾回收。
ThreadLocal的应用:
ThreadLocal 不是用来解决多线程访问共享变量的问题,所以不能替换掉同步方法。一般而言,ThreadLocal的最佳应用场景是:按照线程多实例(每个线程对应一个实例)的对象的访问。
例如:在事务中,connection绑定到当前线程来保证这个线程中的数据库操作用的是同一个connection。
demo:
public class ThreadLocalTest {
public static void main(String[] args) {
ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("张三");
new Thread(()->{
threadLocal.set("李四");
System.out.println("*******"+Thread.currentThread().getName()+"获取到的数据"+threadLocal.get());
},"线程1").start();
new Thread(()->{
threadLocal.set("王二");
System.out.println("*******"+Thread.currentThread().getName()+"获取到的数据"+threadLocal.get());
},"线程2").start();
new Thread(()->{
System.out.println("*******"+Thread.currentThread().getName()+"获取到的数据"+threadLocal.get());
},"线程3").start();
System.out.println("线程=" + Thread.currentThread().getName() + "获取到的数据=" + threadLocal.get());
}
}
从运行结果,我们可以看出线程1和线程2在ThreadLocal中设置的值相互独立,每个线程只能取到自己设置的那个值。
ThreadLocal底层:
ThreadLocal存储数据的逻辑是:每个线程持有一个自己的ThreadLocalMap,key为ThreadLocal对象的实例,value 是我们需要设置的值。这里需要注意的是,ThreadLoalMap的Entry是继承WeakReference,和HashMap很大的区别是,Entry中没有next字段,所以就不存在链表的情况。
ThreadLocalMap的散列表采用开放地址,线性探测的方法处理hash冲突,在hash冲突较大的时候效率低下,因为ThreadLoaclMap是一个Thread的一个属性,所以即使在自己的代码中控制设置的元素个数,但还是不能控制其他代码的行为。
ThreadLocalMap的set、get、remove操作中都带有删除过期元素的操作,类似缓存的lazy淘汰。
ThreadLocal内存泄漏:
ThreadLocal可能导致内存泄露,为什么?先看看Entry的实现:
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
通过之前的分析我们已经知道,当使用ThreadLocal保存一个value时,会在ThreadLoalMap中的数组插入一个Entry对象,按理来说key-value都可以以强引用保存在Entry对象中,但在ThreadLocalMap的实现中,key被保存到了WeakReference对象中。
这就导致了一个问题,当一个ThreadLocal没有强引用时,threadLocal会被GC清理,会形成一个key为null的Map的引用。废弃threadLocal占用的内存会在三种情况下清理:
-
thread结束,那么与之相关的threadlocal value会被清理
-
GC后,thread.threadLocal(map) 的threadhold超过最大值时,会清理
-
GC后,thread.threadlocals(maps)添加新的Entry时,hash算法没有命中既有Entry时,会清理
那么何时会“内存泄漏”?当Thread长时间不结束,存在大量废弃的ThreadLocal,而又不再添加新的ThreadLocal时。
如何避免内存泄露:
在调用ThreadLocal的get()、set()可能会清除ThreadLocalMap中key为null的Entry对象,这样对应的value就没有GC Roots可达了,下次GC的时候就可以被回收,当然如果调用remove方法,肯定会删除对应的Entry对象。
ThreadLocal<String> threadLocal = new ThreadLocal<>();
try {
threadLocal.set("张三");
} finally {
threadLocal.remove();
}
ReenTrantLock中的condition有什么作用?condition的await和signal和Object的wait和notify有什么区别?
Condition:
Condition是一个多线程间协调通信的工具类(接口),使得某个,或者某些线程一起等待某个条件(Condition),只有当该条件具备( signal 或者 signalAll方法被带调用)时 ,这些等待线程才会被唤醒,从而重新争夺锁。
对比wati,notify,notifyAll:
java1.5出现的显式协作Condition接口的 await、signal、signalAll 也可以说是普通并发协作 wait、notify、notifyAll 的升级;普通并发协作 wait、notify、notifyAll 需要与synchronized配合使用,显式协作Condition 的 await、signal、signalAll 需要与显式锁Lock配合使用 Lock.newCondition()),调用await、signal、signalAll方法都必须在lock 保护之内。
和wait一样,await在进入等待队列后会释放锁和cpu,当被其他线程唤醒或者超时或中断后都需要重新获取锁,获取锁后才会从await方法中退出,await同样和wait一样存在等待返回不代表条件成立的问题,所以也需要主动循环条件判断;await提供了比wait更加强大的机制,譬如提供了可中断或者不可中断的await机制等;condition其实是等待队列的一个管理者,condition确保阻塞的对象按顺序被唤醒。
总线嗅探、高速缓存一致性协议、总线风暴
总线嗅探机制:
在现代计算机中,CPU 的速度是极高的,如果 CPU 需要存取数据时都直接与内存打交道,在存取过程中,CPU 将一直空闲,这是一种极大的浪费,所以,为了提高处理速度,CPU 不直接和内存进行通信,而是在 CPU 与内存之间加入很多寄存器,多级缓存,它们比内存的存取速度高得多,这样就解决了 CPU 运算速度和内存读取速度不一致问题。
由于 CPU 与内存之间加入了缓存,在进行数据操作时,先将数据从内存拷贝到缓存中,CPU 直接操作的是缓存中的数据。但在多处理器下,将可能导致各自的缓存数据不一致(这也是可见性问题的由来),为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,而嗅探是实现缓存一致性的常见机制。
注意:缓存的一致性问题,不是多处理器导致,而是多缓存导致的。
嗅探机制工作原理:每个处理器通过监听在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从主内存中把数据读到处理器缓存中。
注意:基于 CPU 缓存一致性协议,JVM 实现了 volatile 的可见性,但由于总线嗅探机制,会不断的监听总线,如果大量使用 volatile 会引起总线风暴。所以,volatile 的使用要适合具体场景。
总线风暴:
由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值。
类加载问题
1. 加载
加载指的是将类的class文件读入到内存,并为之创建一个java.lang.Class对象,也就是说,当程序中使用任何类时,系统都会为之建立一个java.lang.Class对象。
类的加载由类加载器完成,类加载器通常由JVM提供,这些类加载器也是前面所有程序运行的基础,JVM提供的这些类加载器通常被称为系统类加载器。除此之外,开发者可以通过继承ClassLoader基类来创建自己的类加载器。
通过使用不同的类加载器,可以从不同来源加载类的二进制数据,通常有如下几种来源。
- 从本地文件系统加载class文件,这是前面绝大部分示例程序的类加载方式。
- 从JAR包加载class文件,这种方式也是很常见的,前面介绍JDBC编程时用到的数据库驱动类就放在JAR文件中,JVM可以从JAR文件中直接加载该class文件。
- 通过网络加载class文件。
- 把一个Java源文件动态编译,并执行加载。
类加载器通常无须等到“首次使用”该类时才加载该类,Java虚拟机规范允许系统预先加载某些类。
2. 链接
当类被加载之后,系统为之生成一个对应的Class对象,接着将会进入连接阶段,连接阶段负责把类的二进制数据合并到JRE中。类连接又可分为如下3个阶段。
1)验证:验证阶段用于检验被加载的类是否有正确的内部结构,并和其他类协调一致。Java是相对C++语言是安全的语言,例如它有C++不具有的数组越界的检查。这本身就是对自身安全的一种保护。验证阶段是Java非常重要的一个阶段,它会直接的保证应用是否会被恶意入侵的一道重要的防线,越是严谨的验证机制越安全。验证的目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。其主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
四种验证进一步说明:
-
文件格式验证:主要验证字节流是否符合Class文件格式规范,并且能被当前的虚拟机加载处理。例如:主,次版本号是否在当前虚拟机处理的范围之内。常量池中是否有不被支持的常量类型。指向常量的中的索引值是否存在不存在的常量或不符合类型的常量。
-
元数据验证:对字节码描述的信息进行语义的分析,分析是否符合java的语言语法的规范。
-
字节码验证:最重要的验证环节,分析数据流和控制,确定语义是合法的,符合逻辑的。主要的针对元数据验证后对方法体的验证。保证类方法在运行时不会有危害出现。
-
符号引用验证:主要是针对符号引用转换为直接引用的时候,是会延伸到第三解析阶段,主要去确定访问类型等涉及到引用的情况,主要是要保证引用一定会被访问到,不会出现类等无法访问的问题。
2)准备:类准备阶段负责为类的静态变量分配内存,并设置默认初始值。
3)解析:将类的二进制数据中的符号引用替换成直接引用。说明一下:符号引用:符号引用是以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量,只要不会出现冲突能够定位到就行。布局和内存无关。直接引用:是指向目标的指针,偏移量或者能够直接定位的句柄。该引用是和内存中的布局有关的,并且一定加载进来的。
3. 初始化
初始化是为类的静态变量赋予正确的初始值,准备阶段和初始化阶段看似有点矛盾,其实是不矛盾的,如果类中有语句:private static int a = 10,它的执行过程是这样的,首先字节码文件被加载到内存后,先进行链接的验证这一步骤,验证通过后准备阶段,给a分配内存,因为变量a是static的,所以此时a等于int类型的默认初始值0,即a=0,然后到解析(后面在说),到初始化这一步骤时,才把a的真正的值10赋给a,此时a=10。
相同的类被不同的类加载器加载了:
类的唯一性由类加载器和类本身决定,所以虚拟机中会存在两个被加载的类。