(一)锁策略
1.乐观锁,悲观锁
1)乐观锁:在进行加锁前,认为锁冲突不会特别激烈,所以对加锁的处理较少。
2)悲观锁:在进行加锁前,认为有很大概率会发生锁重涂,所以对加锁的处理比较多。
之前说了很多synchronized的问题,synchronized是自适应锁,在刚开始时是乐观锁,当锁冲突比较激烈时,就会转变为悲观锁
2.轻量级锁,重量级锁
1)轻量级锁:加锁机制尽可能不使⽤mutex,⽽是尽量在⽤⼾态代码完成.实在搞不定了,再使⽤mutex.,只涉及到少量的内核态用户态转换,并且不容易引发线程的调度
2)重量级锁:加锁机制重度依赖了OS提供了mutex,涉及到很多内核态和用户态的转换并且容易引发线程的调度
synchronized刚开始时是轻量级锁,在锁冲突严重时会转化成重量级锁
3.自旋锁,挂起等待锁
1)自旋锁:按之前所说,如果进行加锁失败,就会进入阻塞等待,而进入阻塞等待的线程虽然会放弃抢夺cpu资源(加锁),但这样如果被其他线程唤醒后,再一次进行加锁会很慢,甚至可能再次进入阻塞等待,所以我们利用自旋锁,他不放弃在cpu上的调度执行,即使加不上锁,也不会阻塞等待,等待锁一释放,就会加锁
2)挂起等待锁:即是,放弃cpu资源,进入阻塞等待,等待其他线程唤醒,再去竞争锁
说说自旋锁的优缺点:
优点:不放弃cpu资源,不进行阻塞等待,不用唤醒,在解锁后,可以直接加锁
缺点:因为不放弃cpu资源,所以会占用cpu的资源
synchronized轻量级锁的实现方式可能就是用自旋锁。
4.公平锁,非公平锁
先来了解一下这里说的公平是什么意思:”先来后到“
1)公平锁:满足先来后到的锁当a加锁后b先来,c后来,那么b就会在c前面加锁
2)非公平锁:不满足先来后到的锁,即随机调度,抢占式执行。
注意:个人认为如果想实现公平锁那就需要额外的数据结构,可以用阻塞队列来实现
synchronized是非公平锁,ReentrantLock可以实现公平锁,在后面我们会说到
5.可重入锁,不可重入锁
1)可重入锁:一个线程对一个对象在没释放的情况下,重复加锁不会出现死锁,内部有一个类似计数器一样的东西,记录了加锁几次,只有全部释放后,才是真正意义上的解锁
2)不可重入锁:一个线程对一个对象在没释放的情况下,重复加锁会导致死锁
6.普通互斥锁,读写锁
1)普通互斥锁:一个线程对这个对象加锁了后,另一个线程只能阻塞等待,是互斥的
2)读写锁:先说一下为什么需要读写锁,因为线程之间如果对一个变量只涉及到读不涉及到写,那么就不会引发线程不安全问题,那么如果都进行加锁,那就会影响我们的执行效率,所以读写锁就应运而生,读写锁把读操作和写操作分开对待
• ReentrantReadWriteLock.ReadLock 类表⽰⼀个读锁.这个对象提供了lock/unlock⽅法 进⾏加锁解锁.
• ReentrantReadWriteLock.WriteLock 类表⽰⼀个写锁.这个对象也提供了lock/unlock⽅法进⾏加锁解锁.
其中只有读加锁和读加锁不会互斥,其他只要涉及到写加锁,都会互斥,所以读写锁使用于读操作多的多线程中
注:synchronized不是读写锁
(二)CAS
CAS(compare and swap):本质上就是实现以下操作,有一个内存地址中的值,两个寄存器中的值(一个旧值,一个新值)在执行时,我们会比较旧值和内存地址中的值是否相等,如果相等那么我们就会把新值写入到内存地址中去。
这里我们写一下CAS的伪代码
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
因为是伪代码,只是为了方便理解看上去不是原子的,但实际上我们有操作系统给我们提供的api再通过封装成一个原子类提供给我们使用。
CAS可以视为是⼀种乐观锁.(或者可以理解成CAS是乐观锁的⼀种实现⽅式)
这里我们来了解一下原子类的一些方法:
AtomicInteger atomicInteger=new AtomicInteger(1);
atomicInteger.getAndIncrement();
这就相当于我们的i++,但是在之前我们说,普通的i++如果不进行加锁,会出现线程不安全问题,本质上是因为操作不是原子的,但是利用这个类和方法,就可以通过原子的方式进行操作,不用加锁也是线程安全的
伪代码实现
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
我们来通过几张图更详细的了解CAS是如何原子的解决线程不安全问题:
首先两个线程都会把value读取到oldValue中
线程1先执行,发现与value中的值一样,就将value加1后的新值写回
线程2后执行,发现自己与主内存中的值不一样,就会重新读取主内存值到oldValue中
一样了之后,线程2会把自己加1后的新值再写回到value中
通过上述步骤,我们就可以不需要加锁,即可完成多线程对同一个变量的修改
CAS的ABA问题
什么是ABA问题我们先了解一下,假设存在两个线程t1和t2.有⼀个共享变量num,初始值为A. 接下来,线程t1想使⽤CAS把num值改成B,那么就需要
• 先读取num的值,记录到oldNum变量中.
• 使⽤CAS判定当前num值是否为A,如果为A,就修改成B.但是,在t1执⾏这两个操作之间,t2线程可能把num的值从A改成了B,⼜从B改成了A
到这里我们就无法分辨中间是否有其他线程修改了变量
那目前看,ABA问题也没什么事,反正结果对了,那如果换一个场景呢?
假设你银行卡里有100,你从中取50元(但你点了两遍,创建了两个线程),这时正好你妈向你银行卡里转账50,这时就会发生问题
首先两个线程,线程1的旧值为100,期望更新为50,线程2旧值为100,期望更新为50,线程一拿到value后发现与旧值相同就会更新value为50,线程2拿到value为50后发现与旧值不相等就会阻塞,而这时银行卡转账了50就把value变为了100,这时相等了。就会把value变成50,进行重复扣款。
那我们该如何解决呢?
我们可以引入一个版本号,一旦value更新,版本号就加1,如果发生ABA问题,版本号就会告诉我们是否正常如 果发现当前版本号和之前读到的版本号⼀致,就真正执⾏修改操作,并让版本号⾃增;如果发现当前版 本号⽐之前读到的版本号⼤,就认为操作失败.
(三)synchronized的锁策略
上述我们在讲锁策略时有提到过synchronized是什么策略,那么现在我们来汇总一下
synchronized是自适应锁,可重入锁,不公平锁,普通互斥锁
加锁的一些优化:
1)锁升级:
我们都知道加锁需要消耗资源,所以能不加锁尽量就不加锁,synchronized这时就有了锁升级,在没有发生线程安全问题时即第一个线程刚加锁,synchronized是偏向锁阶段,这时候还没进行加锁,只是进行了一个标记,如果需要加锁,就会将synchronized升级成轻量级锁(此处的加锁是通过CAS来实现的),当线程间锁竞争比较激烈时,就会升级成重量级锁,并且一直保持重量级锁(此处用到内核提供的mutex
2)锁消除:在我们代码上加锁了后,如果没有线程安全问题。就是无锁阶段,那么此时jvm就会在确保万无一失的情况下,自动将我们的锁进行消除。
3)锁粗化:我们一个线程频繁进行加锁和解锁非常消耗资源,那我们就不如一直加锁,等到他所有业务处理完之后,再进行解锁。
(四)JUC的常见类
1.callable接口
在创建多线程时,我们会发现,之前的线程,都没有返回值,那如果我们想返回数据就需要使用这个callable接口和furthertask来实现
先看代码,我们再通过代码来展示如何返回:
Callable<Integer> callable=new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int a=10;
return a;
}
};
FutureTask<Integer> future=new FutureTask<>(callable);
Thread T1=new Thread(future);
T1.start();
int result=future.get();
System.out.println(result);
首先我们先用匿名内部类的方式实现callable接口,然后重写call方法,因为Thread的构造方法不支持callable实例,所以我们用FutureTask来包装一下,再将FutureTask实例传入Thread中执行,然后通过get方法拿到返回值即可
2.ReentrantLock类
我们在之前将公平锁非公平锁时,用synchronized与他进行比较,这里我们来说一下
首先他是可重入锁,是普通互斥锁,默认是非公平锁,可以成为公平锁
ReentrantLock的⽤法:
• lock():加锁,如果获取不到锁就死等.
• trylock(超时时间):加锁,如果获取不到锁,等待⼀定的时间之后就放弃加锁.
• unlock():解锁
比synchronized麻烦的时需要我们手动进行解锁操作,如果我们忘记解锁,就会出现死锁问题,所以我们通常在finally里进行解锁操作,来确保我们一定能够进行解锁
public static void main(String[] args) throws ExecutionException, InterruptedException {
ReentrantLock reentrantLock=new ReentrantLock();
reentrantLock.lock();
try {
}finally {
reentrantLock.unlock();
}
}
然后我们再来说一下与synchronized的区别:
1)synchronized是关键字,而ReentrantLock是一个类
2)synchronized是非公平锁不可以实现公平锁模式,ReentrantLock可以将参数修改变成公平锁
3)synchronized不需要我们手动释放锁,RenntrantLock必须要进行手动释放
4)synchronized如果对这个对象进行阻塞等待就会一直等,ReentrantLock可以通过try lock来处理
5)synchronized的唤醒是通过wait/notify来进行,而ReentrantLock是通过condition类来进行
所以,如果我们锁竞争不激烈,我们可以用synchronized来加锁,因为不需要我们进行手动释放,而如果竞争激烈,我们可以用ReentrantLock来处理,因为我们可以通过try lock来合理规划是否阻塞,当我们涉及到公平锁时也可以使用
3.semaphore
semaphore又叫信号量(本质上就是计数器),因为内部是通过原子类实现的,所以我们也可以通过这个进行加锁操作
我们先将信号量置为3,再通过acquire来获取不同次数的信号量
看这个代码,我们在acquire获取后,再进行释放,执行了三次
再看这个代码,我们只获取,并没有释放信号量,那么我们想执行第三次就需要阻塞等待前面的线程释放这个信号量
那如果将我们的信号量变成1,那我在线程中获取到这个信号量,执行完任务后再释放,那就与我们的加锁类似,在锁或者信号量被释放前,其他线程无法执行,只能阻塞等待,来保证我们的线程安全(也可以实现共享锁,即多个线程同时加锁)
4.CountDownLatch
等待多个线程全部结束
我们有时会遇到一个任务过大,如果全交给一个线程,那么执行的会很慢,所以我们会将任务拆分交给不同的线程,但是如果要执行之后的任务就需要等待这些线程全部都返回结果才可以,所以CountDownLatch就出现了,我们可以通过这个类来等待全部结果返回再执行之后的代码(与wait类似)
代码如下
public static void main(String[] args) throws ExecutionException, InterruptedException {
CountDownLatch countDownLatch=new CountDownLatch(5);
for (int i = 0; i < 5; i++) {
Thread T1=new Thread(()->{
countDownLatch.countDown();
System.out.println("start");
});
T1.start();
}
countDownLatch.await();
System.out.println("fin");
}
我们用先用构造方法传递一个数据告诉我们有十个任务,再用调用countDown来说明一个任务已经执行完了,再用await方法来等待所有任务执行完。
(五)线程安全的集合类
我们之前使用的数据结构大多数是线程不安全的
1.多线程环境下使用ArrayList
1)我们可以在关键的位置上手动加锁
2)我们可以用Collection中的synchronizedList方法,这个方法在关键位置进行了加锁操作
3)使用CopyOnWrite
• 当我们往⼀个容器添加元素的时候,不直接往当前容器添加,⽽是先将当前容器进⾏Copy,复制 出⼀个新的容器,然后新的容器⾥添加元素,
• 添加完元素之后,再将原容器的引⽤指向新的容器。
但是缺点是很消耗我们的内存资源,而且因为要先copy那么第一时间读取数据的速度就会慢
2.多线程下使用队列
我们可以使用阻塞队列
1. ArrayBlockingQueue基于数组实现的阻塞队列
2. LinkedBlockingQueue 基于链表实现的阻塞队列
3. PriorityBlockingQueue 基于堆实现的带优先级的阻塞队列
4. TransferQueue最多只包含⼀个元素的阻塞队列
3.多线程下使用哈西表
如果我们使用hashmap那么是线程不安全的,我们可以使用hashtable和concurrenthashmap
我们来讲一下三者的区别:
首先,hashmap是线程不安全的,其他两个线程安全
1)hashtable就是在一些关键的方法上用synchronized进行加锁处理,是对一整个顺序表进行加锁,所以即使对不在一个链表上的数据进行加锁,不涉及到线程安全问题也会阻塞等待。
2)hashtable如果到达了阈值涉及到扩容操作,是整个hashtable copy过去,涉及到大量的拷贝和重新hash操作,效率很低
再来说ConcurrentHashMap
1)读操作没有加锁(但是使⽤了volatile保证从内存读取结果),只对写操作进⾏加锁.加锁的⽅式仍然 是是⽤synchronized,但是不是锁整个对象,⽽是"锁桶"(⽤每个链表的头结点作为锁对象),⼤⼤降 低了锁冲突的概率.
2)充分利用了CAS进行加锁,避免加锁操作过于麻烦消耗资源
3)哈希表需要进行扩容操作时,我们是先创建一个新哈希表同时将少量元素copy到新哈希表中,将之后添加的元素放到新哈希表中,之后每次调用哈希表,我们都会再copy几个数据到新表,当我们旧表的元素都copy完之后,我们再删除旧表(在这期间,查询操作需要查找两个哈希表)
那么综上,我们的锁策略就先讲到这,用了三篇博客来讲述我学习多线程的知识