本文主要是介绍线程知多少~(下篇),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
一. 常见的锁策略
首先声明,接下来的锁策略并不仅仅局限于java,任何与"锁"相关的话题,都可能会涉及到接下来的内容.
1.1 乐观锁&悲观锁
悲观锁 :总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。乐观锁:假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
一般情况下,实现悲观锁需要做更多的工作,乐观锁做的工作就比较少
举个例子来说明~
对于大学生来说,能进入公司内部实习是一个梦寐以求的机会.同学A认为,面试某讯公司的竞争者并不多,因此他做的准备比较少,这就是"乐观锁";同学B认为,肯定有很多人挤破头想去某讯当实习生,因此为了这次面试,他闭关修炼了大半年,这就是"悲观锁"的实现.
1.2 轻量级锁&重量级锁
- CPU 提供了 "原子操作指令".
- 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
- JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类.
重量级锁 : 加锁机制重度依赖了 OS 提供了 mutex.轻量级锁 : 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成 . 实在搞不定了 , 再使用 mutex.
重量级锁基于内核的一些功能来实现,会在内核中做很多事情,比如阻塞等待;轻量级锁只涉及到少量的内核态用户态切换,加锁成本比较低.
大部分情况下,悲观锁也是重量级锁,乐观锁则是轻量级锁.但一种是对阻塞情况的预估,一种是实际锁的开销,要注意区分.
1.3 自旋锁&挂起等待锁
自旋锁:如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来.
挂起等待锁:如果获取锁失败,就会阻塞等待,放弃CPU,过了很久之后,再次尝试获取锁
举个栗子来理解一下~
貂蝉小姐宣布和吕布复合了.她的追求者自成两派~
滑稽A老铁就相当于一个"自旋锁",他的伪代码是这样的.
while(lock==false){ }
自旋锁是"轻量级锁"的一种实现方式,是由用户态代码实现的.
好处就是,一旦锁被其他线程释放,它就能立刻获取到锁;但是如果其他线程一直占用锁,自旋锁就会持续消耗CPU资源.
挂起等待锁往往是由内核实现的.
如果没有获取到锁,挂起等待锁也不会一直抢占CPU;但是这个线程无法第一时间获取到锁.
1.4 公平锁&非公平锁
公平锁 : 遵守 " 先来后到 ". B 比 C 先来的 . 当 A 释放锁的之后 , B 就能先于 C 获取到锁 .非公平锁 : 不遵守 " 先来后到 ". B 和 C 都有可能获取到锁 .
举个栗子~
貂蝉小姐果然不孚众望地分手了~
下面是公平锁的做法.
如果是非公平锁...
1.5 可重入锁&不可重入锁
1.5.1 可重入VS不可重入
可重入锁,允许一个线程给一个对象进行多次加锁.
不可重入锁,一个对象只能被加一次锁.
下图为可重入锁~
下面是不可重入锁~
在理解这两者的概念之后,我们来详细谈一下"死锁"问题.
1.5.2 死锁的产生
死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放.
举个接地气的例子~滑稽老铁出门的时候,不小心把车钥匙落在了家里,然后悲催地发现自己家里的钥匙在车里...
死锁产生的必要条件如下:
- 互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
- 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
- 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
- 循环等待,即存在一个等待队列.p1等待p2,p2等待p3,p3等待p1...
死锁一般是以下三种情况:
1. 一个线程想要给一个不可重入锁加锁两次
lock (locker){//第一次加锁
lock(locker){//第二次加锁
...
}
}
上面的代码中,假设该线程第一次加锁成功,而该线程第二次尝试加锁时发现locker对象已经被加锁,就会阻塞等待锁释放,但是第一次加锁释放的条件是代码块已被执行完.因此就产生了死锁
2. 两个线程各自拥有一把锁,但是都想要对方手里的那把
比如,两位滑稽老铁都想吃西红柿鸡蛋面,一个只有西红柿,一个只有鸡蛋~就引发了"死锁"
3. n个线程有m把锁
最经典的例子莫过于哲学家就餐问题~
有五位哲学家,每天循环做两件事:思考,吃面。吃面时每人面前都有一个盘子,盘子左边和右边都有一根筷子,他们在吃面之前需要同时拿起两边的筷子,有了一双筷子就可以吃面了.很不幸,当五位哲学家同时想吃面条时,就会发生这样的事故...
他们谁也不肯把手里的筷子让给别人,于是只能阻塞等待...也就产生了"死锁".
1.5.3 解决"死锁"问题
死锁的解决方法有很多种,下面介绍一种最简单的解决办法.
给锁进行编号,确定加锁顺序(比如只能按照从小到大的编号进行加锁)
按照规则,右下角的那位哲学家只能先拿起编号为1的筷子,因此他左边的哲学家就可以拿起两只筷子吃饭了...
1.6 读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥. 如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生.
- 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
- 两个线程都要写一个数据, 有线程安全问题.
- 一个线程读另外一个线程写, 也有线程安全问题
- 读加锁和读加锁之间, 不互斥.
- 写加锁和写加锁之间, 互斥.
- 读加锁和写加锁之间, 互斥.
简单进行以下代码的演示~
public static void main(String[] args) {ReentrantReadWriteLock lock=new ReentrantReadWriteLock();ReentrantReadWriteLock.ReadLock readLock=lock.readLock();Thread thread=new Thread(()->{readLock.lock();//...执行代码逻辑readLock.unlock();});thread.start();}
读写锁特别适合于"频繁读,不频繁写"的场景中,但是有个缺点----必须手动释放,程序猿很可能会忘记解锁.
1.7 synchronized实现原理
1.7.1 synchronized的特性
- 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 实现轻量级锁的时候大概率用到的自旋锁策略
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
1.7.2 synchronized加锁过程
JVM将synchronized分成无锁,偏向锁,轻量级锁,重量级锁状态.会根据冲突情况依次升级,这个过程也被称为"锁膨胀".
1> 偏向锁,第一个尝试加锁的线程,会进入偏向锁状态.并不是真的加锁,而是做个标记
2> 轻量级锁,当另外一个线程也尝试对该对象加锁时,就会进入轻量级锁状态(一般是CAS实现的自旋锁)
就像电视剧上的狗血情节一样~
小红和小绿是青梅竹马,也相互喜欢.没有人追求小红之前,小绿从未官宣过小红是他的女友(此时处于偏向锁状态).小蓝对小红表白后,小绿立马发朋友圈"这是我女朋友!"(进入轻量级锁状态).
3> 重量级锁,更多的线程尝试给该对象加锁,锁竞争激烈,就会膨胀为重量级锁(挂起等待锁)
1.8 其他的锁优化
1.8.1 锁消除
在某些代码中,用到了synchronized,但是并没有涉及到多线程此时每个 append 的调用都会涉及加锁和解锁 . 但如果只是在单线程中执行这个代码 , 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销 .就会触发编译器的优化.
1.8.2 锁粗化
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
粒度粗的锁会减少加锁/解锁的开销
举个栗子~
老板安排了三个任务,你要一个一个地去汇报,还是三个做完了一起汇报?如果我是老板,我更喜欢第二种,因为你每次进来我都得准备给你泡茶...
二. CAS
2.1 什么是CAS
我们假设内存中的原数据 V ,旧的预期值 A ,需要修改的新值 B 。1. 比较 A 与 V 是否相等。(比较)2. 如果比较相等,将 B 写入 V 。(交换)3. 返回操作是否成功。
CAS伪代码:(下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解)
boolean CAS ( address , expectValue , swapValue ) {//address为待比较值(存放在内存中),exceptValue为期望的值(存放在寄存器中),swapValue为要更改的新值(存放在寄存器中)if ( address == expectedValue ) {address = swapValue ;return true ;}return false ;}
当多个线程同时对某个资源进行CAS操作,只有一个线程能操作成功并返回true,其他线程只能返回false.这为我们实现"无锁编程"提供了新思路.
CAS是由硬件提供了支持才得以实现,是一个原子指令
2.2 CAS的应用
1> 实现原子类
AtomicInteger atomicInteger = new AtomicInteger ( 0 ); //设置初始值为0atomicInteger . getAndIncrement (); // 相当于 i++
伪代码实现:
class AtomicInteger {
private int value ;//内存中的值public int getAndIncrement () {int oldValue = value ;//相当于一个寄存器,存储原始数据while ( CAS ( value , oldValue , oldValue + 1 ) != true ) {oldValue = value ;}return oldValue ;}}
来解释一下上面的代码,如果在单线程中,CAS操作是没有必要的,但是如果是多线程...
相对于synchronized来说,使用CAS操作不会引起线程阻塞等待问题,并且CAS是一条原子指令,是线程安全的.
2> 实现自旋锁
基于CAS操作的自旋锁伪代码
public class SpinLock {private Thread owner = null ;public void lock (){// 通过 CAS 看当前锁是否被某个线程持有 .// 如果这个锁已经被别的线程持有 , 那么就自旋等待 .// 如果这个锁没有被别的线程持有 , 那么就把 owner 设为当前尝试加锁的线程 .while ( ! CAS ( this . owner , null , Thread . currentThread ())){}}public void unlock (){this . owner = null ;}}
哪个线程调用lock方法,就会不停地循环,从而实现自旋锁
2.3 CAS的ABA问题
ABA 的问题:
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.接下来 , 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要先读取 num 的值 , 记录到 oldNum 变量中 . 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.但是 , 在 t1 执行这两个操作之间 , t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A到这一步,线程t1无法区分当前的变量始终是A,还是经历了yigebianhu
可能有的同学会问,这个ABA问题会有什么影响吗?
举个栗子~
假设你手中有100块钱~打算还给小丽同学50,在你转账的时候,不小心按了两下转账,创建了两个-50的线程;就在这时,小明同学将他欠你的50还了回来
我们期待的是,你使用的ATM机判断出来这是操作失误,只扣款50元
最终你的余额只剩了50,既然都谈到钱了,ABA问题是不是很严重!
那么该怎么解决ABA问题呢?
- CAS 操作在读取旧值的同时, 也要读取版本号.
- 真正修改的时候, 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
- 如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
于是你的转账流程就变成了这样~
三. JUC(java.util.concurrent) 的常见类
3.1 Callable接口
Callable 是一个 interface . 相当于把线程封装了一个 "返回值". 方便程序猿借助多线程的方式计算结果.
因为run方法没有返回值,就有了Callable的诞生~下面代码演示下它的使用
public static void main(String[] args) throws ExecutionException, InterruptedException {Callable<Integer> callable=new Callable<Integer>() {//定义Callable的内部逻辑,相当于run方法@Overridepublic Integer call() throws Exception {int sum=0;for(int i=1;i<50;i++){//计算1+2+...+49的和sum+=i;}return sum;}};FutureTask<Integer> futureTask=new FutureTask<>(callable);//用callable构造futureTask对象Thread thread=new Thread(futureTask);//用futureTask构造线程thread.start();//线程开始执行System.out.println(futureTask.get());//用futureTask接收结果}
怎么用一个通俗的例子解释FutureTask类的作用捏~
就好比你去食堂排队买麻辣烫,将食材挑好之后交给食堂阿姨(Callable对象构造完成),阿姨会给你一个小票(FutureTask对象构建完成),然后阿姨开始制作(线程开始执行),最后你凭着这张小票去取你的麻辣烫(用FutureTask对象接收结果)
如果我们只关心线程的执行,重写run方法即可.
如果我们需要知道线程执行的结果,需要借助Callable类.
3.2 原子类
- AtomicBoolean
- AtomicInteger
- AtomicIntegerArray
- AtomicLong
- AtomicReference
- AtomicStampedReference
以 AtomicInteger 举例,常见方法有
addAndGet(int delta); //i += deltadecrementAndGet(); //--igetAndDecrement(); //i--incrementAndGet(); //++igetAndIncrement(); //i++
3.3 ReentrantLock
可重入互斥锁,与synchronized类似,都是用来保证线程安全的
ReentrantLock 的用法:
- lock(): 加锁, 如果获取不到锁就死等.
- trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
- unlock(): 解锁
public static void main(String[] args) {ReentrantLock lock=new ReentrantLock();lock.lock();try{//代码逻辑执行 }finally {lock.unlock();}}
ReentrantLock与synchronized的区别:
1. synchronized使用时不需要手动释放锁,ReentrantLock需要手动释放
2. synchronized申请锁失败时会死等,ReentrantLock可以灵活调整等待时间
3. synchronized是JVM内部(大概率是C++)实现的,ReentrantLock是标准库的一个类,是基于Java实现的
4. ReentrantLock可以实现公平锁(构造方法传入true就可以创建一个公平锁)
3.4 信号量Semaphore
信号量, 用来表示 "可用资源的个数". 本质上就是一个计数器.
就好像小区里的停车位一样~当前车位有100个,就表示可用资源有一百个,每当一辆车进来,就要用掉一个(称为信号量的P操作);每当一辆车出去,就要释放一个(称为信号量的V操作)
Semaphore的PV操作是原子的,可以直接在多线程环境下使用.
代码示例
public static void main(String[] args) {Semaphore semaphore=new Semaphore(5);//初始设置的信号量为5for(int i=0;i<10;i++){Thread thread=new Thread(()->{//创建线程try {semaphore.acquire(2);//尝试获取2个信号量,不够时会发生阻塞等待System.out.println(Thread.currentThread().getName()+"获取信号量完成");Thread.sleep(1000);//休息1s后释放信号量semaphore.release();System.out.println(Thread.currentThread().getName()+"释放信号量完成");} catch (InterruptedException e) {e.printStackTrace();}});thread.start();}}
3.5 CountDownLatch
同时等待n个任务执行完成.
举个栗子~一场马拉松比赛,只有所有选手都跑到了终点,才算完成
代码示例
public static void main(String[] args) throws InterruptedException {CountDownLatch latch=new CountDownLatch(10);//一开始有十个运动员for(int i=0;i<10;i++){Thread thread=new Thread(()->{latch.countDown();try {Thread.sleep(1000);//每个运动员休息1s} catch (InterruptedException e) {e.printStackTrace();}});thread.start();}latch.await();//等待10个运动员全部到达}
有些同学会疑惑,这个类有什么用途嘛?
用处可大了,比如让人憎恶的"某度网盘",由于服务器那边的限制,下载速度贼慢,这时候就可以使用"多线程下载(ADM)",把一个文件拆成多份一个线程下载一部分,直到所有线程都下载完毕了才算完成.
四. 线程安全的集合类
4.1 多线程环境下使用ArrayList
1> 手动添加synchronized/ReentrantLock
2> 使用Cooletions.synchronizedList(new ArrayList)
该类的每一个关键操作都加了synchronized
3> 使用CopyOnWriteArrayList
CopyOnWrite 容器即写时复制的容器。写时复制,修改的时候会先创建一个副本,然后在副本上进行修改,修改完后让副本转正.这样修改的时候就不会对读操作造成影响.
- 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy, 复制出一个新的容器,然后新的容器里添加元素
- 添加完元素之后,再将原容器的引用指向新容器
4.2 多线程环境使用队列
- ArrayBlockingQueue 基于数组实现的阻塞队列
- LinkedBlockingQueue 基于链表实现的阻塞队列
- PriorityBlockingQueue 基于堆实现的带优先级的阻塞队列
- TransferQueue 最多只包含一个元素的阻塞队列
4.3 多线程环境使用哈希表
HashMap本身是线程不安全的
1> HashTable,在关键方法上加上了synchronized
这相当于直接对HashTable对象本身加锁
- 如果多线程访问同一个 Hashtable 就会直接造成锁冲突.
- size 属性也是通过 synchronized 来控制同步, 也是比较慢的.
- 一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低.
如上图,当一个线程修改第一个链表,另一个线程修改第三个链表时,并不会有线程安全问题(抛开size不谈)
2> ConcurrentHashMap
只给每个链表的对象头加锁
- 读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁.
- 加锁的方式仍然是用 synchronized, 但是不是锁整个对象, 而是 "锁桶" (用每个链表的头结点作为锁对象), 大大降低了锁冲突的概率.
- 充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况.
优化了扩容方式: 化整为零 .发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去. 扩容期间, 新老数组同时存在.
1. 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小部分元素. 搬完最后一个元素再把老数组删掉.
2. 这个期间, 插入只往新数组加.
3. 这个期间, 查找需要同时查新数组和老数组
这篇关于线程知多少~(下篇)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!