并发编程
一、Volatile关键字
1、Java内存模型JMM
- 计算机内存模型
由于现在的计算机CPU的指令运行速度 >> 内存的读写速度,所以一般在CPU内部加一层高速缓存,然后CPU将数据复制到缓存中运算,然后再从缓存中同步回主内存。但是需要解决数据一致性问题。
- Java内存模型
描述了Java中 各个线程对共享变量 的访问规则,规定了:
(1)所以的共享资源(类变量,实例)都存储在主内存
(2)每个线程都有自己的工作内存,保留了工作副本,线程对共享变量的操作,都只能在工作内存中进行
(3)线程间不能访问对方的工作内存,线程间值传递只能通过主内存进行中转。
从而引发了可见性问题,即不加锁的情况下,当前线程对共享变量进行操作时,不知道此值是否为最新。
2、可见性问题
解决可见性问题,主要解决线程间共享变量及时更新的问题。
解决办法:
- 1. 加锁
这种方法最简单粗暴,其中一个线程在工作内存中操作完毕后,写回主内存时才释放锁,其他线程从一直阻塞再开始操作,这样肯定是最新值。
- 2. Volatile修饰共享变量
使用Volatile来修饰这个共享变量,就能实现:
其中一个线程修改共享变量后,写回主线程时,其余线程都能“知道”,使得工作内存中的拷贝值失效(此次操作失败),重新从主存获取值,此时肯定是最新。
3、Volatile底层如何实现可见性?
Volatile 底层会触发一次lock前缀指令,锁定这块内存,然后进行一次缓存一致性原则MESI。
- MESI:缓存一致性原则:
当CPU在对 共享变量 进行写操作时,必须马上写回到主存中,然后其他CPU通过总线嗅探机制监听总线,监听到修改后,工作内存中的值失效,重新触发一次内存的 read, load,use操作。
4、何为有序性?Volatile底层如何保证有序性?
- 指令重排序
一般来说,编译器为了提升程序运行效率,可能会改变代码的执行顺序,但会保证单线程结果与预期结果一致。
所以为了保证多线程下,结果一致,就必须禁止指令重排序。
- 有序性
顾名思义,程序按照代码的先后顺序执行。有序性通常也指的是happen-before原则。
happen-before,我觉得就是按照控制流执行,按部就班。其中Volatile变量规则:对Volatile域的写操作,后序所以线程的读操作都可见。
- Volatile通过禁止指令重排序来保证有序性
具体做法:会插入内存屏障
,禁止重排序。
5、Volatile无法保证原子性
因为Volatile的lock前缀指令
会触发总线嗅探,可能使其他线程一次结果失效,这样就会导致结果有误。
例如多线程操作Volatile变量i++,可能会导致加的次数不变,但是结果变少。
如果想要保住原子性,如何操作?
加锁,简单粗暴,直接在代码块加上synchronized关键字
使用原子类
将共享变量类型使用原子类进行操作,底层使用的是CAS。
6、Volatile与Synchronized的区别
- Volatile可以保住可见性、有序性,但是无法保证原子性。而synchronized都可以保证
- Volatile只能修饰变量,而synchronized是修饰方法、代码块的。
- 最重要的一点:Volatile可以看做轻量级的synchronized,当多线程只进行读操作、或者赋值的操作,没有其他操作就可以使用Volatile进行代替,保证线程安全。
二、Java锁机制
1、Java的锁机制
- 引入锁的原因
多线程对同一共享变量进行操作时,可能会造成数据不一致(JMM决定)的问题,则需要对资源进行锁定来防止其他线程操作使得结果不一致。
- Java的锁机制
多线程进行操作Java堆对象、方法区(类信息、常量、类变量)这些线程共享的数据时,会存在异常。
2、Java对象结构
在JVM中,对象由 对象头、数据、填充字节
,重点看看对象头。
对象头:以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针,指向方法区中的类信息)。
我们重点来看看mark word字段:
无锁:
无锁状态时,存储的是对象的hashcode值。
偏向锁:
偏向锁时,前23位存储偏向的线程ID,并将倒数第三位置为1,表示偏向锁。
轻量级锁:
轻量级锁时,存储的是 指向JVM栈中的锁记录Lock Record的指针
重量级锁:
当自旋次数超过10次时,将转换成重量级锁,重量级锁存储的是 指向重量级锁Monitor的指针
三、乐观锁
乐观锁其实不是锁,我们想要不锁定资源,也能进行线程同步。
1、说一说CAS吧!
CAS:compare and swap
比较并且交换,是乐观锁
的一种实现方式,JUC很多工具类都是基于CAS的。
注意:底层比较和交换是原子的
2、CAS如何保证线程安全?
线程在操作变量时不会进行加锁,在准备写回主存时,先去查询原值是否被修改,若无修改则写回,若被修改则此次操作失效重新read到工作内存操作。
3、原子类底层的实现?
其实原子类就可以看成实现了乐观锁。原子类型底层是 Volatile + CAS 实现,例如整型数据
(1)Value用Valatile修饰
private volatile int value; |
(2)方法调用底层的Unsafe类的方法(基于CAS实现)
例如:自增方法
// 调用Unsafe类的getAndAddInt方法 |
4、CAS存在的问题
- 一直自旋
do-while循环一直执行的话,CPU一直空转,开销大。
- ABA问题
(1)线程1读取了A,线程2也读取了A
(2)线程2通过CAS将A改成了B,但同时线程3进来了,通过CAS将B改成了A
(3)最后线程1发现数据是A,CAS没有错,则写成了自己要改的值,虽然结果正确但我们还是要将此次的CAS判错。
解决方案:加一个标志位,或者版本号。
标志位:操作一次就加1,判断是否为0,版本号同理。
update table set value = newValue ,vision = vision + 1 where value = #{oldValue} and vision = #{vision} |
- 只能保证一个变量保持原子性
CAS操作多个数据就不行了,底层只能保证一个数据的比较 + 交换操作。
可以使用原子引用AtomicReference来保证对象之间的原子性,把多个对象放入CAS中操作。
四、悲观锁
1、JVM层面的锁:synchronized
synchronized
是JVM层面的悲观锁,JVM什么都帮我们做了。
使用synchronized
关键字来实现线程的同步,可以对对象、方法、代码块
进行同步。
底层:使用了Monitor保证了同一时间只允许一个线程操作数据。
- 对象
同步对象时,对象的对象头中Mark Word字段有指向Monitor的指针,如果Monitor被某个线程持有,则owner部分会指向该线程。
- 方法
同步方法时,通过在字节码中设置ACC_SYNCHRONIZED标志位(底层隐式调用monitor来实现同步的),其他线程发现方法中有标志位时,就会阻塞。
- 代码块
同步代码块时,通过monitorenter 和 monitorexit
实现的。(底层依赖OS的mutex lock原语)
1.1、为什么说synchronized是重量级锁
因为synchronized来同步线程,涉及到Java线程的挂起和唤醒操作,而Java线程实际上是映射了OS的线程,所以同步时就会涉及到OS的内核态与用户态的切换。这样的操作显然是重量级的,所以Java6之后对synchronized进行了优化。
1.2、说说Java6之后锁升级的过程?
- synchronized的优化
由于synchronized是重量级锁,耗费资源,所以进行优化。
优化方法:无锁状态到有锁状态,引入锁升级机制。
- 锁升级
锁分类是按照控制粒度层面划分:
注意:锁升级过程不可逆。(都是synchronized一个关键字,内部实现不同罢了)
(1)无锁
开始阶段,资源例如对象还没有被锁定,所有线程都能访问这一资源,Mark Word存放对象的hashcode值。
(2)偏向锁
一旦有线程锁定了这个对象,Mark Word标志位修改为1,就进入偏向模式,同时会把这个线程的ID记录在对象的Mark Word中。
这个过程是CAS来实现:每次同一线程进入,虚拟机就不进行任何同步的操作了,对标志位+1就好了,不是则CAS会失败,也就意味着获取锁失败。
注:当发现不只一个线程来操作对象时,偏向锁升级为轻量级锁。
(3)轻量级锁
当线程进入时,会进行CAS尝试,会拷贝Mark Word在线程对应的JVM栈中生成Lock Record空间,里面有owner指针,指向了当前对象,此时就实现了绑定,说明加锁成功,则在Mark Word字段中增加指向栈中的锁记录的指针。
自旋
其他线程进入时,发现CAS不成功,就会进行自旋(CPU空轮询)等待,一直等待资源被释放,避免了挂起操作耗费资源。
但自旋会使cpu一直轮询占用,所以当自旋个数超过CPU一半、或者自旋次数超过10次就升级为重量级锁。
(4)重量级锁
使用monitor来实现线程的同步。参考前面的synchronized悲观锁。
1.3、synchronized具有独占性、可重入性、不可中断性、非公平锁
- (1)可重入性
synchronized锁对象的时候,就会有一个计数器(该线程获取锁的次数),获取时就+1 ,执行完后计数器 -1,清零表示释放掉锁。
作用:避免了死锁
- (2)不可中断性
该线程通过synchronized锁住对象时,其余的线程获取资源的话会被阻塞、或者等待。该线程不释放,其余线程就一直阻塞、等待,不可以被中断。
注意:Lock的tryLock方法是可以被中断的。
- (3)非公平锁
1.4、双重校验锁实现对象单例
问题: uniqueInstance = new Singleton(); 这段代码,在多线程环境下会导致一个线程获得还没有初始化的实例。
例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
uniqueInstance 采用 volatile 关键字修饰很重要,保证了可见性,有序性(防止指令重排)保证在多线程环境下也能正常运行。
public class Singleton { |
2、API层面的锁:Lock
Lock是一个接口,是API层面的锁。需要lock()和unlock()来获取和释放锁。
意义:提供了区别于synchronized的另一种具有更多广泛操作的同步方式,支持更灵活的结构,可以关联多个Condition对象。
2.1、说说synchronized与Lock的区别
synchronized是关键字,是JVM层面的锁,而Lock是个接口,是API层面的锁
synchronized会自动释放锁,而Lock必须手动释放锁unlock()
synchronized是非公平锁,Lock公平锁和非公平锁都有实现
synchronized是不可被中断的,Lock可以中断也可以不被中断。(阻塞 -> 中断阻塞)
Lock可以通过
boolean tryLock();
方法可以知道线程是否拿到锁,synchronized不能。synchronized能锁住方法和代码块,Lock只能锁住代码块
lock.lock();
//同步方法
if(ticket > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
String name = Thread.currentThread().getName();
System.out.println(name + "正在卖" + ticket--);
}
lock.unlock();可以使用Lock的读锁子类来提高多线程读效率
2.2、说说AQS吧,谈谈对它的理解
AQS是抽象队列同步器
,我的理解:它是一个通过CAS来对竞争资源进行同步管理的框架。
- 实现思想
- 利用CAS 和 Volatile 原子的修改共享标志位state,值为1 时表示有线程占用,因为有
独占和共享
两种模式,所以设置成int类型。 - 维护一个等待队列,实现未获取到资源进行等待的队列
2.3、synchronized与Reentrantlock的区别
- 两者都是独占、可重入锁
- synchronized依赖于JVM,而ReentrantLock依赖于API,实现了Lock接口,内部sync是继承自AQS
- ReentrantLock多一些特性,例如可中断、可以实现公平锁、实现选择通知可以创建多个Condition对象。
可中断:通过lockInterruptibly()
方法实现,正在等待的线程可以中断等待
synchronized 与 wait
和 notify / notifyAll
实现等待 / 通知
,而ReentrantLock通过Condition(对象监视器)来实现,线程注册到Condition上,有选择性的通知线程。
2.4、Semaphore与ReentrantLock的区别
- synchronized 和 ReentrantLock 都是独占锁,一次只允许一个线程访问某个资源。
- Semaphore(信号量)是共享锁,允许多个线程同时访问,可以指定同一时间,资源可被访问的线程数量,一般用于流量控制。
五、ThreadLocal
使用目的:
我们创建的变量,多线程情况可能被其他线程访问修改。当我们想要一个线程专属的本地变量时,能够做到数据的隔离,就需要ThreadLocal。
1、ThreadLocal常见的使用场景?
一般用于的场景:
(1)数据库操作时,主要用于隔离,使得每个线程使用的都是一个数据库连接。
(2)Web项目,例如一些user或者token、cookie之类的用户信息需要跨越若干层方法传递,每个方法加一个user(token、cookie)很麻烦
void work(User user) { |
使用ThreadLocal将信息和线程绑定改进:
threadLocalUser.set(user); |
2、ThreadLocal实现原理
实际上每个Thread,内部都维护了一个ThreadLocalMap类型的变量threadsLocals,这样每次创建一个绑定线程的ThreadLocal时,就存入线程的threadLocals中。
注意:threadlocals和inheritableThreadLocals初始化为null,直到创建threadlocal时,才创建。
/* ThreadLocal values pertaining to this thread. This map is maintained |
拓展:inheritableThreadLocals 可以设置ThreadLocal成线程共享。
3、ThreadLocal 和 ThreadLocalMap 的关系
- ThreadLocalMap 是 ThreadLocal 的一个静态内部类
- 创建与线程绑定的本地变量ThreadLocal时,实际上是先初始化ThreadLocalMap ,然后将ThreadLocal存进去。
- ThreadLocalMap 是个Map结构,但不是实现Map接口,key是ThreadLocal,而Value是ThreadLocal set的值,所以内部维护多个ThreadLocal。
4、ThreadLocalMap 的底层结构(重点)
先来撕一撕源码发现:
- 它并未实现Map接口
- 他的Entry是继承WeakReference(弱引用)
- 没有HashMap中的next,所以不存在链表
static class ThreadLocalMap { |
4.1、底层实现时什么结构?
ThreadLocalMap 没有采用map接口,而是使用的Entry数组存储多个ThreadLocal值。
插入过程中,根据ThreadLocal对象的hash值(int i = key.threadLocalHashCode & (len-1)),定位到table中的索引位置。
4.2、如何解决hash冲突?
先看看源码:
ThreadLocal<?> k = e.get(); |
索引位置有值时,判断key即ThreadLocal是否相同,相同就更新。
索引位置有值时,key不同,则hash冲突时,
i = nextIndex(i, len);
,即key不等于entry,那就找下一个空位置,直到为空为止
5、ThreadLocal 实例存放在堆内存嘛?
虽然线程私有的都是放在JVM栈中,但是ThreadLocal的实例以及其值还是存放在堆中,通过一些技巧将可见性修改成了线程可见。
6、ThreadLocal 为什么会有内存泄露问题?(重点)
弱引用:一定回收
弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
ThreadLocalMap中的Entry类,他继承了WeakReference弱引用,使得key被设计成WeakReference弱引用。
static class ThreadLocalMap { |
内存泄露问题
如果创建ThreadLocal的线程一直持续运行,那么这个Entry对象中的value不置为null,就一直得不到回收,发生内存泄露。
例如:线程池里面的线程,线程本来就是复用的,value没有被回收,则下一次复用仍然没有被回收,导致内存泄露。
- 解决:最后使用
remove
方法,将所有value对象置为null即可。netty的fastThreadLocal来弥补问题。
7、为什么key要设计成弱引用?不是找麻烦嘛?
不这样做其实就会导致,如果最后不把key即ThreadLocal置null,也会跟value一样,发生内存泄露问题。
六、线程池
1、能说说线程池(池化技术)的好处吗?
- 降低资源的消耗:避免频繁的创建和销毁线程造成的消耗。
- 提高了响应速度:任务不需要等待线程创建。
- 增强了线程的可管理性:使用线程池可以进行统一的分配,调优和监控。
2、线程实现Runnable接口和Callable接口的区别
- 使用Callable接口创建线程会返回结果,但是一般的任务不需要返回结果 / 抛出异常,所以为了简洁起见,就使用Runnable。
- Runnable一直都存在,而Callable是1.5引入的。
- 工具类Executors,可以实现两者的转换。
3、说一说执行execute()方法和submit()方法的区别是什么呢?
- execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被执行。
- submit(),线程池会返回Future对象,通过Future来判断任务是否执行成功。
Future
的get()
方法来获取返回值
// 1.submit实际上也是先创建一个绑定的Future对象,然后调用execute方法 |
4、如何规范且优雅的创建线程池?
为什么不使用Executors
《阿里巴巴Java开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
因为Executors创建,实际上就是调用
ThreadPoolExecutor
的方法,且可能堆积大量的请求,或者堆积大量的线程,从而导致OOM。
public static ExecutorService newFixedThreadPool(int nThreads) { |
- 使用 ThreadPoolExecutor 创建
ThreadPoolExecutor
构造函数重要参数分析
ThreadPoolExecutor
3 个最重要的参数:
corePoolSize
: 核心线程数线程数定义了最小可以同时运行的线程数量。maximumPoolSize
: 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue
: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务会被存放在队列中。
ThreadPoolExecutor
其他常见参数:
keepAliveTime
:当线程池中的线程数量大于corePoolSize
的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
才会被回收销毁;unit
:keepAliveTime
参数的时间单位。threadFactory
:executor 创建新线程的时候会用到。handler
:拒绝策略。
5、说一说线程池的拒绝策略吧!(重要)
- 何时采用拒绝策略
如果当前同时运行的线程数量达到最大线程数量并且等待队列也已经被放满了
- 拒绝策略种类:
- 默认抛出异常
- 调用线程来执行,它喜欢增加队列容量,降低任务的提交速度,影响性能。
- 拒绝
- 丢弃最早执行的线程请求
- 高峰结束
高峰期过去,超过corePoolSize的那部分线程长时间没有task任务做,则结束释放资源。