目录
ConcurrentHashMap和HashTable的区别(重点)
多线程出bug的原因分析
通过一个例子来描述bug的产生:
package Thread;
public class ThreadDemo7 {
public static class counter{
public int count = 0;
public void print(){
System.out.println(count);
}
}
public static void main(String[] args) {
counter counter = new counter();
Thread t1 = new Thread(){
@Override
public void run() {
for (int i = 0 ; i < 100_0000;i++){
counter.count++;
}
}
};
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0 ; i < 100_0000;i++){
counter.count++;
}
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
counter.print();
}
}
图片可以观察到,当我们用多线程操作同一资源时,结果可能和预期不同,出现bug
首先要清楚线程的工作流程:
一个线程从内存中读取资源,将其放入CPU的寄存器中,执行操作,执行完毕后将其写回内存当中
bug出现的原因:
-
抢占式执行。
-
修改同一个资源。
-
原子性:指令被分为读取,操作,存入。线程之间的指令可以相互穿插。
-
内存可见性:编译器在整体逻辑不变的情况下会对指令进行调整,做出一些更有花的执行方案,提高效率,比如读取内存速度比操作寄存器的速度慢上很多。多线程编程的情况下可能会导致线程1还未写入内存时,线程2读取了旧的数据。
-
指令重排序:编译器在确保逻辑不变的情况下,会自动的优化执行的顺序提高效率,在单线程的情况下没事,在多线程的情况下可能改变逻辑顺序。
synchronized(监视器锁)
保证原子性,内存可见性和禁止指令重排序
修饰一个方法或者一个代码块
就上一个问题通过synchronized对方法加锁优化后
public class ThreadDemo7 {
public static class counter{
public int count = 0;
synchronized public void increase(){
count++;
}
}
public static void main(String[] args) {
counter counter = new counter();
Thread t1 = new Thread(){
@Override
public void run() {
for (int i = 0 ; i < 100_0000;i++){
counter.increase();
}
}
};
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0 ; i < 100_0000;i++){
counter.increase();
}
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(counter.count);
}
}
除此之外synchronized还可以通过修饰代码块来枷锁
public void increase(){
synchronized (this){
count++;
}
}
public void run() {
for (int i = 0 ; i < 100_0000;i++){
synchronized (counter){
counter.increase();
}
}
}
这两种方法来实现相同的效果,()里面填写加锁的对象。
synchronized相当于在开始执行前先加入LOCK和UNLOCK,特性就是只能存在一个LOCK,其他线程申请LOCK时便会失败进入阻塞状态直到UNLOCK解锁后。将三个指令打包成为一个原子,不可拆分。
当通过synchronized锁上加锁时,在理论上就会触发死锁。
不过synchronized有特殊方法进行优化(可重入锁),即判断执行LOCK指令加锁线程是否和持有锁线程是同一个线程,如果是则计数器++,后面在遇到继续这样判断,当遇到解锁时继续判断是否为同一个线程,如果是则计数器--,直到计数器为0就会判断为真正的解锁,触发UNLOCK指令。
因为synchronized的多种限制编译器的优化,导致了使用了该结构的线程基本告别了高性能,在个别情况下并不适用,这时引入一个新的辅助功能votalie
volatile保证了内存可见性,禁止了指令重排序,但并没有保证原子性,因此在不访问同一资源的情况下,volatile效率将比synchronized高
各种数据结构的线程安全分析
线程不安全的结构:ArrayList,LinkedList,HashMap,TreeMap,HashSet,TreeSet...
线程安全的结构:Stack,Vactor,HashTable(不推荐使用,简单粗暴一个synchronized完成的)
1)多线程情况下使用顺序表:
1.自己加锁
2.Collections.synchronizedList,相当于在ArrayList等集合类加了一层壳,壳里面试用synchronized来加锁。
3.CopyOnWriteArrayList,让不同的线程使用不同的变量,并没有加锁。属于写时拷贝,多个线程来读取一份数据,某个线程进行了修改,立刻就给这个线程拷贝一份新的数据。
2)多线程情况下使用队列(使用阻塞队列):
1.ArrayBlockingQueue
2.LinkedBlockingQueue
3.PriorityBlockingQueue
4.TransferQueue(只包含一个元素的阻塞队列)
3)多线程情况下使用哈希表(不推荐使用HashTable)
并不推荐使用,单纯的一个synchronized对整个哈希表进了加锁,坏处就是锁冲突会特别容易发生。
4)ConcurrentHashMap(推荐使用)
1.并不是针对整个对象进行加锁,而是分成很多把锁,每个链表/红黑树进行加锁,只有当多个线程修改到同一个链表/红黑树才会发生锁竞争。
2.针对读操作直接不加锁,虽说是一个十分大胆的操作不过还好,大部分的场景状态下对读操作的线程安全并没有太高的要求,如果有则更加推荐读写锁的使用。
3.内部采用大量的CAS操作,提高效率
4.针对扩容进行了优化,HashTable的扩容特别麻烦,需要将整个表进行一次拷贝,如果轮到了哪个倒霉线程去执行,就需要负责整个扩容的过程,相对比来说,ConcurrentHashMap将扩容的任务分散开了,一次只扩容一点,能够更加平滑的过度。
ConcurrentHashMap和HashTable的区别(重点)
ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
底层数据结构: JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
实现线程安全的方式(重要): ① 在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配16个Segment,比Hashtable效率提高16倍。) 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。