Java面试集锦(一)之Java多线程
- 面试宝典
- 时间:2020-08-05 09:11
- 6306人已阅读
🔔🔔🔔好消息!好消息!🔔🔔🔔
有需要的朋友👉:联系凯哥
Java多线程
1. 多线程
新建状态: 一个新产生的线程从新状态开始了它的生命周期。它保持这个状态直到程序 start 这个线程。
运行状态:当一个新状态的线程被 start 以后,线程就变成可运行状态,一个线程在此状态下被认为是开始执行其任务
就绪状态:当一个线程等待另外一个线程执行一个任务的时候,该线程就进入就绪状态。当另一个线程给就绪状态的线程发送信号时,该线程才重新切换到运行状态。
休眠状态: 由于一个线程的时间片用完了,该线程从运行状态进入休眠状态。当时间间隔到期或者等待的时间发生了,该状态的线程切换到运行状态。
终止状态: 一个运行状态的线程完成任务或者其他终止条件发生,该线程就切换到终止状态。
2. 僵死进程
计算机的计算模型大部分是基于空间和时间来考虑的。僵死进程唯一占用的空间是pid空间,这个空间如果不能合理的应用就会造成浪费,之所以保留这个空间,是为了让父进程感知子进程已经终止这个行为。时间方面,这个感知过程是一个异步的过程。
3. 创建线程的方式
继承 Thread 类
实现 Runnable 接口
使用 Executor 框架
法一:继承Thread类
1.1定义一个类继承Thread
1.2重写run方法
1.3创建对象
1.4调用start方法开启线程
线程对象调用run()方法和start()方法区别?
调用run方法不开启线程,仅是对象调用方法。
调用start方法开启线程,并让jvm调用run方法在开启的线程中执行。
run()方法用来执行线程体中具体的内容
start()方法用来启动线程对象,使其进入就绪状态
法二:实现Runnable接口
2.1定义一个类实现Runnable接口。
2.2覆盖run()方法。
2.3创建子类对象。
2.4创建Thread类对象,将实现Runnable接口的子类对象作为参数传递给Thread类对象的构造函数。
2.5调用start方法开启线程。
Runnable优点:避免了继承Thread类的单继承局限性,更加符合面向对象
3. 线程安全类
何为线程安全的类
在线程安全性的定义中,最核心的概念就是 正确性。当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么这个类就是线程安全的。
线程安全类
在集合框架中,有些类是线程安全的,这些都是jdk1.1中的出现的。在jdk1.2之后,就出现许许多多非线程安全的类。 下面是这些线程安全的同步的类:
vector:就比arraylist多了个同步化机制(线程安全),因为效率较低,现在已经不太建议使用。在web应用中,特别是前台页面,往往效率(页面响应速度)是优先考虑的。
statck:堆栈类,先进后出
hashtable:就比hashmap多了个线程安全
enumeration:枚举,相当于迭代器
除了这些之外,其他的都是非线程安全的类和接口。
线程安全的类其方法是同步的,每次只能一个访问。是重量级对象,效率较低。
4. 如何确保线程安全
在Java中可以有很多方法来保证线程安全,诸如:
通过加锁(Lock/Synchronized)保证对临界资源的同步互斥访问;
使用volatile关键字,轻量级同步机制,但不保证原子性;
使用不变类 和 线程安全类(原子类,并发容器,同步容器等)。
5. 什么是死锁
两个线程或两个以上线程都在等待对方执行完毕才能继续往下执行的时候就发生了死锁。结果就是这些线程都陷入了无限的等待中
6. wait()与 sleep()的区别
sleep()来自 Thread 类,wait()来自 Object 类;
调用 sleep()方法,线程不会释放对象锁。而调用 wait 方法线程会释放对象锁;
sleep()睡眠后不出让系统资源,wait 让其他线程可以占用 CPU;
sleep(milliseconds)需要指定一个睡眠时间,时间一到会自动唤醒。而 wait()需要配合 notify()
或者 notifyAll()使用。
7. 为什么wait(), notify()和notifyAll()必须在同步方法或者同步块中被调用
wait/notify机制是依赖于Java中Synchronized同步机制的,其目的在于确保等待线程从Wait()返回时能够感知通知线程对共享变量所作出的修改。如果不在同步范围内使用,就会抛出java.lang.IllegalMonitorStateException的异常。
8. 什么是 ThreadLocal?ThreadLocal 和 Synchonized 的区别?
线程局部变量。是局限于线程内部的变量,属于线程自身所有,不在多个线程间共享。Java提供 ThreadLocal 类来支持线程局部变量,是一种实现线程安全的方式。
synchronized 是利用锁的机制,使变量或代码块在某一时该只能被一个线程访问。而 ThreadLocal 为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。
9. ThreadLocal中的内存泄露问题(OOM):
如果ThreadLocal被设置为null后,并且没有任何强引用指向它,根据垃圾回收的可达性分析算法,ThreadLocal将被回收。这样的话,ThreadLocalMap中就会含有key为null的Entry,而且ThreadLocalMap是在Thread中的,只要线程迟迟不结束,这些无法访问到的value就会形成内存泄露。为了解决这个问题,ThreadLocalMap中的getEntry()、set()和remove()函数都会清理key为null的Entry,以下面的getEntry()函数为例。
要注意的是ThreadLocalMap的key是一个弱引用。在这里我们分析一下强引用key和弱引用key的差别
强引用key:ThreadLocal被设置为null,由于ThreadLocalMap持有ThreadLocal的强引用,如果不手动删除,那么ThreadLocal将不会回收,产生内存泄漏。
弱引用key:ThreadLocal被设置为null,由于ThreadLocalMap持有ThreadLocal的弱引用,即便不手动删除,ThreadLocal仍会被回收,ThreadLocalMap在之后调用set()、getEntry()和remove()函数时会清除所有key为null的Entry。
ThreadLocalMap仅仅含有这些被动措施来补救内存泄露问题,如果在之后没有调用ThreadLocalMap的set()、getEntry()和remove()函数的话,那么仍然会存在内存泄漏问题。在使用线程池的情况下,如果不及时进行清理,内存泄漏问题事小,甚至还会产生程序逻辑上的问题。所以,为了安全地使用ThreadLocal,必须要像每次使用完锁就解锁一样,在每次使用完ThreadLocal后都要调用remove()来清理无用的Entry。
10. 多线程常见问题
上下文切换
多线程并不一定是要在多核处理器才支持的,就算是单核也是可以支持多线程的。 CPU 通过给每个线程分配一定的时间片,由于时间非常短通常是几十毫秒,所以 CPU 可以不停的切换线程执行任务从而达到了多线程的效果。
但是由于在线程切换的时候需要保存本次执行的信息,在该线程被 CPU 剥夺时间片后又再次运行恢复上次所保存的信息的过程就称为上下文切换。
上下文切换是非常耗效率的。
通常有以下解决方案:
采用无锁编程,比如将数据按照 Hash(id) 进行取模分段,每个线程处理各自分段的数据,从而避免使用锁。
采用 CAS(compare and swap) 算法,如 Atomic 包就是采用 CAS 算法。
合理的创建线程,避免创建了一些线程但其中大部分都是处于 waiting 状态,因为每当从 waiting 状态切换到 running 状态都是一次上下文切换。
死锁
死锁的场景一般是:线程 A 和线程 B 都在互相等待对方释放锁,或者是其中某个线程在释放锁的时候出现异常如死循环之类的。这时就会导致系统不可用。
常用的解决方案如下:
尽量一个线程只获取一个锁。
一个线程只占用一个资源。
尝试使用定时锁,至少能保证锁最终会被释放。
资源限制
当在带宽有限的情况下一个线程下载某个资源需要 1M/S,当开 10 个线程时速度并不会乘 10 倍,反而还会增加时间,毕竟上下文切换比较耗时。如果是受限于资源的话可以采用集群来处理任务,不同的机器来处理不同的数据,就类似于开始提到的无锁编程
11. synchronized 关键字原理
1)synchronized 关键字是解决并发问题常用解决方案,有以下三种使用方式:
同步普通方法,锁的是当前对象。
同步静态方法,锁的是当前 Class 对象。
同步块,锁的是 () 中的对象。
实现原理: JVM 是通过进入、退出对象监视器( Monitor )来实现对方法、同步块的同步的
2)锁优化
synchronized 很多都称之为重量锁,JDK1.6 中对 synchronized 进行了各种优化,为了能减少获取和释放锁带来的消耗引入了偏向锁和轻量锁。
轻量锁
当代码进入同步块时,如果同步对象为无锁状态时,当前线程会在栈帧中创建一个锁记录(Lock Record)区域,同时将锁对象的对象头中 Mark Word 拷贝到锁记录中,再尝试使用 CAS 将 Mark Word 更新为指向锁记录的指针。
如果更新成功,当前线程就获得了锁。
如果更新失败 JVM 会先检查锁对象的 Mark Word 是否指向当前线程的锁记录。
如果是则说明当前线程拥有锁对象的锁,可以直接进入同步块。
不是则说明有其他线程抢占了锁,如果存在多个线程同时竞争一把锁,轻量锁就会膨胀为重量锁。
解锁
轻量锁的解锁过程也是利用 CAS 来实现的,会尝试锁记录替换回锁对象的 Mark Word 。如果替换成功则说明整个同步操作完成,失败则说明有其他线程尝试获取锁,这时就会唤醒被挂起的线程(此时已经膨胀为重量锁)
轻量锁能提升性能的原因是:
认为大多数锁在整个同步周期都不存在竞争,所以使用 CAS 比使用互斥开销更少。但如果锁竞争激烈,轻量锁就不但有互斥的开销,还有 CAS 的开销,甚至比重量锁更慢。
偏向锁
为了进一步的降低获取锁的代价,JDK1.6 之后还引入了偏向锁。
偏向锁的特征是:锁不存在多线程竞争,并且应由一个线程多次获得锁。
当线程访问同步块时,会使用 CAS 将线程 ID 更新到锁对象的 Mark Word 中,如果更新成功则获得偏向锁,并且之后每次进入这个对象锁相关的同步块时都不需要再次获取锁了。
释放锁
当有另外一个线程获取这个锁时,持有偏向锁的线程就会释放锁,释放时会等待全局安全点(这一时刻没有字节码运行),接着会暂停拥有偏向锁的线程,根据锁对象目前是否被锁来判定将对象头中的 Mark Word 设置为无锁或者是轻量锁状态。
偏向锁可以提高带有同步却没有竞争的程序性能,但如果程序中大多数锁都存在竞争时,那偏向锁就起不到太大作用。可以使用 -XX:-UseBiasedLocking 来关闭偏向锁,并默认进入轻量锁。
|
12.ReentrantLock 和Synchronized的区别
1、ReentrantLock 拥有Synchronized相同的并发性和内存语义,此外还多了 锁投票,定时锁等候和中断锁等候
线程A和B都要获取对象O的锁定,假设A获取了对象O锁,B将等待A释放对O的锁定,
如果使用 synchronized ,如果A不释放,B将一直等下去,不能被中断
如果 使用ReentrantLock,如果A不释放,可以使B在等待了足够长的时间以后,中断等待,而干别的事情
ReentrantLock获取锁定与三种方式:
a) lock(), 如果获取了锁立即返回,如果别的线程持有锁,当前线程则一直处于休眠状态,直到获取锁
b) tryLock(), 如果获取了锁立即返回true,如果别的线程正持有锁,立即返回false;
c)tryLock(long timeout,TimeUnit unit), 如果获取了锁定立即返回true,如果别的线程正持有锁,会等待参数给定的时间,在等待的过程中,如果获取了锁定,就返回true,如果等待超时,返回false;
d) lockInterruptibly:如果获取了锁定立即返回,如果没有获取锁定,当前线程处于休眠状态,直到或者锁定,或者当前线程被别的线程中断
2、synchronized是在JVM层面上实现的,不但可以通过一些监控工具监控synchronized的锁定,而且在代码执行时出现异常,JVM会自动释放锁定,但是使用Lock则不行,lock是通过代码实现的,要保证锁定一定会被释放,就必须将unLock()放到finally{}中
3、在资源竞争不是很激烈的情况下,Synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态;
13.Java 多线程三大核心
1)原子性
Java 的原子性就和数据库事务的原子性差不多,一个操作中要么全部执行成功或者失败。
2)可见性
现代计算机中,由于 CPU 直接从主内存中读取数据的效率不高,所以都会对应的 CPU 高速缓存,先将主内存中的数据读取到缓存中,线程修改数据之后首先更新到缓存,之后才会更新到主内存。如果此时还没有将数据更新到主内存其他的线程此时来读取就是修改之前的数据。
如上图所示。
volatile 关键字就是用于保证内存可见性,当线程A更新了 volatile 修饰的变量时,它会立即刷新到主线程,并且将其余缓存中该变量的值清空,导致其余线程只能去主内存读取最新值。
使用 volatile 关键词修饰的变量每次读取都会得到最新的数据,不管哪个线程对这个变量的修改都会立即刷新到主内存。
synchronized和加锁也能能保证可见性,实现原理就是在释放锁之前其余线程是访问不到这个共享变量的。但是和 volatile 相比开销较大。
3)顺序性
int a = 100 ; //1int b = 200 ; //2int c = a + b ; //3
正常情况下的执行顺序应该是 1>>2>>3。但是有时 JVM 为了提高整体的效率会进行指令重排导致执行的顺序可能是 2>>1>>3。但是 JVM 也不能是什么都进行重排,是在保证最终结果和代码顺序执行结果一致的情况下才可能进行重排。
重排在单线程中不会出现问题,但在多线程中会出现数据不一致的问题。
Java 中可以使用 volatile 来保证顺序性,synchronized 和 lock 也可以来保证有序性,和保证原子性的方式一样,通过同一段时间只能一个线程访问来实现的。
除了通过 volatile 关键字显式的保证顺序之外, JVM 还通过 happen-before 原则来隐式的保证顺序性。
其中有一条就是适用于 volatile 关键字的,针对于 volatile 关键字的写操作肯定是在读操作之前,也就是说读取的值肯定是最新的。
总结
volatile 关键字只能保证可见性,顺序性,不能保证原子性。
14 .volatile关键字在Java中有什么作用
volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新,即保证了内存的可见性,除此之外还能 禁止指令重排序。此外,synchronized关键字也可以保证内存可见性。
指令重排序问题在并发环境下会导致线程安全问题,volatile关键字通过禁止指令重排序来避免这一问题。而对于Synchronized关键字,其所控制范围内的程序在执行时独占的,指令重排序问题不会对其产生任何影响,因此无论如何,其都可以保证最终的正确性。
15. volatile关键字和synchronized关键字的区别
(1)、volatile只能作用于变量,使用范围较小。synchronized可以用在变量、方法、类、同步代码块等,使用范围比较广。
(2)、volatile只能保证可见性和有序性,不能保证原子性。而可见性、有序性、子性synchronized都可以包证。
(3)、volatile不会造成线程阻塞。synchronized可能会造成线程阻塞。
16. 对于锁的一些认知
同一进程
重入锁
使用 ReentrantLock 获取锁的时候会判断当前线程是否为获取锁的线程,如果是则将同步的状态 +1 ,释放锁的时候则将状态 -1。只有将同步状态的次数置为 0 的时候才会最终释放锁。
读写锁
使用 ReentrantReadWriteLock ,同时维护一对锁:读锁和写锁。当写线程访问时则其他所有锁都将阻塞,读线程访问时则不会。通过读写锁的分离可以很大程度的提高并发量和吞吐量。
不同进程
分布式锁:
基于数据库
可以创建一张表,将其中的某个字段设置为唯一索引,当多个请求过来的时候只有新建记录成功的请求才算获取到锁,当使用完毕删除这条记录的时候即释放锁。
存在的问题:
数据库单点问题,挂了怎么办?
不是重入锁,同一进程无法在释放锁之前再次获得锁,因为数据库中已经存在了一条记录了。
锁是非阻塞的,一旦 insert 失败则会立即返回,并不会进入阻塞队列只能下一次再次获取。
锁没有失效时间,如果那个进程解锁失败那就没有请求可以再次获取锁了。
解决方案:
数据库切换为主从,不存在单点。
在表中加入一个同步状态字段,每次获取锁的是加 1 ,释放锁的时候-1,当状态为 0 的时候就删除这条记录,即释放锁。
非阻塞的情况可以用 while 循环来实现,循环的时候记录时间,达到 X 秒记为超时,break。
可以开启一个定时任务每隔一段时间扫描找出多少 X 秒都没有被删除的记录,主动删除这条记录。
基于 Redis
使用 setNX(key) setEX(timeout) 命令,只有在该 key 不存在的时候创建这个 key,就相当于获取了锁。由于有超时时间,所以过了规定时间会自动删除,这样也可以避免死锁。
17. 线程池
简单来说使用线程池有以下几个目的:
线程是稀缺资源,不能频繁的创建。
解耦作用;线程的创建于执行完全分开,方便维护。
应当将其放入一个池子中,可以给其他任务进行复用。
18. 线程池原理
谈到线程池就会想到池化技术,其中最核心的思想就是把宝贵的资源放到一个池子中;每次使用都从里面获取,用完之后又放回池子供其他人使用,有点吃大锅饭的意思。
那在 Java 中又是如何实现的呢?
在 JDK 1.5 之后推出了相关的 api,常见的创建线程池方式有以下几种:
Executors.newCachedThreadPool():无限线程池。
Executors.newFixedThreadPool(nThreads):创建固定大小的线程池。
Executors.newSingleThreadExecutor():创建单个线程的线程池。
这几个核心参数的作用:
corePoolSize 为线程池的基本大小。
maximumPoolSize 为线程池最大线程大小。
keepAliveTime 和 unit 则是线程空闲后的存活时间。
workQueue 用于存放任务的阻塞队列。
handler 当队列和最大线程池都满了之后的饱和策略。
了解了这几个参数再来看看实际的运用。
这里借助《聊聊并发》的一张图来描述这个流程:
线程池的饱和策略
当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:
AbortPolicy:直接抛出异常,默认策略;
CallerRunsPolicy:用调用者所在的线程来执行任务;
DiscardOldestPolicy:丢弃阻塞队列中最老的任务,并执行当前任务;
DiscardPolicy:直接丢弃任务;
当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务。
线程池调优
设置最大线程数,防止线程资源耗尽;
使用有界队列,从而增加系统的稳定性和预警能力(饱和策略);
根据任务的性质设置线程池大小:CPU密集型任务(CPU个数个线程),IO密集型任务(CPU个数两倍的线程),混合型任务(拆分)。
如何配置线程池
流程聊完了再来看看上文提到了几个核心参数应该如何配置呢?
有一点是肯定的,线程池肯定是不是越大越好。
通常我们是需要根据这批任务执行的性质来确定的。
IO 密集型任务:由于线程并不是一直在运行,所以可以尽可能的多配置线程,比如 CPU 个数 * 2
CPU 密集型任务(大量复杂的运算)应当分配较少的线程,比如 CPU 个数相当的大小。
当然这些都是经验值,最好的方式还是根据实际情况测试得出最佳配置。
19. 优雅的关闭线程池
有运行任务自然也有关闭任务,从上文提到的 5 个状态就能看出如何来关闭线程池。
其实无非就是两个方法 shutdown()/shutdownNow()。
但他们有着重要的区别:
shutdown() 执行后停止接受新任务,会把队列的任务执行完毕。
shutdownNow() 也是停止接受新任务,但会中断所有的任务,将线程池状态变为 stop。
两个方法都会中断线程,用户可自行判断是否需要响应中断。
shutdownNow() 要更简单粗暴,可以根据实际场景选择不同的方法。
20. springBoot使用线程池
2018 年了,SpringBoot 盛行;来看看在 SpringBoot 中应当怎么配置和使用线程池。
其实也挺简单,就是创建了一个线程池的 bean,在使用时直接从 Spring 中取出即可。
监控线程池
谈到了 SpringBoot,也可利用它 actuator 组件来做线程池的监控。
线程怎么说都是稀缺资源,对线程池的监控可以知道自己任务执行的状况、效率等。
线程池的隔离
线程池看似很美好,但也会带来一些问题。
如果我们很多业务都依赖于同一个线程池,当其中一个业务因为各种不可控的原因消耗了所有的线程,导致线程池全部占满。
这样其他的业务也就不能正常运转了,这对系统的打击是巨大的。
比如我们 Tomcat 接受请求的线程池,假设其中一些响应特别慢,线程资源得不到回收释放;线程池慢慢被占满,最坏的情况就是整个应用都不能提供服务。
所以我们需要将线程池进行隔离。
通常的做法是按照业务进行划分:
比如下单的任务用一个线程池,获取数据的任务用另一个线程池。这样即使其中一个出现问题把线程池耗尽,那也不会影响其他的任务运行。
hystrix隔离
这样的需求 Hystrix 已经帮我们实现了。
Hystrix 是一款开源的容错插件,具有依赖隔离、系统容错降级等功能。
下面来看看 Hystrix 简单的应用:
首先需要定义两个线程池,分别用于执行订单、处理用户。
可以看到两个任务分成了两个线程池运行,他们之间互不干扰。
获取任务任务结果支持同步阻塞和异步非阻塞方式,可自行选择。
它的实现原理其实容易猜到:
利用一个 Map 来存放不同业务对应的线程池。
21. 深入理解线程通信
前言
开发中不免会遇到需要所有子线程执行完毕通知主线程处理某些逻辑的场景。
或者是线程 A 在执行到某个条件通知线程 B 执行某个操作。
可以通过以下几种方式实现:
等待通知机制
等待通知模式是 Java 中比较经典的线程通信方式。
两个线程通过对同一对象调用等待 wait() 和通知 notify() 方法来进行通讯。
有一些需要注意:
wait() 、notify()、notifyAll() 调用的前提都是获得了对象的锁(也可称为对象监视器)。
调用 wait() 方法后线程会释放锁,进入 WAITING 状态,该线程也会被移动到等待队列中。
调用 notify() 方法会将等待队列中的线程移动到同步队列中,线程状态也会更新为 BLOCKED
从 wait() 方法返回的前提是调用 notify() 方法的线程释放锁,wait() 方法的线程获得锁。
join()方法
22. volatitle共享内存
CounDownLatch并发工具
CountDownLatch 可以实现 join 相同的功能,但是更加的灵活。
CountDownLatch 也是基于 AQS(AbstractQueuedSynchronizer) 实现的,
初始化一个 CountDownLatch 时告诉并发的线程,然后在每个线程处理完毕之后调用 countDown() 方法。
该方法会将 AQS 内置的一个 state 状态 -1 。
最终在主线程调用 await() 方法,它会阻塞直到 state == 0 的时候返回。
CyclicBarrier 并发工具
CyclicBarrier 中文名叫做屏障或者是栅栏,也可以用于线程间通信。
它可以等待 N 个线程都达到某个状态后继续运行的效果。
首先初始化线程参与者。
调用 await() 将会在所有参与者线程都调用之前等待。
直到所有参与者都调用了 await() 后,所有线程从 await() 返回继续后续逻辑。
可以看出由于其中一个线程休眠了五秒,所有其余所有的线程都得等待这个线程调用 await() 。
该工具可以实现 CountDownLatch 同样的功能,但是要更加灵活。甚至可以调用 reset() 方法重置 CyclicBarrier (需要自行捕获 BrokenBarrierException 处理) 然后重新执行。
线程响应中断
可以采用中断线程的方式来通信,调用了 thread.interrupt() 方法其实就是将 thread 中的一个标志属性置为了 true。
并不是说调用了该方法就可以中断线程,如果不对这个标志进行响应其实是没有什么作用(这里对这个标志进行了判断)。
但是如果抛出了 InterruptedException 异常,该标志就会被 JVM 重置为 false。
线程池 awaitTermination() 方法
使用这个 awaitTermination() 方法的前提需要关闭线程池,如调用了 shutdown() 方法。
调用了 shutdown() 之后线程池会停止接受新任务,并且会平滑的关闭线程池中现有的任务。
管道通信
Java 虽说是基于内存通信的,但也可以使用管道通信。
需要注意的是,输入流和输出流需要首先建立连接。这样线程 B 就可以收到线程 A 发出的消息了。
实际开发中可以灵活根据需求选择最适合的线程通信方式。
23. CAS(无锁算法)
CAS(Compare And Swap) 无锁算法: CAS是乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做
CAS : CAS自旋volatile变量,是一种很经典的用法。
CAS,Compare and Swap即比较并交换,设计并发算法时常用到的一种技术。CAS有3个操作数,内存值V,旧的预期值A,新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。CAS是通过unsafe类的compareAndSwap (JNI, Java Native Interface) 方法实现的,该方法包括四个参数:第一个参数是要修改的对象,第二个参数是对象中要修改变量的偏移量,第三个参数是修改之前的值,第四个参数是预想修改后的值。
CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题:ABA问题、循环时间长开销大和只能保证一个共享变量的原子操作。
ABA问题:因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。
不适用于竞争激烈的情形中:并发越高,失败的次数会越多,CAS如果长时间不成功,会极大的增加CPU的开销。因此CAS不适合竞争十分频繁的场景。
只能保证一个共享变量的原子操作:当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,因此可以把多个变量放在一个对象里来进行CAS操作。
AQS : 队列同步器
队列同步器(AbstractQueuedSynchronizer)是用来构建锁和其他同步组件的基础框架,技术是 CAS自旋Volatile变量:它使用了一个Volatile成员变量表示同步状态,通过CAS修改该变量的值,修改成功的线程表示获取到该锁;若没有修改成功,或者发现状态state已经是加锁状态,则通过一个Waiter对象封装线程,添加到等待队列中,并挂起等待被唤醒。
同步器是实现锁的关键,子类通过继承同步器并实现它的抽象方法来管理同步状态,利用同步器实现锁的语义。特别地,锁是面向锁使用者的,它定义了使用者与锁交互的接口,隐藏了实现细节;同步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程排队、等待与唤醒等底层操作。锁和同步器很好地隔离了锁的使用者与锁的实现者所需关注的领域。
一般来说,自定义同步器要么是独占方式,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
同步器的设计是基于 模板方法模式 的,也就是说,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。
AQS维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。这里volatile是核心关键词,具体volatile的语义,在此不述。state的访问方式有三种:getState()、setState()以及compareAndSetState()。
AQS定义了两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:
isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它;
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false;
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false;
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源;
tryReleaseShared(int):共享方式。尝试释放资源,成功则返回true,失败则返回false。
以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
上一篇: Java面试集锦(一)之Java基础
下一篇: Java面试集锦(一)