注:此博文为本人学习过程中的笔记
1.常见的锁策略
当我们需要自己实现一把锁时,需要关注锁策略。Java提供的synchronized已经非常好用了,覆盖了绝大多数的使用场景。此处的锁策略并不是和Java强相关的,只要涉及到并发编程,涉及到锁,都要关注锁策略。锁策略就是指这个锁在加锁的过程中有什么特点,有什么行为
1.乐观锁和悲观锁
这不是针对某一把具体的锁,而是一种特性,一把锁具有“乐观”或者“悲观”的特性。
悲观锁是指加锁的时候,预测接下来的锁竞争非常激烈,针对这样激烈的情况会做一些额外的工作。
乐观锁是指加锁的时候,预测接下来的锁竞争不激烈,不做额外的工作。
2.重量级锁和轻量级锁
这是指遇到以上场景之后的解决方案
重量级锁是指当悲观的场景下付出更多的代价,更低效
轻量级锁是指当乐观的场景下付出较少的代价,更高效
3.挂起等待锁和自旋锁
挂起等待锁是重量级锁的典型实现,是操作系统内核级别的,加锁的时候发现竞争,就会使线程进入阻塞状态,后续就需要内核进行唤醒了。竞争激烈,获取锁的周期更长,很难及时获取到锁,此时这个过程就不会消耗cpu资源,让cpu去做其他的事情。
自旋锁是轻量级锁的典型实现,是应用程序级别的,加锁的时候发现竞争,一般是不进入阻塞,而是通过忙等的形式进行等待。锁竞争不激烈,获取锁的周期更短,等待锁的过程中会一直消耗cpu资源。
4.普通互斥锁和读写锁
synchronized就是普通互斥锁,只有加锁和解锁。而读写锁存在读方式加锁,写方式加锁和解锁。多个线程读取一个数据本身就是线程安全的,但是当遇到多个线程读数据,而有一个线程写数据,那么就会涉及到线程安全问题了。当大部分操作在读,少部分操作在写,这时把锁设置成普通互斥锁就意味着锁冲突会非常严重。读写锁能确保读锁和读锁之间不互斥,而读锁和写锁,写锁和写锁之间产生互斥,在保证线程安全的前提下,减少锁冲突的概率,提高效率。
Java标准库中可以使用读写锁,使用的是经典的lock和unlock写法
5.可重入锁和不可重入锁
当一个线程针对一把锁连续加锁的时候,可重入锁不会造成死锁,synchronized就是可重入锁。可重入锁的要点有:1.锁要记录当前是哪个线程拿到锁,2.统计加锁的次数,在合适的时机释放锁。
6.公平锁和非公平锁
当多个线程争取一把锁的时候,锁被释放时,如果遵守先来后到的原则,那么这个锁就是公平锁。synchronized是非公平锁,锁在默认情况下被获取到的概率是均等的,因为操作系统的调度是随机的。要想实现公平锁需要付出额外的代价,比如用一个队列来记录各个线程获取锁的顺序。
7.synchronized
synchronized是自适应的锁,不是读写锁,是可重入锁和非公平锁。自适应是指,jvm会统计锁竞争的激烈程度,来决定锁是挂起等待锁还是自旋锁。锁自适应的过程存在锁升级,由无锁升为偏向锁,再身为自旋锁,最后升级为挂起等待锁。偏向锁就是指当synchronized的时候不是一上来就加锁,而是加一个标记,如果这个锁没有被竞争,就不会真正的加锁,在解锁的时候把这个标记删除即可。如果这个锁被竞争了,那就会真正加锁。这个标记是非常轻量的,比加锁解锁高效得多。当前jvm只提供了锁升级,不存在锁降级。
8.锁消除
这是编译器优化的一种体现,编译器会判定当前这个代码是否真的需要加锁,如果确实不需要加锁,就会自动把synchronized删去。这个优化的策略是比较保守的,所以我们加锁的时候还是要仔细辨别。
9.锁粗化
这里引入一个新的概念,锁的粒度。当加锁和解锁之间包含的代码越多(需要执行的指令),锁的粒度就越粗,反之越细。一个代码中反复针对细粒度的代码进行加锁,就可能优化成粗粒度的锁。因为每次加锁解锁都会增加锁的竞争,影响效率。
2.CAS
CAS是指比较和交换,compare and swap。
boolean CAS(address, expectedValue, swapValue) {if(&address == expectedValue) {address == swapValue;return true;}return false;
}
这里的伪代码是指判定内存中的值是否和寄存器1的值一致,如果一致就把内存中的值和寄存器2的值进行交换。由于这里基本只关心内存里的值,而不关注寄存器2的值,所以可以把这里理解成赋值,基于交换实现的赋值。
CAS是cpu的一条指令,所以它是原子的,这就对我们编写多线程代码产生了很大的作用。
CAS本质上是cpu的指令,操作系统把这个指令进行了封装,提供了一些api,就可以在C++被调用了,而jvm又是C++实现的,所以jvm也能实现CAS操作。
1.原子类
CAS主要的应用是原子类。以下是Java标准库中提供的原子类。原子类是一个专有名词,特指atomic这个包里的这些类。
对boolean,int,long这些类型进行了封装,确保性能,又能确保线程安全。
以AtomicInteger里的getAndIncrement为例(以下是伪代码)
class AtomicInteger {private int value;public int getAndIncrement() {int oldValue = value;//可以把oldValue理解成寄存器while(CAS(oldValue, value, oldValue + 1) != true) {oldValue = value;}return oldValue;}
}
在计算的过程中,即使是多线程操作,因为CAS会不停对比寄存器和内存里的值,所以不会产生线程安全问题。
2.基于CAS实现自旋锁
public class SpinLock {private Thread owner;private void lock() {//通过CAS判断当前锁是否被线程持有//如果这个锁已经被其他线程所持有,那么就自旋等待//如果这个锁没有被其他线程持有,那么锁的拥有者就设为当前线程while(!CAS(this.owner, null, Thread.currentThread)) {}}private void unlock() {this.owner = null;}
}
3.CAS的典型缺陷
CAS有一个典型的缺陷,是ABA问题。使用CAS能够进行线程安全的编程的核心就是比较,比较内存和寄存器里的值。这里本质上就是在判定是否有其他线程插入进来进行了修改。我们认为如果内存和寄存器里的值一致,那么就没有线程穿插进来修改。但实际上存在另一种情况,另一个线程把内存里的A改成B,又把B改回A。
ABA问题一般不会产生什么大问题,只有在极端情况下才会产生严重问题。
1.事例
以一个取钱的问题举例,假设我的余额有1000,我在atm机前想取出500。由于我不当的操作,让机器里产生了两个线程来进行取钱操作。即使两个线程穿插操作,因为CAS有判断,所以不会产生什么问题。但如果在第一个线程的CAS执行完毕,余额变成500之后,刚好在那个瞬间有人往我的余额里转了500,那么余额又变成了1000,此时第二个线程进行判断后,就又会扣500。最终,我的余额就只有500了。
2.解决方法
在上述问题中,是使用余额来判定是否有其他线程插入,余额这个数值是既能增加又能减少,所以会产生ABA问题。如果这个时候我们引入一个其他指标,比如“版本号”,规定它只能增加,不能减少,每进行一次操作时,版本号就加1,就能有效避免ABA问题。
3.JUC相关的组件
这里的JUC指的是,java.util.concurrent这个包。这个包里封装了和并发编程相关的东西。
1.Callable接口
这个接口和Runnable接口是并列关系。Runnable接口里面的run方法没有返回值,Callable接口里面的call方法可以设置返回值
public Test {public static void main(String[] args) {Callable<Integer> callable = new Callable<>(){public int call() {int result = 0;for(int i = 0; i < 5000; i++) {result++;}return result;}};//Thread没有提供参数是Callable的构造方法,所以要借助FutureTask这个类//注意这里的泛型类要和Callable的一致FutureTask<Integer> futureTask = new FutureTask<>(callable);Thread t = new Thread(futureTask);t.start();//get操作就是获取FutureTask的返回值,这个返回值来自Callable里的call方法//get是可能会阻塞的,如果线程没有执行完,get拿不到返回结果,那么它就会一直阻塞System.out.println(futureTask.get());}
}
2.ReentrantLock
ReentrantLock和synchronized是并列的,都是用来加锁的。
1.synchronized是关键字(内部是通过C++实现的),ReentrantLock是类(内部是Java代码实现的)。
2.synchronized是通过代码块来加锁,ReentrantLock是通过lock和unlock来加锁的,注意unlock容易掉,搭配finally使用
3.ReentrantLock除了提供lock方法外,还提供了一个tryLock方法,这个方法加锁不会阻塞,会返回true或者false,由开发者根据判定结果决定后续的操作。
4.ReentrantLock还提供了公平锁的实现,它默认是非公平锁,在new的时候往括号里填个true就能获得一把公平锁。
5.ReentrantLock搭配的等待通知机制是Condition类,功能比wait/notify更强大
3.Semaphore
Semaphore指的是信息量,能够协调多个线程之间的资源分配。
信息量表示的是“可用资源的个数”,申请一个资源(P操作,acquire)时就会+1,释放一个资源(V操作,release)时就会-1。当计数器为0时,继续申请就会阻塞等待。
Semaphore semaphore = new Semaphore(需要的信号量);
Semaphore有一种特殊情况,当信号量为1时,取值要么是1要么是0,此时就相当于一把锁,我们也可以通过信号量的设置来限制最多有几个线程来执行任务。
4.CountDownLatch
使用多线程编程时,经常把一个大任务拆分成多个子任务,并发执行这些子任务,从而提高程序的效率。那我们要怎么衡量这些任务都完成了呢?这时就需要用到CountDownLatch。
1.基本使用
1.构造方法指定参数,描述一共有多少个任务
2.每个任务执行完毕之后,都调用countDown方法,当调用次数达到了设定的参数,则全部执行完
3.在主线程中调用await方法,等待所有任务执行完。
4.在多线程环境下使用集合类
我们在数据结构中学到的集合类大多都是线程不安全的
1.多线程下使用ArrayList
1.自行加锁(推荐)
自己分析清楚哪些部分需要加锁。
2.Collections synchronized(new ArrayList);
这个东西相当于套了一层壳,返回的所有List里的方法都是synchronized加锁的。
3.使用CopyOnWrite
这个是编程中常见的一种思想方法,写时拷贝。
假设我们有一个数组,现在有线程1对它进行修改,那么就会先复制一份这个数组,在复制的数组上进行修改,修改完之后在改变引用的指向。此时如果有其他线程来读取这个数组,我们能保证要么这个线程读到的时旧数组,或者是新数组,不会是修改到一半的数组。
缺陷
1.当这个数组非常大时,进行复制的开销会很大。
2.当有多个线程进行修改操作时,也会产生很大的问题。
这个方法使用于特定的场景,比如服务器重新加载配置的时候。
2.多线程下使用HashMap
HashMap是线程不安全的,虽然HashTable是线程安全的,但是它是给所有方法都加锁,效率不高。所以我们使用ConcurrentHashMap,它是按照桶级别进行加锁,不是加了一个全局锁,大幅降低了产生锁冲突的概率。
上图中的竖线标志对应的链表。
Concurrent的核心优化点
1.桶级别加锁
当有多个线程进行修改时,如果修改的是不同链表上的值,本身就不涉及线程安全问题。如果在同一个链表上修改才会产生线程安全问题。 在实际开发中,使用的哈希表可能是非常大的,那么桶也会有很多,大概率是不会产生线程安全问题的。
2.size使用原子类
修改不同链表的过程不会产生线程安全问题,但是它们一起修改哈希表的size时,就会有问题了,这个时候我们就可以使用原子类来设置size
3.分段扩容
当我们想对哈希表进行扩容时,一次把所有的数据搬运完会比较耗时间,这是就可以分段搬运数据,进行多次put/get。