因为这块资料比较多,所以随缘分篇。这篇主要讲集合类不安全。
其实这个是常识,可能刚刚入门就背下来了:HashMap不安全,HashTable安全,ArrayList不安全,Vector安全。这些东西很容易背,但是为什么安全?为什么不安全?从哪里体现的?什么情况下不安全?
这些问题当我们知道了集合类的底层原理,就很容易理解了,下面让我们走进具体问题。
ArrayList不安全
ps:这种不安全是指在多线程的情况下,单线程情况下不存在不安全。
首先ArrayList不安全我们众所周知,但是具体怎么不安全了?
其实这个就涉及到ArrayList的源码了。对于集合类,其实操作也就那么几个:增删改查。
这个源码写的。。简单易懂,就是内部维护的数组当前长度的下一个赋值为给定值。然后整个代码上没有任何防护措施。在单线程的环境下没问题,但是如果多线程同时往里添加呢?让我们用代码测试一下:
ArrayList多线程下的添加
如图,很简单的代码,用30个线程添加,然后就出问题了。报的错其实名字也很明显:并发-修改-异常。
会不会觉得有点懵?为什么啊?这个代码里也没修改啊怎么能报这个错呢!
其实很简单啊,我们知道是多线程。而且没有任何锁。可能走进这个方法是时候多个线程同时想往list里添加第i个元素。于是就出现了这个并发修改异常的错误。其实往下看这个异常的原因:checkForComodification。
也就是添加是抢了,也和预计的情况不符合了,但是真正报错的是我们添加完还读了。顺着报错找代码:
next方法中调用了这个方法
checkForComodification方法代码
其实挺好理解的,这里直接输出是个遍历的过程,我们在遍历的时候发现实际的数组长度和预期的数组长度不一样(因为可能在获取预期之后又增加元素了)。所以抛出这个错误,意思是我遍历到一半发现集合改变了,我没法往下走了!所以抛了个错。
当然了,其实除了这个显而易见报出来的错误,这里还有一个结果和期望不统一的错,因为没明显的报异常但是也不要忽视:
代码截图
如上代码,我明明是添加了300次,结果只有298个元素,就说明发生了争抢的结果(睡了3s,都添加完了的)
当然了,解决这个问题的办法也很多,比如:
- 用Vector替代ArrayList(但是Vector比ArrayList出的早,之所以现在不用就是因为用了Synchronized。所以性能很差,一般不用)
- 用Collections工具类包装一层,使之成为线程安全的(如下代码)
List<Integer> list = Collections.synchronizedList(new ArrayList<Integer>());
Collections的包装类
- 使用CopyOnWriteArrayList类
List<Integer> list = new CopyOnWriteArrayList<>();
这个类我们点进去看看源码:
如图,这个方法的add是安全的,加锁的,就不会出现之前ArrayList的实际上是添加,结果是多个抢一个位置,添加个数和预期不符合的问题了。而解决遍历到一半发现集合修改的报错的代码:就是这个类名字了:copy on write。这个数据结构有意思的点可以理解为:读写分离!
HashSet线程不安全
其实这个也是我们比较常用的set实现类,至于代码演示其不安全可以和ArrayList差不多。
set不安全
解决办法也简单说下:
- 用工具类包装:
Set<Integer> set = Collections.synchronizedSet(new HashSet<Integer>());
工具类包装成线程安全的
- 用JUC中的CopyOnWriteArraySet。原理和上个CopyOnWriteArrayList类似。
HashMap线程不安全
HashSet的底层是HashMap,之所有map是kv对,set只有一个元素的原因是set.add中,添加的元素是key,而value是一个Object的命名为PRESENT的常量。
所以HashSet的不安全,和HashMap的不安全其实是可以归类到一起的。展示不安全的代码如下:
而其解决办法依旧如上:
- 用工具类包装
- 用JUC中的类(注意这里名字变了,不是CopyOnWriteXXX了): ConcurrentHashMap
Java中的锁
公平锁:这个其实就是排队获取锁。
非公平锁:这个是每次锁一释放所有等待的线程都参与抢锁。
自旋锁:尝试获取锁的相乘不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗cpu。(CAS就是标准的自旋)
独占锁:指该锁一次只能被一个线程所持有。ReentrantLock和Synchronized都是独占锁。
共享锁:指该锁可以被多个线程所持有,比如读写锁ReentrantReadWriteLock该锁读锁是共享的,写锁的独占的。(因为读本身在自身不修改的情况下不会出问题,所以可以共享,保证高效。而写在多线程的情况下会出问题,所以写是互斥的,也就是独占的。)这个其实可以看成是synchronized->lock->读写锁。现在这个读写锁是一刀切的进一步进化。
其实这里的公平锁和非公平锁就没啥好说的了,一半默认都是非公平。而这么设置的原因是为了公平(这句话要细品)。当然了,至于实现的机制就不说了,无非就是一个有序一个无序。
然后说自旋锁:这个比较经典,昨天学CAS的时候就看到过demo:其中CAS中就是在do-while中实现的。上面也说了,其实就是用循环取尝试获取锁,减少上下文切换的消耗,但是循环本身也消耗cpu。(个人感觉什么时候用自旋还是比较清晰的,获取锁后处理快的话,比较适合自旋。这样哪怕同时大量线程进入循环等待也没多久就都处理完了。但是如果获取锁一个线程要很久,那么大量线程一直循环等待。比上下文切换的消耗还大,就不应该用自旋。这个是个人看法)
下面是一个自旋锁的demo:
/**
*
* @author lisijia
*
*/
public class Test6 {
AtomicReference<Thread> atomicReference = new AtomicReference<Thread>();
public void lock() {
Thread thread = Thread.currentThread();
System.out.println(thread.getName()+"进入!");
while(!atomicReference.compareAndSet(null, thread)) {
}
}
public void unLock() {
Thread thread = Thread.currentThread();
System.out.println(thread.getName()+"离开!");
atomicReference.compareAndSet(thread, null);
}
public static void main(String[] args) {
Test6 test6 = new Test6();
for(int i = 0;i<100;i++) {
new Thread(()-> {
test6.lock();
try {
TimeUnit.SECONDS.sleep(2);
} catch (Exception e) {
// TODO: handle exception
}
test6.unLock();
}).start();
}
}
}
这个代码运行起来的效果就是大家几乎同时一起进入到lock方法中。然后两秒钟出去一个。由此说明代码是严格按照预计走的(第一个线程进入并锁上了,然后过了两秒出去解锁。这个时候第二个线程早就进去了等一号线程解锁后拿到锁了,过了两秒解锁。这个两秒我们可以理解为while中执行的消耗时间。)
整个代码中没有用到lock也没有用到synchronized,但是确实是实现了加锁的功能。这就是自旋锁。
接着说下读写锁,众所周知的读占了百分之八十的请求,但是读本身是无害的,所以读的时候没必要加锁。但是写的时候如果不加锁就会发生很诡异的现象。最上面的demo中就有同时add结果抢一个位置的现象。所以读写锁可以实现的功能是读锁可以共享,写锁互斥。下面是一个demo:
class MyCache {
private volatile Map<String, Object> map = new HashMap<String, Object>();
private ReentrantReadWriteLock rwlock = new ReentrantReadWriteLock();
public void put(String key,Object value) {
rwlock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName()+"正在写入:"+key);
map.put(key, value);
System.out.println(Thread.currentThread().getName()+"写完了!");
} catch (Exception e) {
e.printStackTrace();
}finally {
rwlock.writeLock().unlock();
}
}
public Object get(String key) {
rwlock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName()+"正在读值!");
Object value = map.get(key);
System.out.println(Thread.currentThread().getName()+"读到的内容:"+value);
return value;
} catch (Exception e) {
return null;
}finally {
rwlock.readLock().unlock();
}
}
}
这个是加锁的代码,而且是用内部类的形式(注意这个类不是public),下面是测试代码:
public static void main(String[] args) {
MyCache myCache = new MyCache();
for(int i = 0;i<10;i++) {
final int j = i;
new Thread(()-> {
myCache.put(j+"",j);
}).start();
}
for(int i = 0;i<10;i++) {
final int j = i;
new Thread(()-> {
myCache.get(j+"");
}).start();
}
}
因为每次进入和出去都有打印,我们运行一下就能发现这段代码跑起来如下:
读的时候顺序是打乱的,同时多个线程读。而写的时候都是一个写完了才有下一个的。所以说写是独占,读是共享。
本篇笔记就记到这里,如果稍微帮到你了记得点个喜欢点个关注,也祝大家工作顺顺利利!念念不忘,必有回响,愿所有的付出都有回报!
网友评论