锁策略(CAS,死锁)和多线程对集合类的使用
锁策略
1.乐观锁VS悲观锁
2.轻量级锁VS重量级锁
3.自旋锁VS挂起等待锁
4.互斥锁VS读写锁
5.可重入锁vs不可重入锁
死锁的第一种情况
死锁的第二种情况
死锁的第三种情况
CAS
1.实现原子类
2.实现自旋锁
偏向锁:非必要,不加锁
锁消除
锁粗化
Callable 的用法
JUC(ava.util.concurrent)
原子类
信号量 Semaphore
CountDownLatch
多线程对集合类的使用
多线程环境使用 顺序表
多线程环境使用队列
多线程环境使用哈希表
其他方面的改进:
更充分的利用了CAS机制--无锁编程
优化了扩容策略
上面我说过,锁是为了解决线程冲突的问题。但是我也说过加锁操作会影响程序的效率。(因为阻塞),为了应对这个我们应该合理去进行加锁操作,那么就应该有策略的操作。
乐观锁: 预测接下来冲突概率不大(做的工作少)--->效率会快一些
悲观锁:预测接下了的冲突概率不大(做的多)--->x效率会慢一些
其实这两个就是预测接下来的锁冲突(阻塞等待)的概率是大,还是不大,根据这个冲突的概率,决定接下来怎么做。
Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
轻量级锁:加锁解锁的过程更快更高效。(一个乐观锁很可能是一个轻量级锁)
重量级锁:加锁解锁,过程更慢,更低效。(一个悲观锁很可能是一个重量级锁)
自旋锁:是轻量级锁的一种典型实现(纯用户态的不需要经过内核态(时间相对更短))
加锁失败后,不停等待的去问是否可以加锁了
挂起等待锁:是重量级锁的一种典型实现(通过内核机制来实现挂起等待(时间更长了))
加锁失败后,先去做其他事情,等这个锁给我信号后我就回来加锁。
Synchronized 既是悲观锁,也是乐观锁,既是轻量级锁,也是重量级锁;轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待锁实现。
而Synchronized 会根据当前锁竞争的激烈程度,自适应;
互斥锁:
synchronized是一个互斥锁,就单纯的加锁。通常只有两种操作:
读写锁:
有一种锁,把读操作和写操作分开加锁(线程安全):
约定:
Java中专门提供了读锁一个类,写锁一个类。
如何产生死锁,我们对一个代码加两次锁,此时内部的锁要等待外部的锁释放才能加锁,而此时外部的锁释放,需要等待内部锁加锁成功。然后逻辑上矛盾了,于是产生了死锁。
两个线程两把锁,即使单个线程是可重入锁,也会死锁。
线程1的外部锁加锁,需要等待线程2内部锁释放,同理线程2外部锁加锁,需要等待线程1内部锁释放,此时逻辑矛盾,产生死锁。
哲学家,就餐问题(N个线程,M把锁)
一个桌子上有五只筷子。也有五个人,桌上有一碗面,每个人只能用一双筷子吃一口。诺是五个同时拿起一只筷子,场上就构不成一双筷子的条件,也就是谁都吃不了面。此时就死锁了。
怎么办,很简单,五个人约定一个规则,谁先吃,谁后吃,此时就可以避开死锁的情况。
死锁的四个必要条件
实践中如何避免死锁?
对锁进行编号,如果需要获取多把锁,就约定加锁顺序,务必先对编号小的加锁,在对编号大的加锁。
公平锁VS非公平锁
约定:
遵循先来后到,就是公平锁,不遵守先来后到的(等概率竞争是不公平的),非公平锁。
synchronized是非公平的,要实现公平就需要在synchronized的基础上,加个队列来记录这些加锁线程的顺序。
总结一下synchronized的特点:
CAS: 全称Compare and swap,字面意思:“比较并交换”,一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
1. 比较 A 与 V 是否相等。(比较)
2. 如果比较相等,将 B 写入 V。(交换)
3. 返回操作是否成功。
真实的 CAS (即cpu的一条指令)是一个原子的硬件指令完成的(具有原子性),相当于我们不加锁,就能保证线程安全。
当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。
CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式)
讲到锁操作的时候,我们说过因为一个读一个写的两个线程,他们不会自己去检查变量是否发生过改变。但是CAS却可以进行自检,并返回是否成功。
基于CAS实现的操作:
标准库里提供AtomInteger类保证程序的原子性
通过CAS的自检性,反复检查当前的锁状态,看是否解开了;
但是CAS不是没有问题,最典型的问题A->B->A问题,其实就是我们要内存改变的值与内存的值一样,是得不断在A--B--A中不断横跳。在具体一点就是,两个线程(t1,t2)对数据进行减法,(t3)还有一个对数据进行加法,而加的数据与减的数据一样。
那么就会有一个问题。两个线程中其中一个线程(t1)提前做了减操作,接下来是(t3)加操作,此时内存的值没变,t2线程发现值是原来的值,又做了一次减操作。(这显然不是我们所期望的)
如何解决呢?
加入一个衡量内存的值是否变化的量,俗称版本号,版本号只能增加无法减少,每一次修改版本+1,这样我只需对比版本号本身就可以避免aba问题。
synchronized的锁策略:锁升级
先让线程针对锁,有个标记,如果整个代码执行过程中没有遇到别的线程和我竞争这个所,我就加锁了。但是如果有人来竞争,就升级为真的锁。这样既保证了效率,也保证了线程安全。
基础逻辑是,非必要不加锁。编译器+JVM 判断锁是否可消除,如果可以,就直接消除。检测当前代码是否多线程执行,判断是否有必要加锁,如果没有必要,但是又加上了锁,就会在编译过程中自动取消掉。
比如StringBuffer,在源码内加入了synchronized关键字。诺是单线程就必要加锁了,也就可以取消掉。
锁的粒度,synchronized代码块,包含代码的多少(代码越多,粒度越粗,越少,粒度越细),多数情况希望锁的粒度更小。(串行代码少,意味着并发代码就多。)
如果有一个场景需要频繁的加锁解锁,此时就会将整个场景锁起来,变成一个更粗的锁
Callable 是一个 interface . 相当于把线程封装了一个 "返回值". 方便程序猿借助多线程的方式计算结果,非常类似于Runnable,只不过返回值不是void,而是泛型
创建线程计算 1 + 2 + 3 + ... + 1000(非callable)
//创建一个类 Result , 包含一个 sum 表示最终结果, lock 表示线程同步使用的锁对象
static class Result {
public int sum = 0;
public Object lock = new Object();
}
public static void main(String[] args) throws InterruptedException {
Result result = new Result();
Thread t = new Thread() {
@Override
public void run() {
//main 方法中先创建 Result 实例, 然后创建一个线程 t. 在线程内部计算 1 + 2 + 3 + ... + 1000
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
synchronized (result.lock) {
result.sum = sum;
//主线程同时使用 wait 等待线程 t 计算结束
result.lock.notify();
}
}
};
t.start();
synchronized (result.lock) {
//
while (result.sum == 0) {
result.lock.wait();
}
//当线程 t 计算完毕后, 通过 notify 唤醒主线程, 主线程再打印结果.
System.out.println(result.sum);
}
}
创建线程计算 1 + 2 + 3 + ... + 1000(callable)
Callable callable = new Callable() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
return sum;
}
};
FutureTask futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
int result = futureTask.get();
System.out.println(result);
Callable中泛型是什么,就返回什么。
Callable 和 Runnable的区别
FutureTask 的理解,其实可以理解为,炖汤,通常炖汤我们将食物放入砂锅中,只需要等待时间过去2-3小时,砂锅就能为我们呈现一锅鲜美的汤。
ReentrantLock:可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全
用法:
ReentrantLock 和 Synchronized 的区别:
更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一 个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.
如何选择使用哪个锁?
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
本质是一个计数器,描述了当前“可用资源”的个数
如果计数器为0,就阻塞等待,等待出现资源时,及继续申请等待。
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println("申请资源");
semaphore.acquire();
System.out.println("我获取到资源了");
Thread.sleep(1000);
System.out.println("我释放资源了");
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 20; i++) {
Thread t = new Thread(runnable);
t.start();
}
同时等待 N 个任务执行结束.
public class Demo {
public static void main(String[] args) throws Exception {
CountDownLatch latch = new CountDownLatch(10);
Runnable r = new Runable() {
@Override
public void run() {
try {
Thread.sleep(Math.random() * 10000);
latch.countDown();
} catch (Exception e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 10; i++) {
new Thread(r).start();
}
// 必须等到 10 人全部回来
latch.await();
System.out.println("比赛结束");
}
}
常用的集合类:ArrayList,LinkedList,HashMap,PriorityQueue。。。线程是不安全的。
如果要使用怎么办?
1.可以手动对集合的修改操作加锁。(synchronized 或者 ReentrantLock)
2.使用java标准库提供的一些线程安全的版本的集合类。
ArrayList可用,Vertor代替,但是vertor该有的方法都用synchronized,是很老的集合,实际场景并不适用。
1.Collections.synchronizedList(new ArrayList);
2.使用 CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器。
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会
添加任何元素。
所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:
缺点:
多线程使用队列:BlockingQueue
在多线程环境下使用哈希表可以使用:
是线程安全的,给关键方法加上synchronized,颗粒度比较粗。它对整个哈市表加锁,任何的增删查操作,都会触发加锁,也就意味着会有锁竞争。其实没有必要,哈希表是有桶的,修改值是要通过key计算hash值,然后将新元素放到链表上。
两个线程对不同量进行修改,不会产生冲突,但是由于方法上加了锁也就意味着,两个线程同时使用一个方法会阻塞。(所以不建议)
线程是安全的,ConcurrentHashMap不是只有一把锁了,每个桶也就是链表的头结点作为一把,锁,这样针对不同的链表进行操作是不会产生的所冲突。大部分的加锁操作就没有锁冲突。
有些操作,比如获取或更新某个元素个数,就可以直接使用CAS完成,不必加锁
对于hashTable来说,如果元素太多们就会涉及扩容,诺元素很多很多,上亿个,那么将原表大部分的元素搬到新的位置上,这个操作非常不流畅。所以呢ConcurrentHashMap,在此基础上,诺put触发扩容机制,就会一次性创建更大的内存空间,然后搬运一部分,此时就相当于存在两个hash表,此时对表操作,插入是对新表插入,删除是对旧表(看元素在那个表上)删除,查找是新旧表都查。(每一次操作。都会从旧表搬运一部分到新表)
Hashtable和HashMap、ConcurrentHashMap 之间的区别
Java多线程是如何实现数据共享的?
Java创建线程池的接口是什么?参数 LinkedBlockingQueue 的作用是什么?
创建线程池主要有两种方式:
LinkedBlockingQueue 表示线程池的任务队列。 用户通过 submit / execute 向这个任务队列中添
加任务, 再由线程池中的工作线程来执行任务。