一、常见的锁策略
1、乐观锁和悲观锁
- 悲观锁:预测锁冲突的概率较高。在锁中加阻塞操作。
- 乐观锁:预测锁冲突的概率较低。使用忙等/版本号等,不产生阻塞。
2、轻量级锁和重量级锁
- 重量级锁:加锁的开销较大,线程等待锁的时间更长。
- 轻量级锁:加锁的开销较小。线程等待锁的时间更短。
3、挂起等待锁和自旋锁
- 挂起等待锁:是悲观锁/重量级锁的典型实现。遇到锁冲突,让线程挂起等待,调度出 cpu,产生阻塞。
- 自旋锁:是乐观锁/悲观锁的典型实现。遇到锁冲突,不放弃 cpu,而是“忙等”,没有产生阻塞,不停地尝试拿锁。
sychronized 属于自适应的,当竞争不激烈时,采取自旋锁策略;竞争激烈时,采取挂起等待锁策略。
4、公平锁和非公平锁
当锁被释放后,让哪个线程获取锁的策略。
- 公平锁:先来后到。需要引入队列记录顺序。
- 非公平锁:概率平等。
5、可重入锁和不可重入锁
- 可重入锁:同一把锁连续加锁,没有死锁。如 sychronized。
- 不可重入锁:同一把锁连续加锁,死锁。
6、读写锁和互斥锁
- 互斥锁:如 sychronied,加锁、解锁。
- 读写锁:加读锁、加写锁、解锁。某些场景读操作比写操作多得多,读写锁让读锁与读锁间不产生互斥(多线程读没有线程安全问题),读锁与写锁之间、写锁与写锁之间产生互斥。
二、sychronized 的优化
1、锁升级
synchronized 对锁策略的自适应调整,其实是锁升级的过程(只升级,不降级):
- 偏向锁:一开始没有竞争时,是偏向锁,只是修改一个标记来代替加锁,当出现竞争时,才真正加锁(类似懒汉模式)。
- 自旋锁:当出现锁竞争时,升级为自旋锁,采用忙等的方式解决锁冲突,能第一时间拿到锁。
- 重量级锁:当竞争激烈时,升级为重量级锁,采用阻塞的方式解决锁冲突。
而竞争的激烈程度,是由 JVM 内部统计这个锁上有多少个线程在等待获取。
2、锁消除
有时在代码中写了加锁,但并不必要,JVM 就会自动把锁给去掉。比如 StringBuilder 不带 synchronized,StringBuffer 带 synchronized,在单线程中使用 StringBuffer 就是没有必要的。
3、锁粗化
锁的粒度就是在加锁解锁的范围内,代码越多,锁的粒度越粗;反之越细。但加锁解锁会影响效率,锁粒度越细,加锁解锁就越频繁,有时是没有必要这么细,JVM 就会进行锁粗化。
三、CAS(Compare and Swap)
1、什么是 CAS
CAS 的伪代码:
- addtress 是内存地址,expectValue、swapValue 是两个寄存器存放的值。
- 内存的值跟寄存器1的值相等,就将内存的值跟寄存器2的值交换(因为我们只关心内存的值,所以直接将寄存器1的值赋值给内存),并返回 true。
- 如果不相等就返回 false。
- 这些逻辑是由一条 CPU 指令完成的,意味着它是原子的。
操作系统封装了这个指令为系统API,Java 又封装了系统API 为 unsafe 包里的操作,比较底层,可能不安全。因此我们常用的不是 unsafe,而是 unsafe 里的操作的进一步的封装类,比如原子类,等。
2、CAS 实现原子类
常用的就是这几个原子类:
原子类里的各种操作都是原子的,如下对原子整形类的各种操作:
import java.util.concurrent.atomic.AtomicInteger;public class Demo1 {private static AtomicInteger counter = new AtomicInteger(0);public static void main(String[] args) {// 以下的操作都是原子的,不加锁也能线程安全counter.incrementAndGet(); // 自增并返回新值,相当于 ++countercounter.decrementAndGet(); // 自减并返回新值,相当于 --countercounter.getAndIncrement(); // 返回当前值并自增,相当于 counter++counter.getAndDecrement(); // 返回当前值并自减,相当于 counter--counter.addAndGet(5); // 加上给定值并返回新值,相当于 counter += 5counter.getAndAdd(5); // 返回当前值并加上给定值,相当于 counter += 5counter.getAndSet(10); // 返回当前值并设置为新值,相当于 counter = 10}
}
可以看到底层是用 CAS 实现的:
getAndIncrement 伪代码:
class AtomicInteger {private int value;public int getAndIncrement() {int oldValue = value;while ( CAS(value, oldValue, oldValue+1) != true) {oldValue = value;}return oldValue; // 先返回旧值,再自增的逻辑}
}
- 判断内存中的 value 是否跟寄存器1 中的 oldValue 一样,一样则更新 value 内存值为 oldValue+1,并返回 true,跳出循环返回自增后的值(一样则说明,没有其它线程更改过 value 内存的值,可以进行安全的更新操作)。
- 不一样,则返回 false,load 内存中的值 value 到寄存器1 oldValue,继续判断(不一样说明,其它线程更改过内存 value 的值,需要更新寄存器1中 oldValue)。
- CAS 实现的操作,解决了多线程对同一变量修改的线程不安全问题;也解决了内存可见性问题。
使用原子类的示例,两个线程同时对 count 计数,没有出现线程不安全问题:
计数的场景推荐使用 CAS,因为它不用加锁解锁,效率高。比如统计服务器一天有多少用户访问量、有多少个广告被展示等。
3、CAS 实现自旋锁
- 当 owner 不为空,说明锁被其它线程占有,那么就一直循环等待,也一直占用 cpu 资源,在竞争不激烈的时候,当锁被释放,能第一时间拿到锁,这就是忙等。
- 当 owner 为空,就将当前线程的引用赋值给 owner,表示加锁。
- 解锁,就让 owner 重新为 null(单纯的赋值,本身就是原子操作)。
- 当竞争激烈时,不要用自旋锁,因为有大量线程处于忙等的状态,占用大量 cpu 资源。
4、CAS 的 ABA 问题
虽然 CAS 是原子的,也能避免内存不可见问题,但是当把 A 改为 B 又改为 A 时,因为值 A 没变,会误判为没有进行修改。如下面的银行取钱场景:按了一个取钱,产生线程 t1,这时atm卡住了;又按了一次取钱,产生线程 t2,扣了 500;这时又有 t3 线程转 500 回来;最后卡住的 t1 又恢复,因为余额没变(1000),所以又多扣了一次 500。
解决办法,引入版本号。AtomicStampedReference<V> 类不仅包装了 value 属性,还包装了版本号属性,每进行一次修改,版本号就会加1。在比较的时候,虽然 value 又改回了 1000,没有变,但是版本号增加了两次,因此版本号不同,不触发多余的扣款。
四、JUC(java.util.concurrent)的常见类
concurrent 就表示并发,java 中的并发以多线程体现,所以这个包里都是关于多线程的一些类。
1、Callable 接口
作用类似于 Runnable,用于描述一个任务,但是他多了一个返回值。Runnable 关注一段逻辑,Callable 关注一段逻辑运行的结果。
如果 Runnable 想要得到一个任务执行的结果,需要在类里加一个属性,用于在 Runnable 中存储结果,但是这样类的成员属性和 Runnable 任务的耦合就比较高,我们不希望高耦合。
而 Callable 会返回执行结果,Callable 是泛型类,可以设置返回值的类型;将 Callable 传给 Thread 执行,需要用 FutureTask 进行包装;使用 Future 类的 get 方法获取结果,当逻辑没执行完时,get 方法阻塞,所以会抛出 InterruptedException,另外还有 ExecutionException 异常。
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;public class Demo3 {public static void main(String[] args) throws InterruptedException, ExecutionException {FutureTask<Integer> futureTask = new FutureTask<>(() -> {int result = 0;for(int i = 1; i <= 100; i++) {result += i;}return result;});Thread thread = new Thread(futureTask);thread.start();System.out.println(futureTask.get());}
}
总结,创建线程的方式:
- 继承 Thread。
- 实现 Runnable。
- 实现 Callable。
- 线程池。
2、ReentrantLock
ReentrantLock 也是可重入互斥锁,但它与 synchronized 不同的是:
- synchronized 是 JVM 内部实现的,reentrantLock 是 JVM 外部实现的(Java)。
- reentrantLock 需要手动加锁、解锁,所以容易遗漏解锁;可以用 try-finally 让程序无论以何种方式结束都能执行解锁,但这种写法不太优雅,这是它相对于 sychronized 的缺点。
- synchronized 加锁失败会死等,而 reentrantLock 加锁失败可以直接放弃等待,或者等一段时间放弃(使用 tryLock 实现)。
或者多次尝试加锁失败后放弃:
- synchronized 是非公平锁;reentrantLock 可以是非公平的,也可以设置为公平的(传入 true 参数)。
- synchronized 通过 wait/notify 实现等待-唤醒,随即唤醒一个等待线程;reentrantLock 通过 Condition 类实现等待-唤醒,可以精准唤醒某个等待线程。
3、信号量 Semaphore
信号量本质是一个可用资源数的计数器。P 操作申请资源,计数器减1;V 操作释放资源,计数器加1。当计数器为 0 时,表示没有可用资源,这时申请资源就会发生阻塞(因此也会抛出 InterruptedException 异常)。
可用资源数为 1 的信号量,就相当于加锁。
总结,编写线程安全的代码,可以用:
- 加锁
- CAS(原子类)
- 信号量
4、CountDownLatch
可用于计数已完成的线程数,当所有线程都完成后,阻塞结束。
import java.util.concurrent.CountDownLatch;public class Demo3 {public static void main(String[] args) throws InterruptedException {// 初始化,计数的任务个数CountDownLatch countDownLatch = new CountDownLatch(8);for (int i = 0; i < 8; i++) {int id = i;Thread thread = new Thread(() -> {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("线程" + id + "完成任务");// 计数countDownLatch.countDown();});thread.start();}// 所有线程执行结束后,结束阻塞countDownLatch.await();System.out.println("所有任务完成");}
}
应用场景:把一个大任务拆成多个子任务,比如多线程下载,通过多线程与资源服务器建立多个网络连接。运营商说套餐升级为多大的带宽是没用的,因为资源服务器供应商会限制带宽,就算你有 500 Mbps(62.5 MB/s) 的带宽,一个线程能达到 5 MB/s 都算快的。像百度网盘买了会员,会提供多线程下载,就会快很多。
5、线程安全的集合类
5.1、多线程环境使用 ArryList
- 使用同步机制。
- Collections.synchronizedList(new ArrayList):返回的 List.synchronizedList 的关键方法带有 sychronized 关键字。
- CopyOnWriteArrayList:写时复制容器。对写操作加锁,对读操作不加锁,让读的性能提升,适用于“多读少写”的场景。写操作时,会先复制当前数组的副本,再对副本进行修改,再将当前数组的引用指向副本。读操作时,直接访问当前数组(跟副本不是同一个对象,如果读操作在写之前读取,就算写操作更新了当前数组的引用,读操作访问的也是之前的数组对象)。缺点就是:占内存多、不能第一时间读取到新写的数据。
5.2、多线程环境使用队列
- ArrayBlockingQueue:基于数组的阻塞队列
- LinkedBlockingQueue:基于链表的阻塞队列
- PriorityBlockingQueue:基于堆的优先级阻塞队列
- TransferQueue:最多只包含一个元素的阻塞队列。
5.3、多线程环境使用 Hash 表
HashMap 是线程不安全的,比如多个线程对同一个哈希表中的同一个链表进行修改,对存储的键值对数量 size 进行修改。可以用以下方法解决:
- 自己加锁。(不推荐,肯定没有现成的包好用)
- Hashtable。(不推荐,他对关键方法用 synchronized 修饰,锁是 this,任何线程对 Hash 表进行修改,都会触发阻塞,导致效率大大降低。我们根本没必要对整个表用同一把锁,因为对不同链表上的数据进行修改是线程安全的。所以 Hashtable 即将被 JDK 废弃)
-
ConcurrentHashMap:
① 它采用锁桶的方案,将数组中存储的链表引用作为锁(每个锁就是一个锁桶),对哈希表上的元素进行操作,大概率分布在不同锁桶上,触发锁竞争的概率很小。
② 而 size 的修改是针对整个表的操作,如果依然用锁实现,那么锁桶的优化就没有什么用了。因此对 size 的修改采用的是 CAS 操作。
③ 在扩容时采取 “化整为零” 的方案。因为一般数据量很大,扩容时需要搬运的数据就很多,为了保证线程安全,如果采用锁,那么阻塞的时间就很长很长,导致其它线程无法使用哈希表。采取的方法就是,一旦触发扩容,每次进行 get、put、remove 等都会搬运一点。
ConcurrentHashMap 的使用方法跟 HashMap 大致一样: