多线程进阶目录
1.锁策略
1.1 悲观锁 vs 乐观锁
- 悲观锁
悲观锁总假设最坏的情况,它认为被多线程共享的资源在被每次访问时有极大可能性会被其他线程进行更改,所以在每次拿数据时都会加锁,其他线程想要拿到该共享数据,就必须阻塞直到获得这把锁 - 乐观锁
乐观锁会假设共享数据在一般情况下不会发生并发冲突,在数据提交更新时,才会正式对数据进行是否产生并发冲突的检查,如果发现并发冲突了,就返回给用户错误信息,让用户决定如何去做 - 特点概括:
悲观锁对于锁冲突的预期概率很高,因此它做的工作和付出的成本更多,但效率更低
乐观锁对于锁冲突的与其概率很低,因此它做的工作和付出的成本相对更少,效率更高
1.2 读写锁 vs 互斥锁
- 读写锁
读写锁将线程对共享数据的读操作和写操作分开对待,读锁和读锁之间不互斥;读锁和写锁以及写锁和写锁之间互斥,通过这种方法提高并发环境下的程序运行效率 - 互斥锁
对于互斥锁而言,在并发环境下,不论对共享数据进行读操作还是写操作都会进行加锁动作,这对数据的访问结束后释放锁。 - 特点概括:
读写锁对并发资源的读加读锁,读锁之间不互斥;对并发资源的修改加写锁,写锁与读锁以及写锁和写锁之间互斥。
互斥锁对并发资源的读和写操作都进行加锁
1.3 重量级锁 vs 轻量级锁
- 重量级锁
当一个锁中调用了操作系统提供的mutex接口,需要一些基于内核的操作才能够实现功能的时候,通常认为这是一把重量级锁,例如线程的阻塞等待 - 轻量级锁
如果锁的实现是纯用户态的或者很少依赖内核态,通常认为这是一把轻量级锁 - 特点概括:
重量级锁做的事情更多,开销更大,一般是依赖内核态实现的
轻量级锁做的事情相对较少,开销也小,一般是依赖用户态实现的
1.4 挂起等待锁 vs 自旋锁
- 挂起等待锁
挂起等待锁,往往是通过内核的一些机制来完成的,是重量级锁的一种典型体现 - 自旋锁
往往是通过用户态代码来实现的,是轻量级锁的一种典型体现。如果获取到锁失败,它会立即尝试再次获取锁,知道获取到锁为止。 - 特点概括:
挂起等待锁依赖内核功能实现,锁的释放和获取间隔的时间比较大;是重量级锁的一种典型体现
自旋锁一般是基于用户态实现,锁的释放和获取的间隔时间很小;是轻量级锁的一种重要体现。
1.5 公平锁 vs 非公平锁
- 公平锁
按照线程等待的先后顺序来获取竞争的这把锁。遵循先来后到的规则 - 非公平锁
等待锁的所有线程获取该锁的概率是不能确定的,取决于CPU的调度。
1.6 可重入锁 vs 不可重入锁
- 可重入锁
可重入锁,即可以重新进入的锁,指一个线程可以多次获取同一把锁而不会产生死锁 - 不可重入锁
同一个线程不能多次获得同一把锁,否则就会产生死锁。这样的锁称为不可重入锁,比如Java中的ReentrantLock
2. Java中的synchronized锁
2.1 synchronized的归类
- synchronized锁既可以被当作乐观锁,也可以被当做悲观锁,它会根据锁的激烈程度进行自适应
- 既是重量级锁,也是轻量级锁,也是根据实际情况自适应;轻量级锁的部分会基于自旋锁来实现,重量级锁的部分会基于挂起等待锁来实现
- 是普通的互斥锁,也是非公平锁和可重入锁
2.2 synchronized的特点(JDK8)
结合上边锁的策略,我们可以总结出synchronized具有以下特性:
- 程序刚开始运行时,synchronized是乐观锁,当锁冲突变得严重时,synchronized变为悲观锁
- 刚开始时synchronized时轻量级锁,但是如果锁的持有时间变长,就转变为重量级锁
2.3 synchronized的工作过程
JVM将synchronized锁分为无锁->偏向锁->轻量级锁->重量级锁的状态,会根据实际情况对锁的类型进行调整,下边我们来看一下它的调整过程:
- 当没有线程争夺这把锁时,处理无锁状态
- 第一个尝试加锁的线程,会优先使该锁进入偏向锁状态
- 随着其他线程的加入,锁的竞争开始变得激烈,转换为轻量级锁状态,此处的轻量级锁就是通过CAS来实现的
- 锁的竞争进一步加剧,通过自旋不能很快获取到锁资源,该锁就会发生膨胀,变为重量级锁
2.4 锁的优化策略
锁消除
如果加锁的资源并没有处在多线程的环境下,JVM和编译器通过判断,就会直接消除这些锁,减少不必要的资源开销。例如,我们在单线程环境下使用StringBuffer类进行多次append,这时候可能就会出现锁消除,提高我们程序的运行效率.
锁粗化
一段程序、逻辑中出现多次加锁解锁,编译器可能会对这个锁进行粗化优化,例如在一个循环内使用synchronized多次加锁解锁,这可能使不必要的,此时锁就可能会发生粗化优化:
class Main { private int val; public void add() { for(int i=0;i<10;i++) { synchronized(this) { this.val++; } } } ... } //锁粗化 class Main { private int val; public void add() { synchronized(this){ for(int i=0;i<10;i++) { this.val++; } } } ... }
3. CAS的实现和应用
3.1 CAS实现
CAS全称Compare and Swap。CAS是一条完整的硬件指令完成的,因此它是线程安全的。一个CAS包含以下操作:
假设内存中有原数据old,预期待修改值oldExpect和修改值new :
- 将old和oldExpect进行比较
- 如果比较结果相同,则将oldExcept的值修改为new,并将new值和内存中的old值进行交换,否则返回
- 返回操作结果
下面我们参照一组伪代码来帮助我们更好的理解:
boolean CAS(address, expectValue, swapValue) { if (&address == expectedValue) { //取内存中的值与预期值进行比较 &address = swapValue; //如果比较结果相同的话,则将新值与内存中的值进行交换,也可以理解为赋值 return true; //返回操作成功 } return false; //返回操作失败 }
3.2 CAS应用
CAS应用 1——实现原子类
Java中的原子类存在于java.util.concurrent.atomic包中,我们使用接下来的伪代码来帮助我们理解下原子类的实现原理:class AtomicInteger { private int value; //保存的是内存中的旧值,或者说是原始的数据 public int getAndIncrement() { int oldValue = value; //将内存中的值保存在oldValue中,这个oldValue可能是寄存器,这里不好表示 //执行CAS指令 //1.判断内存值与寄存器中oldValue的值是否一致 //2.如果相同,就进行寄存器中oldValue的值修改为oldValue+1,并将寄存器中的值和内存中的值进行交换,返回true //3.如果不相等,返回false,继续循环读取内存中的value值与寄存器中的当前值比较。 //4.重复上述操作,直到返回结果 while ( CAS(value, oldValue, oldValue+1) != true) { oldValue = value; } return oldValue; } }
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; } }
3.3 CAS中的ABA问题
CAS的 ABA问题是指:
线程T1在进行ABA操作过程中的加载——>比较的这段时间内,另一个线程T2对T1要进行CAS时从内存中读取的用来比较的值进行了修改,使得T1线程在进行CAS操作时残生了误判的效果。这样讲是不是有点抽象,我们来结合一个栗子来帮助我们更好的理解:
- 用户A前往银行ATM取钱,假设他的账户上的yu余额为200,他要取100出老
- 我们的用户A老哥按下取款键,但是机器程序由于某些原因卡了一下,于是我们的老哥A又按了一下取款键
- 这个时候机器中应该是对应两条CAS指定来执行扣款操作的
- 按理来说,第二条CAS指令在进行比较时发现寄存器中的值与内存中的值不同,就不进行修改了
- 但是就在执行完第一条CAS指令到开始执行第二条CAS指令的时候,我们的用户B老哥向用户A老哥转账了100并成功了!!!
- 这个时候第二条CAS指令在执行时由于比较寄存器中的值和内存中的值相同,救护出现误判。并成功执行。
- 这个时候,我们的用户老哥A发现自己只按了一下取款键,却扣了两次款,已陷入懵逼状态…
综合上面的例子,我们可以得出下面这张图,即ABA问题出现是由于存在其他线程,将执行ABA指令线程进行比较的值进行了修改但是没有发生改变。使得执行CAS指令的线程出现误判而错误执行的问题:
🤔解决方案:
给要修改的数据引入版本号,对内存中数据发生修改操作时都按指定规则修改版本号,在CAS比较当前值和旧值是否相同时也比较版本号是否相同,如果版本号一直,则执行剩余的操作,如果版本号不一致,说明在加载到比较的这段时间内数据的指发生了改变,为了程序的安全这个时候就认为操作失败,直接返回。
4. Callable接口
Callable接口也是创建线程的一种方式,与通过Thread类和Runnable接口创建线程相比,Callable接口的线程可以待会线程执行的返回结果,Callable接口实现的线程相当于把线程封装了一个返回值,供程序猿使用多线程计算使用。
Callable接口创建和启动线程的步骤:
- 编写类实现Callable接口并指定泛型类型
- 重写接口中的call方法
- 创建FutureTask类,对Callable实例进行包装
- 创建Thread类,将包装后的Callable实例传入
- 调用Thread类的start方法,启动线程
- 调用FutureTask类的get方法,获取新启动线程的执行结果。该方法会一直阻塞直到新线程执行结束。
下面我们使用Callable版本实现的线程计算1+2+…+100的值并返回,练习这个版本创建和启动线程:
import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; public class CallableTestDrive { public static void main(String[] args) throws ExecutionException, InterruptedException { //编写类实现Callable,覆写其中的带有返回值的call方法 Callable<Integer> callable = new Callable<Integer>() { @Override public Integer call() throws Exception { int sum = 0; for (int i = 1; i <= 100; i++) { sum += i; } return sum; } }; //创建FutureTask对Callable的实例进行包装 FutureTask<Integer> futureTask = new FutureTask<>(callable); //创建Thread线程类,闯入包装Callable实例的futureTask,调用start方法启动线程 new Thread(futureTask).start(); //根据对应的futureTask,获得线程的执行结果 Integer res = futureTask.get(); System.out.println(res); } }
5. JUC的常见类
所谓JUC,即指的是java.util.concurrent包下的类,这些类都具有在并发环境下稳定运行的效果。
1.ReentrantLock类
ReentrantLock也是Java中的一种锁类型,它的定位与synchronized类似,都是可重入互斥锁。
使用:
方法 | 解释 |
---|---|
lock() | 执行加锁操作,获取不到锁就死等 |
tryLock(long timeout,TImeUnit unit) | 执行加锁操作,如果在指定时间内没有获得到锁,就放弃加锁 |
unlock() | 执行加锁后的解锁操作 |
2.原子类
原子类的内部使用CAS实现的,所以性能要比加锁实现同等运算的效率高不少,java.util.concurrent.atomic下的原子类有以下几个:
- AtomicBoolean
- AtomicInteger
- AtomicIntegerArray
- AtomicLong
- AtomicReference
- AtomicStampdReference
下面以AtomicInteger为例,学习其中的一些方法:
方法 | 说明 |
---|---|
public final int addAngGet(int delta) | 相当于 i += delta 操作 |
public final int getAndIncrement() | 相当于 i++ |
public final int incrementAndGet() | 相当于 ++i |
public final int getAndDecrement() | 相当于 i - - |
public final int drcrementAndGet() | 相当于 - - i |
3.线程池
使用Excutors工厂类创建的线程池
- Excutors是一个工厂类,能够创建出许多种不同风格的线程池:
- ExcutorService 是工厂类创建出的线程池实例对象类型
- ExcutorService 的 submit方法能够向线程池中提交任务
通过Excutors工厂类创建的集中线程池类型如下:
方法 | 说明 |
---|---|
public static ExecutorService newFixedThreadPool(int nThreads) | 创建固定容量的线程池 |
public static ExecutorService newCachedThreadPool() | 创建线程数量动态增长的线程池 |
public static ExecutorService newSingleThreadExecutor() | 创建包含单个线程的线程池 |
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) | 设置延迟时间后执行命令,获得预期执行命令,是进阶版的Timer |
使用ThreadPoolExcutor创建的线程池
ThreadPoolExcutor提供了更多的可选参数,能进一步细化线程池的设定,跟个hi和当作定制版的线程池。
4.信号量Semaphore
本质上就是一个计数器。可以把这个信号量类Semaphore的实例对象当作停车场的剩余车位展示牌,当有车开进停车场时,就相当于调用了信号量对象的acquire申请了一个资源;当有车开出时,就相当于调用信号量对象的release方法释放了一个资源;当停车场的剩余位置为0时(信号量为0时),剩余的车辆如果要想进入就要阻塞排队。下面创建一个Semaphore实例对象练习下使用:
import java.util.concurrent.Semaphore; public class SemaphoreTestDrive { public static void main(String[] args) throws InterruptedException { Semaphore semaphore = new Semaphore(5); //创建一个包含五个信号量的信号量对象 //申请信号量 for (int i = 0; i < 5; i++) { semaphore.acquire(); } new Thread(()->{ //我们两秒后通过其他线程释放一个信号量,会发现程序恢复了阻塞状态 try { Thread.sleep(2000); } catch (InterruptedException e) { throw new RuntimeException(e); } semaphore.release(); }).start(); //再次申请时,会阻塞 semaphore.acquire(); } }
5.CountDownLatch
构造一个初始化任务数为10的CountDownLatch对象,在主线程中使用latch.await();一直阻塞到10个任务执行结束后,才继续向下执行。这样说可能优点抽象,我们直接上程序:import java.util.concurrent.CountDownLatch; public class CountDownLatchTestDrive { public static void main(String[] args) throws InterruptedException { CountDownLatch latch = new CountDownLatch(10); Runnable runnable = new Runnable() { @Override public void run() { try { Thread.sleep((int) (Math.random() * 2000)); latch.countDown(); //减少latch对象中的任务数 System.out.println(Thread.currentThread().getName() + " ——执行结束"); } catch (InterruptedException e) { throw new RuntimeException(e); } } }; for (int i = 0; i < 10; i++) { new Thread(runnable).start(); } latch.await(); //所有的线程执行结束后才恢复阻塞状态 System.out.println(Thread.currentThread().getName() + " ——执行结束"); } }
问题总结
1.怎么理解悲观锁和乐观锁,具体怎么实现?
悲观锁认为多个线程访问同一个共享变量的发生锁冲突概率大,会在每次访问共享变量之前都会真正加锁。
乐观锁认为多个线程访问同一个共享变量发生锁冲突的概率不大,并不会真正加锁,而是直接尝试访问数据。
悲观锁的实现就是先加锁(比如借助操作系统提供的metus接口)。获取到锁后再操作数据,获取不到所就会一直等待。
乐观锁的实现可以引入一个版本号,借助版本号来识别当前的数据访问是否冲突。
2.怎么理解读写锁?
读写锁就是把读操作和写操作分开,分别进行加锁。其中,读锁和读锁之间不互斥;读锁和写锁以及写锁和写锁之间互斥。提高了多线程环境下程序的运行效率。
3.什么是自旋锁策略,优/缺点是什么?
自旋锁策略是指如果获取锁失败,立即尝试再获取锁,无限循环知道获取到锁为止。加入第一次获取锁失败,那么第二次的获取将会在极短的时间内来到。
优点:更高效。由于它没有释放CPU的资源,一旦锁被释放就能第一时间获取到锁,在线程持有锁时间较短的情况下优势明显。
缺点:如果线程持有锁的时间较长,那么比较耗费CPU的资源
4.对Java中synchronized锁的理解?
Java中的synchronized锁分为无锁,偏向锁,轻量级锁和重量级锁状态,JVM会根据程序的运行状态对锁的状态进行调整。
在程序运行开始到还没有线程获取到这把锁时,处于无锁状态;当第一个线程尝试加锁时,会优先进入偏向锁状态;随着其他线程的进入,逐渐转化为轻量级锁状态;最后,随着竞争的进一步加剧,进入到重量级锁状态。
5.如何理解CAS机制以及ABA问题?解决策略
CAS即Compare And Swap,对应操作系统中的一条指令,是一个原子性的操作,因此也是线程安全的。CAS的执行分为读取内存,比较相等,修改内存这三个步骤。
ABA问题是指当一条CAS指令在执行时,由于其他的线程对内存中的数据进行了修改但是并没有改变原值,使得CAS在比较相等阶段出现误判而导致错误执行。
解决策略就是在数据中引入在共享数据被修改时按照一定规则改变的版本号,在CAS的比较相等阶段也进行版本号的比较,如果想通了,才真正的取交换修改数据;否则直接返回
6. 什么是偏向锁机制?
所谓偏向锁,是指不是真正的进行加锁。而是在锁的对象头中生成一个标记来记录当前锁所属的线程,如果没有其他线程草鱼锁竞争就不会真正的进行加锁,从而降低开销,提高程序的运行效率,当其他线程竞争该锁时,才取消偏向锁状态,进入到轻量级锁状态。
7.介绍一个Callable是什么?
与Runnable接口类似,Callable是Java提供的一个创建线程的接口,不同的是,通过Callable接口创建的线程能够返回线程的运行结果。Callable通常要搭配FutureTask类使用,通过FutureTask来保存当前线程的运行结果。
8. 线程的同步方式有哪些?
可以通过synchronized、ReentrantLock以及Semapjore实现线程同步。
9.synchronized和ReentrantLock的区别?
synchronized使用时自动释放锁,而ReentrantLock需要调用unLock方法手动释放锁,使用起来更加灵活。
synchronized尝试获取锁失败时,会死等该锁。ReentrantLock通过tryLock方法指定一段时间可以用来表示如果在该时间段内获取不到锁就放弃加锁,使用也更加灵活。
通过synchronized实现的锁时非公平锁,即当多个线程同时竞争一把锁时,哪个线程能先获取到这把锁是不能确定的,这取决与CPU的调度。而ReentrantLock实现的锁可以是非公平锁也可以是公平锁,其中公平锁的实现可以通过构造方法传入参数来指定
synchrinized是通过Object的睡眠和唤醒机制来管理线程的,而ReentrantLock是通过搭配Condition类来实现线程的等待和唤醒的,功能更加强大。
10.对ConcurrentHashMap的理解?
ConcurrentHashMap的读操作没有加锁,降低了锁冲突的概率,同时搭配了volatile关键字保证了变量的内存可见性确保每次读到的都是修改后的数据。
ConcurrentHashMap在JDK8进行了优化,取消了分段锁,而是直接给每个哈希桶分配了一把锁。同时将原来的数组加链表的实现方式该进成数据+链表和红黑树的存储方式,在链表长度较大时就转换为红黑树存储,进一步提高了哈希表的索引效率。
11.HashTable、HashMap以及ConcurrentHashMap之间的区别?
- HashMap是线程不安全的,同时key值允许为null
- HashTable是线程安全的,但是由于采用无脑加锁的方式,效率比较低,同时key值不允许为null
- ConcurrentHashMap也是线程安全的,使用synchronized对每个链表的头结点进行加锁,降低了锁冲突的概率,优化了扩容方式,key值也不允许为空.
12.对volatile关键字的理解?
volatile关键字能够保证内存可见性,会使程序强制每次都从主内存中读取数据,可以很好的解决变量的内存可见性问题
13.Java多线程是怎么实现数据共享的?
首先,JVM将内存划分成了方法区,堆区,栈区和程序计数器。其中堆区的内存区域是被多个线程共享的,只要将数据放在堆区中,就能够被多线程访问到。
14.Java创建线程池的接口是什么?
Java创建线程池主要有以下两种方式:
通过Excutor工厂类创建以及通过ThreadPoolExcutor创建。通过Excutor工厂类创建的线程池比较简单,定制能力有限。如果要想使用更具有定制色彩的线程池,就使用ThreadPoolExcutor。
15.Java线程有哪几种状态,状态之间是怎么进行切换的?
Java线程之间共有六种状态,分别为NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED.
其中,NEW状态表示线程已经创建但是还没有启动的状态,通过调用Thread类的start方法可以将这个状态的线程变为RUNNABLE状态,RUNNABLE有分为READY和RUNNING状态,具体是这两个状态中的哪种,这取决于CPU的调度;当线程中的锁被其他线程占用还未释放时,该锁对应的其他线程就会有RUNNABLE状态进入BLOCKED阻塞状态;通过调用线程的wait方法可以使该线程进入WAITING状态;通过调用sleep或者带参数的wait方法可以使该线程进入TIMED_WAITING状态;当县城运行结束时,就转换为TERMINATED状态。
16.在一个类中被synchronized修饰的两个方法,两个线程同时执行会发生什么?
这要看这两个方法是不是在同一个实例对象的。如果是的话,两个线程会同步执行,否则并发执行,互不影响