【JAVA EE初阶】多线程(进阶)
目录
1.常见的锁策略
1.1 悲观锁vs乐观锁
1.2 重量级锁vs轻量级锁
1.3 挂起等待锁vs自旋锁
1.4 普通互斥锁vs读写锁
1.5 可重入锁vs不可重入锁
1.6 公平锁vs非公平锁
1.7 synchronized的优化:
1.8 相关面试题
2.CAS比较和交换(compare and swap)
2.1 相关面试题
3.JUC中的组件
3.1 Callable接口
3.2 ReentrantLock可重入
3.3 原子类
3.4 线程池
3.5 信号量Semaphore
3.6 CountDownLatch
3.7 相关⾯试题
4.线程安全的集合类
4.1 多线程环境使用ArrayList
4.2 多线程环境使用队列
4.3 多线程环境使用哈希表
5. 面试题
1.常见的锁策略
1.1 悲观锁vs乐观锁
不是针对某一个具体的锁,而是某个具体锁具有“悲观”特性或者“乐观”特性。
悲观:加锁的时候,预测接下来的锁竞争的情况非常激烈,需要针对这样的激烈情况额外做一些工作。
乐观:加锁的时候,预测接下来的锁竞争的情况不激烈,不需要做额外工作。
1.2 重量级锁vs轻量级锁
重量级锁,当悲观的场景下,此时要付出更多的代价(更低效)。
轻量级锁,当乐观的场景下,此时要付出的代价更小(更高效)。
1.3 挂起等待锁vs自旋锁
挂起等待锁:重量级锁的典型实现。操作系统内核级别的,加锁的时候发现竞争,就会使该线程进入阻塞状态,后续就需要内核进行唤醒。
自旋锁:轻量级锁的典型实现。应用程序级别的,加锁的时候发现竞争,一般也不是进入阻塞,而是通过忙等的形式进行等待。(乐观锁的场景,本身遇到锁竞争的概率很小,真的遇到竞争,在短时间内就能拿到锁)
JVM内部会统计每个锁竞争的激烈程度,如果竞争不激烈,此时synchronized就会按照轻量级锁(自旋);如果竞争激烈,此时synchronized就会按照重量级锁(挂起等待)。
1.4 普通互斥锁vs读写锁
synchronized不是读写锁。
多个线程读取一个数据,是本身就线程安全的。多个线程读取,一个线程修改,肯定会涉及到线程安全问题。
读写锁,确保读锁和读锁之间不是互斥的(不会产生阻塞)。写锁和读锁之间产生互斥。写锁和写锁之间产生互斥。
保证线程安全的前提下,降低锁冲突的概率,提高效率。
读写锁适合读多写少的情况。
1.5 可重入锁vs不可重入锁
synchronized是“可重入锁”,一个线程一把锁,连续加锁多次,是否会死锁。出现死锁,就是可重入,否则不可重入。
- 锁要记录当前是哪个线程拿到的这把锁
- 使用计数器,记录当前加锁了多少次,在合适的时候进行解锁。
1.6 公平锁vs非公平锁
synchronized是非公平锁。
结合上⾯的锁策略, 我们就可以总结出, synchronized 具有以下特性(只考虑 JDK 1.8):1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.2. 开始是轻量级锁实现, 如果锁被持有的时间较⻓, 就转换成重量级锁.3. 实现轻量级锁的时候⼤概率⽤到的⾃旋锁策略4. 是⼀种不公平锁5. 是⼀种可重⼊锁6. 不是读写锁
1.7 synchronized的优化:
锁升级:
synchronized是自适应的。自适应的过程,锁升级:无锁=>偏向锁=>自旋锁=>重量级锁。
偏向锁:本质上是懒汉模式。进行synchronized一开始不真加锁,而是简单做个标记,这个标记非常轻量,相对于加锁解锁效率高很多。如果没有其他线程竞争这个锁,最终当前线程执行到解锁代码也就是简单清楚上述标记即可。如果有其他线程来竞争,就抢先一步,在另一个线程拿到锁之前抢先拿到锁,进行加锁,偏向锁就变成轻量级锁,其他线程只能阻塞等待。
当前JVM中,只提供了“锁升级”不能“锁降级”。
锁消除:也是编译器优化的一种体现。编译器会判定当前这个代码逻辑是否真的需要加锁,如果不需要加锁,但有synchronized,就会自动把synchronized去掉。
锁粗化:
加锁和解锁之间包含的代码越多(不是代码行数,而是实际执行的指令/时间),就认为锁的粒度越粗。如果包含的代码越少,就认为锁的粒度越细。
一个代码中,反复针对细粒度的代码加锁,就可能被优化成更粗粒度的加锁。
1.8 相关面试题
2.CAS比较和交换(compare and swap)
boolean CAS(address,expectValue,swapValue){//(内存地址,寄存器的值,另一个寄存器的值)if(&address==expectValue){&address=swapValue;return true;}return false;}//判定内存中的值和寄存器1的值是否一致,如果一致就把内存中的值和寄存器2进行交换。//但是由于基本上只是关心交换后内存中的值,不关心寄存器2的值,此处也可以把这样的操作理解为“赋值”
CAS是CPU的一条指令。(原子)
CAS本质上是CPU的指令,操作系统就会把这个指令进行封装,提供一些API,就可以在C++中调用,JVM又是基于C++实现的,JVM也能够使用C++调用CAS操作。
CAS最主要的用途:实现原子类。使用原子类的目的就是为了避免加锁。通过CAS能够确保性能,同时保证线程安全。
import java.util.concurrent.atomic.AtomicInteger;public class Demo39 {//使用原子类代替intprivate static AtomicInteger count = new AtomicInteger(0);//原子类基于CAS实现public static void main(String[] args) {Thread t1=new Thread(()->{for(int i=0;i<50000;i++){count.incrementAndGet();}});Thread t2=new Thread(()->{for(int i=0;i<50000;i++){count.incrementAndGet();}});t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println(count.get());}
}
原子类,专有名词,atomic这个包里的类,synchronized保证一个修改的原子性,和“原子类”无关。
基于CAS实现自旋锁:
public class SpinLock{private Thread owner=null;//如果为null,锁是空闲的。如果非null,锁已经被某个线程占有了public void lock(){while(!CAS(this.owner,null,Thread.currentThread())){//加锁操作中,需要判定锁是否被人占用//如果未被人占用就把当前线程的引用设置到owner中//如果已经被人占用就等待//这里就开始自旋,发现锁已经被占用,CAS不会执行交换,返回false,进入循环,进入下一次判定//由于循环体是空着的,整个循环速度非常快(忙等)//但是一旦其他线程释放锁,此时该线程就能第一时间拿到这里的锁}}public void unlock(){this.owner=null;//引用赋值操作,本身就是原子指令}}
CAS的典型缺陷:ABA问题
使用CAS能够进行线程安全的编程,核心是先比较“相等”,内存和寄存器是否相等。(这里本质上在判定是否有其他线程插入进来做了一些修改)
认为如果发现这里寄存器和内存的值一致,就可以认为是没有线程穿插过来修改,因此接下来的修改操作就是线程安全的。本来判定内存的值是否是A,发现果然是A,说明没有其他线程修改过。但是实际上,可能存在一种情况,另一个线程把内存从A修改成B,又从B修改为A。
2.1 相关面试题
3.JUC中的组件
3.1 Callable接口
和Runnable接口并列关系
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;public class Demo40 {public static void main(String[] args) throws ExecutionException, InterruptedException {//此处的Callable只是定义了一个“带有返回值”的任务//并没有真正在执行,执行还需要搭配Thread对象Callable<Integer> callable = new Callable<Integer>() {@Overridepublic Integer call() throws Exception {int sum = 0;for (int i = 1; i <= 10; i++) {sum += i;}return sum;}};FutureTask<Integer> futureTask = new FutureTask<>(callable);//Thread本身不提供获取结果的方法,需要凭FutureTask对象来拿到结果Thread thread = new Thread(futureTask);thread.start();System.out.println(futureTask.get());//get操作就是获取到FutureTask的返回值,这个返回值来自于Callable的call方法}
}
创建线程的写法:
- 继承Thread(定义单独的类/匿名内部类)
- 实现Runnable(定义单独的类/匿名内部类)
- lambda
- 实现Callable(定义单独的类/匿名内部类)
- 线程池 ThreadFactory
3.2 ReentrantLock可重入
和synchronized是并列关系。
import java.util.concurrent.locks.ReentrantLock;public class Demo42 {private static int count=0;public static void main(String[] args) {ReentrantLock locker = new ReentrantLock();Thread t1=new Thread(new Runnable() {@Overridepublic void run() {for (int i = 0; i < 50000; i++) {locker.lock();try{count++;}finally {locker.unlock();}}}});Thread t2=new Thread(new Runnable() {@Overridepublic void run() {for (int i = 0; i < 50000; i++) {locker.lock();try{count++;}finally {locker.unlock();}}}});t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println(count);}
}
ReentrantLock和synchronized的区别:
- synchronized是关键字(内部实现是JVM,内部通过C++实现),ReentrantLock标准库的类(基于JAVA实现)
- synchronized通过代码块控制加锁解锁,不需要手动释放锁,ReentrantLock需要lock/unlock方法,需要注意unlock不被调用的问题,需要手动释放锁
- ReentrantLock除了lock,unlock之外,还提供了一个方法tryLock(),不会阻塞。加锁成功后返回true,加锁失败返回false.调用者判定返回值决定接下来怎么做。可以设置超时时间,等待时间达到超时时间再返回true/false
- ReentrantLock提供了公平锁的实现,默认是非公平的
ReentrantLock locker = new ReentrantLock(true);//公平锁
- ReentrantLock搭配的等待通知机制是Condition类,可以更精确控制唤醒某个指定的线程,相比wait notify来说功能更强大
- 锁竞争不激烈的时候, 使⽤ synchronized, 效率更⾼, ⾃动释放更⽅便.
- 锁竞争激烈的时候, 使⽤ ReentrantLock, 搭配 trylock 更灵活控制加锁的⾏为, ⽽不是死等.
- 如果需要使⽤公平锁, 使⽤ ReentrantLock
3.3 原子类
原子类内部使用CAS实现,性能要比加锁实现i++高很多,原子类有AtomicInteger,AtomicLong
3.4 线程池
3.5 信号量Semaphore
能够协调多个线程之间的资源分配
信号量表示的是“可用资源的个数”,申请一个资源(P操作),计数器-1,释放一个资源(V操作),计数器+1。计数器为0,继续申请就会阻塞等待。
信号量的一个特殊情况:初始值为1的信号量,取值要么是1要么是0(二元信号量),等价于“锁”,普通的信号量相当于锁的广泛推广。
如果是普通的N个信号量,就可以限制同时有多少个线程来执行某个逻辑。
import java.util.concurrent.Semaphore;public class Demo44 {private static int count = 0;public static void main(String[] args) throws InterruptedException {Semaphore semaphore = new Semaphore(1);Thread t1=new Thread(new Runnable() {@Overridepublic void run() {for (int i = 0; i < 50000; i++) {try{semaphore.acquire();count++;semaphore.release();} catch (InterruptedException e) {throw new RuntimeException(e);}}}});Thread t2=new Thread(new Runnable() {@Overridepublic void run() {for (int i = 0; i < 50000; i++) {try{semaphore.acquire();count++;semaphore.release();} catch (InterruptedException e) {throw new RuntimeException(e);}}}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
3.6 CountDownLatch
使用多线程,经常把一个大的任务拆分成多个任务,使用多线程执行这些子任务提高程序的效率。
衡量子任务全部完成:
- 构造方法指定参数,描述拆分成多少个任务
- 每个任务执行完毕之后,都调用一次countDown方法
- 主线程中调用await方法,等待所有任务执行完毕。await就会返回/阻塞等待
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;public class Demo45 {public static void main(String[] args) throws InterruptedException {//把整个任务拆成10个部分,每个部分视为一个“子任务”//可以把10个子任务丢到线程池,也可以安排10个独立的线程执行CountDownLatch latch = new CountDownLatch(10);//构造方法中传入的10表示任务的个数,CountDownLatch用于阻塞主线程,直到所有的子任务都执行完毕ExecutorService exec = Executors.newFixedThreadPool(5);for (int i = 0; i < 10; i++) {int id=i;exec.submit(()->{System.out.println("子任务开始执行"+id);try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("子任务结束执行"+id);latch.countDown();});}latch.await();//阻塞等待所有的任务结束System.out.println("所有任务执行完毕");exec.shutdown();}
}
3.7 相关⾯试题
class AtomicInteger {private int value;public int getAndIncrement() {int oldValue = value;while ( CAS(value, oldValue, oldValue+1) != true) {oldValue = value;}return oldValue;}
}
4.线程安全的集合类
4.1 多线程环境使用ArrayList
- 自己使用同步机制(synchronized或者ReentrantLock)自行加锁,分析清楚把哪些代码打包到一起成为一个“原子”操作
- Collections.synchronizedList(new ArrayList),返回的List的各种关键方法都是带有synchronized,类似于Vector,Hashtable,StringBuffer
- 使用CopyOnWriteArrayList,多线程读取,复制过程中如果其他线程在读,就直接读取旧版本的数据,虽然复制过程不是原子的(消耗一段时间),由于提供了旧版本的数据,不影响其他线程读取。新版本数组复制完毕之后,直接进行引用的修改,引用的赋值是“原子”。确保读取过程中,要么读到的是旧版数据,要么读到的是新版数据,不会读到“修改一半”的数据。
- 这个过程没有加锁,不会产生阻塞
- 有明显的缺点:数组很大,非常低效;如果多个线程同时修改容易出问题
4.2 多线程环境使用队列
- ArrayBlockingQueue基于数组实现的阻塞队列
- LinkedBlockingQueue基于链表实现的阻塞队列
- PriorityBlockingQueue基于堆实现的带优先级的阻塞队列
- TransferQueue最多只包含一个元素的阻塞队列
4.3 多线程环境使用哈希表
HashMap本身不是线程安全的。
Hashtable是线程安全的(给各种public 方法都加synchronized),此时任意两个线程,访问任意的两个不同元素都会产生锁竞争。
如果修改的两个元素在不同链表上,本身就不涉及线程安全问题(修改不同变量);
如果修改同一个链表上的两个元素,可能有线程安全问题,比如把这两个元素插入到同一个元素后面,就可能产生竞争。
给每一个链表加上不同的锁(针对不同的锁对象加锁),不会产生锁竞争(不会阻塞)。Java中任意一个对象都可以作为锁对象,在这个逻辑中不需要额外创建对象作为锁,直接使用每个链表的头结点作为synchronized的锁对象即可。
在多线程环境下通常使用ConcurrentHashMap替代Hashtable
ConcurrentHashMap核心优化点:
- 按照桶级别进行加锁,而不是给整个哈希加一个全局锁,把锁整个表优化成锁桶(用每个链表的头结点作为锁对象),大大降低了锁冲突的概率
- 充分利用CAS特性,使用原子类针对size进行维护,避免出现重量级锁的情况
- 优化了扩容方式:化整为零。针对哈希扩容(意味着需要创建更大的数组,把旧哈希表中的所有元素复制到新的哈希中,元素多耗时长),一次复制完所有的元素比较耗时,需要多次的put/get来完成