Java多线程与并发原理
盘点了常考的Java多线程与并发原理
- 线程安全问题的主要诱因
- 互斥锁
- 获取对象
- 对象在内存中的布局
- Monitor:每个Java对象天生自带了一把看不见的锁(C++实现)
- 什么是从重入
- 为什么会对synchronized嗤之以鼻
- Java6以后,synchronized性能得到了很大的提升
- 自旋锁与自适应自旋锁
- 锁消除
- 锁粗化
- Synchronized的四种状态
- 锁的内存语义
- 偏向锁、轻量级锁、重量级锁的汇总
- Synchronized和ReentrantLock
- 什么是Java内存模型中的happens-before
- CAS(compare and swap)
- Java线程池
线程安全问题的主要诱因
Ø 存在共享数据(也称临界资源)
Ø 存在多条线程共同操作这些共享数据
解决问题的根本方法
同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行操作。
互斥锁
互斥锁的特性
互斥性
即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程的协调机制,这样在同一时间只有一个线程对需要同步的代码块(复合操作)进行访问。互斥性也称为操作的原子性。
可见性
必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获取锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作,从而引起不一致。
synchronized锁的不是代码,锁的都是对象
获取对象
根据获取的锁的分类:获取对象锁和获取类锁
获取对象锁的两种用法
同步代码块(synchronized(this),synchronized(类实例对象)),锁是小括号()中的实例对象
同步非静态方法(synchronized method),锁是当前对象的实例对象
获取类锁的两种用法
同步代码块(synchronized(类.class)),锁是小括号()中的类对象(Class对象)。
同步静态方法(synchronized static method),锁是当前对象的类对象(Class对象)
锁和获取类锁
1 | package com.interview.javabasic.thread; |
执行 A B C
结果如下:
A_thread2_Async_Start: 10:53:42
B_thread2_SyncObjectBlock1: 10:53:42
C_thread1_SyncObjectMethod1: 10:53:42
B_thread1_SyncObjectBlock1: 10:53:42
A_thread1_Async_Start: 10:53:42
C_thread1_SyncObjectMethod1_Start: 10:53:42
A_thread2_Async_End: 10:53:43
A_thread1_Async_End: 10:53:43
C_thread1_SyncObjectMethod1_End: 10:53:43
B_thread1_SyncObjectBlock1_Start: 10:53:43
B_thread1_SyncObjectBlock1_End: 10:53:44
B_thread2_SyncObjectBlock1_Start: 10:53:44
B_thread2_SyncObjectBlock1_End: 10:53:45
C_thread2_SyncObjectMethod1: 10:53:45
C_thread2_SyncObjectMethod1_Start: 10:53:45
C_thread2_SyncObjectMethod1_End: 10:53:46
我们分开来看
A_thread想怎么样就怎么样,因为是异步的。1可能后于2执行但是先于2结束。
B_thread在进入同步块之前和A_thread行为一样,进入后谁先抢占锁谁先执行。
C_thread因为使用synchronized修饰方法,因此一开始就是同步方法。
然后再合起来看:
发现线程C和线程B也是同步的,因为synchronized的变量是一样的。
同步方法和同步块锁的都是同一个对象。
如果换成不同对象呢?
B_thread2_SyncObjectBlock1: 11:05:52
B_thread1_SyncObjectBlock1: 11:05:52
C_thread1_SyncObjectMethod1: 11:05:52
C_thread1_SyncObjectMethod1_Start: 11:05:52
B_thread2_SyncObjectBlock1_Start: 11:05:52
C_thread1_SyncObjectMethod1_End: 11:05:53
C_thread2_SyncObjectMethod1: 11:05:53
C_thread2_SyncObjectMethod1_Start: 11:05:53
B_thread2_SyncObjectBlock1_End: 11:05:53
B_thread1_SyncObjectBlock1_Start: 11:05:53
B_thread1_SyncObjectBlock1_End: 11:05:54
C_thread2_SyncObjectMethod1_End: 11:05:54
明显看到和上面最大的差别就是B和C变成异步的了。
D_thread2_SyncClassBlock1: 11:17:10
A_thread1_Async_Start: 11:17:10
D_thread1_SyncClassBlock1: 11:17:10
E_thread1_SyncClassMethod1: 11:17:10
A_thread2_Async_Start: 11:17:10
E_thread1_SyncClassMethod1_Start: 11:17:10
A_thread2_Async_End: 11:17:11
E_thread1_SyncClassMethod1_End: 11:17:11
D_thread1_SyncClassBlock1: 11:17:10A_thread1_Async_End: 11:17:11
D_thread1_SyncClassBlock1_Start: 11:17:11
D_thread1_SyncClassBlock1_End: 11:17:12
D_thread2_SyncClassBlock1_Start: 11:17:12
D_thread2_SyncClassBlock1_End: 11:17:13
E_thread2_SyncClassMethod1: 11:17:13
E_thread2_SyncClassMethod1_Start: 11:17:13
E_thread2_SyncClassMethod1_End: 11:17:14
其实和第一次的结果一致,同时注意一个细节就是类锁不干扰对象锁。
类锁和对象锁是互不干扰的
对象锁和类锁的总结
有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块;
若锁住的是同一对象,一个线程在访问对象的同步代码块时,另一个访问对象的同步代码块的线程会被阻塞;
若锁住的是同一个对象,一个线程在访问对象的同步方法时,另一个访问对象同步方法的线程会被阻塞;
若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象同步方法的线程会被阻塞,反之亦然;
同一个类的不同对象的对象锁互不干扰;
类锁由于也是一种特殊的对象锁,因此表现和上述1,2,3,4一致,而由于一个类只有一把对象锁,所以同一个类的不同对象使用类锁将会是同步的;
类锁和对象锁互不干扰
一个是类对象,一个是对象。
对象在内存中的布局
Ø 对象头
Ø 实例数据
Ø 对齐填充
对象头的结构
Mark Word
Monitor:每个Java对象天生自带了一把看不见的锁(C++实现)
我们可以看到同步代码块执行的是monitorenter和monitorexit指令
什么是从重入
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入。
为什么会对synchronized嗤之以鼻
Ø 早期版本,synchronized属于重量级锁,依赖于Mutex Lock实现
Ø 线程之间的切换需要从用户态转换到核心态,开销较大。
Java6以后,synchronized性能得到了很大的提升
Adaptive Spinning | Lightweight Locking |
---|---|
Lock Eliminate | Biased Locking |
Lock Coarsening | … |
自旋锁与自适应自旋锁
自旋锁
Ø 许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得
Ø 通过让线程执行忙循环等待锁的释放,不让出CPU
Ø 缺点:若锁被其它线程长时间占用,会带来许多性能上的开销
jvm调优可以用-XX:preBlockSpin
自适应自旋锁
Ø 自旋的次数不再固定
Ø 由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
锁消除
更彻底的优化
Ø JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。
自动消除这个synchronized锁,因为sb属于本地变量并没有return出去,因此不存在被其他线程引用到的风险,因此会自动取消synchronized
锁粗化
另一种极端
通过扩大锁的范围,避免重复加锁和解锁
因为是在循环内部加解锁,虽然可能存在同步竞争,但是在线程内部其实是没有同步竞争的,因此JVM会将锁加到方法上,而不是一个append操作里面。
Synchronized的四种状态
Ø 无锁、偏向锁、轻量级锁、重量级锁
锁膨胀方向:无锁→偏向锁→轻量级锁→重量级锁
偏向锁:减少同一线程获取锁的代价 CAS(Compare and Swap)【-】
Ø 大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得
核心思想:
如果一个线程获得了锁,在Mark Word中CAS记录owner,如果成功。那么锁就进入偏向模式,此时Mark Word的结构也变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word的锁标记位为偏向锁以及当前线程Id等于Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作。
如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程
不适用于锁竞争比较激烈的多线程场合
轻量级锁
轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。
适应的场景:线程交替执行同步块
若存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁
加锁过程
(1) 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁状态位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间。用于存储锁对象目前的Mark Word的拷贝,官方称之为Displaced Mark Word。这时候线程堆栈与对象头的状态如图所示。
(2) 拷贝对象头中的Mark Word复制到锁记录中。
(3) 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向Object mark word。如果更新成功,则执行步骤(4)否则执行步骤(5)。
(4) 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈和对象头的状态如图所示。
(5) 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的过程也要进入阻塞状态。而当前线程便尝试使用自旋来获取锁,自旋之前讲过,就是不让线程阻塞,而采用循环去获取锁的过程。
解锁过程
(1) 通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
(2) 如果替换成功,整个同步过程就完成了。
(3) 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
锁的内存语义
当线程释放锁时,Java内存模型会把该线程对应的本地内存中的共享变量刷新到主内存中;
而当线程获取锁时,Java内存模型会把该线程对应的本地内存置为无效,从而使得被监控器保护的临界区代码必须从主内存中读取共享变量。
偏向锁、轻量级锁、重量级锁的汇总
Synchronized和ReentrantLock
ReentrantLock(再入锁)
Ø 位于java.util.concurrent.locks包
Ø 和CountDownLatch、FutureTask、Semaphore一样基于AQS实现
Ø 能够比synchronized更细粒度的控制,如控制fairness
Ø 调用lock()之后,必须调用unlock()释放锁
Ø 性能未必比synchronized高,并且也是可重入的
ReentrantLock公平性的设置
Ø ReentrantLock fairLock = new ReentrantLock(true);
Ø 参数为true时,倾向于将锁赋予等待时间最久的线程
Ø 公平锁:获取锁的顺序按先后调用lock方法的顺序(慎用)
Ø 非公平锁:抢占顺序不一定,看运气
Ø Synchronized是非公平锁
ReentrantLock将锁对象化
Ø 判断是否有线程,或者某个特定线程,在排队等待获取锁
Ø 带超时的获取锁的尝试
Ø 感知有没有成功获取锁
能将wait\notify\notifyAll对象化
Ø Java.util.concurrent.locks.Condition
Synchronized和ReentrantLock的区别总结
Ø Synchronized是关键字,ReentrantLock是类
Ø ReentrantLock可以对获取锁的等待时间进行设置,避免死锁
Ø ReetrantLock可以获取各种锁的信息
Ø ReentrantLock可以灵活地实现多路通知
Ø 机制:sync操作Mark Word,lock调用Unsafe类的park()方法
什么是Java内存模型中的happens-before
Java内存模型JMM
JMM描述的是一组规则,围绕原子性,有序性、可见性展开,定义了程序中各个变量的访问方式
JMM处于中间层,包含了两个方面:(1)内存模型;(2)重排序以及happens-before规则。同时,为了禁止特定类型的重排序会对编译器和处理器指令序列加以控制。
JMM中的主内存
Ø 存储Java实例对象
Ø 包括成员变量、类信息、常量、静态变量等
Ø 属于数据共享的区域,多线程并发操作时会引发线程安全问题
JMM中的工作内存
Ø 存储当前方法的所有本地变量信息,本地变量对其他线程不可见
Ø 字节码行号指示器、Native方法信息
Ø 属于线程私有数据区域,不存在线程安全问题
JMM与Java内存区域划分是不同的概念层次
Ø JMM描述的是一组规则,围绕原子性,有序性、可见性展开
Ø 相似点:存在共享区域和私有区域
主内存与工作内存的数据存储类型以及操作方式归纳
Ø 方法里的基本数据类型本地变量将直接存储在工作内存的栈帧结构中
Ø 引用类型的本地变量:引用储存在工作内存中,实例储存在主内存中
Ø 成员变量、static变量、类信息均会被存储在主内存中
Ø 主内存共享的方式是线程各拷贝一份数据到工作内存,操作完成后刷新回主内存
指令重排序需要满足的条件
Ø 在单线程环境下不能改变程序运行的结果
Ø 存在数据依赖关系的不允许重排列
无法通过happens-before原则推导出来的,才能进行指令的重排序
A操作的结果需要对B操作可见,则A与B存在happens-before关系
Happens-before的八大原则
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
- 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作;
- Volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
- 传递规则:如果操作A发生先行于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作;
- 线程中断规则:对线程interrupt()方法的调用先行于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有操作都先行于线程的终止检测,我们可以通过Thread.join() 方法结束,Thread.isAlive()的返回值手段检测线程已经终止执行;
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
Happens-before的概念
如果两个操作不满足上述任意一个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序;
如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的。
无法通过happens-before原则推导出(因此不是线程安全的)
可以对方法加入synchronized锁或者对变量进行volatile修饰。
final重排序
按照final修饰的数据类型分类:
基本数据类型:
- final域写:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过。
- final域读:禁止初次读对象的引用与读该对象包含的final域的重排序。
引用数据类型:
额外增加约束:禁止在构造函数对一个final修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量重排序
Volatile:JVM提供的轻量级同步机制
Ø 保证被volatile修饰的共享变量对所有线程总是可见的
Ø 禁止指令重排序优化
Volatile不保证原子性
会产生线程安全问题,因为++不是原子性操作
因此要用synchronized修饰符,并且volatile可以被省略。
由于对boolean值shutdown修改是原子性操作,因此可以保证线程安全。
Volatile变量为何立即可见?
当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中;
当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效。
Volatile如何禁止重排优化
内存屏障(Memory Barrier)
保证特定操作的执行顺序
保证某些变量的内存可见性
通过插入内存屏障指令禁止在内存屏障前后的指令执行重排序优化
强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本
实现线程安全的单例写法(错误示范)
因为步骤2和步骤3不存在数据依赖关系,所以可能被重排序。
重排序只会保证单线程的语义一致性(串行语义的一致性),不会保证多线程之间的语义一致性。
因此本例可能会出现线程1重排序后执行到第2步时已经将instance指向了分配的内存,导致线程2返回instance。
解决方法:使用volatile修饰instance即可。
Volatile和synchronized的区别
Volatile本质是在告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住直到该线程完成变量操作为止
Volatile仅能使用在变量级别;synchronized则可以使用在变量、方法和类级别
Volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量修改的可见性和原子性
Volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞
Volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化
CAS(compare and swap)
乐观锁和悲观锁
Synchronized这种独占锁属于悲观锁,悲观锁始终假定会发生并发冲突,因此会屏蔽一切可能会违反数据完整性的操作,除此之外还有乐观锁,它假设不会发生并发冲突,因此只在提交时检查是否违反数据完整性,如果提交失败则会重试
一种高效实现线程安全性的方法
Ø 支持原子更新操作,适用于计数器,序列发生器等场景
Ø 属于乐观锁机制,号称lock-free
Ø CAS操作失败时由开发者决定是继续尝试,还是执行别的操作
内存位置的值即为主内存里面的值。
CAS思想
包含三个操作数——内存位置(V)、预期原值(A)和新值(B)
不断失败重试,直到成功为止。
我们发现value++被拆分成如下的指令
得到值、操作加一、写回值
可以使用synchronized修饰符修饰解决原子性操作问题,但是效率不高。
我们可以使用AtomicInteger来解决。
每次从内存中读取数据然后将此数据和+1后的结果进行CAS操作,如果成功就返回结果,否则重试直到成功为止。
CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
CAS的原子性实际上是CPU实现
CAS多数情况下对开发者来说是透明的
Ø J.U.C的atomic包提供了常用的原子性数据类型以及引用、数组等相关原子类型和更新操作工具,是很多线程安全程序的首选
Ø Unsafe类虽然提供CAS服务,但因能够操纵任意内存地址读写而有隐患
Ø Java9以后,可以使用Variable Handle API来替代Unsafe
缺点
Ø 若循环时间长,则开销很大
Ø 只能保证一个共享变量的原子操作
Ø ABA问题 解决:AtomicStampedReference
ABA的意思是,A的值被改成B又被改成A了然后cas没办法感知它的变化,以为它一直是A。
Java线程池
利用Executors创建不同的线程池满足不同场景的需求
newFixedThreadPool(int nThreads)指定工作线程数量的线程池
newCachedThreadPool() 处理大量短时间工作任务的线程池,
(1) 试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;
(2) 如果线程闲置的时间超过阈值,则会被终止并移除缓存;
(3) 系统长时间闲置的时候,不会消耗什么资源
newSingleThreadExcutor()创建唯一的工作者线程来执行任务,如果线程异常结束,会有另一个线程取代它
newSingleThreadScheduledExecutor()与newScheduledThreadPool(int corePoolSize)定时或者周期性的工作调度,两者的区别在于单一工作线程还是多个线程
newWorkStealingPool()
内部会构建ForkJoinPool,利用working-stealing算法,并行地处理任务,不保证处理顺序
Fork/Join框架
Work-Stealing算法:某个线程从其他队列里窃取任务来执行
Ø 把大任务分割成若干个小任务并行执行,最终汇总每个小任务结果后得到大任务结果的框架
可能会出现有些线程的任务队列已经完成了,但其它线程的队列还有任务没有完成,主要会造成已完成任务的线程会被闲置,为了提高效率,已经完成任务的线程会从未完成任务线程的队列里面窃取任务。为了减少任务窃取线程和被窃取线程之间的竞争通常会使用双端队列。被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务线程永远从双端队列的尾部拿任务执行。
为什么要使用线程池
Ø 降低资源消耗
Ø 提高线程的可管理性
Executor的框架
J.U.C的三个Executor接口
Ø Executor:运行新任务的简单接口,将任务提交和任务操作细节解耦
Ø ExecutorService:具备管理执行器和任务生命周期的方法,提交任务机制更完善
Ø ScheduledExecutorService:支持Future和定期执行任务
ThreadPoolExecutor的构造函数
Ø corePoolSize:核心线程数量
Ø maximumPoolSize:线程不够用时能够创建的最大线程数
Ø workQueue:任务等待队列
Ø keepAliveTime:抢占的顺序不一定,看运气
Ø threadFactory:创建新线程,Executors.defaultThreadFactory()
ThreadPoolEecutor
Handler:线程池的饱和策略
Ø AbortPolicy:直接抛出异常,这是默认策略
Ø CallerRunsPolicy:用调用者所在的线程来执行任务
Ø DiscardOldestPolicy:丢弃队列中靠最前的任务,并执行当前任务
Ø DiscardPolicy:直接丢弃任务
Ø 实现RejectedExecutionHandler接口的自定义handler
新任务提交execute执行后的判断
Ø 如果运行的线程少于corePoolSize,则创建新线程来处理任务,即使线程池中的其他线程是空闲的;
Ø 如果线程池中的线程数量大于等于corePoolSize且小于maximumPoolSize,则只有当workQueue满时才创建新的线程去处理任务;
Ø 如果设置的corePoolSize和maximumPoolSize相同,则创建的线程池的大小是固定的,这时如果有新任务提交,若workQueue未满,则将请求放入workQueue中,等待有空闲的线程去从workQueue中取任务并处理;
Ø 如果运行的线程数量大于等于maximumPoolSize,这时如果workQueue已经满了,则通过handler所指定的策略来处理任务;
Ctl:状态值和有效线程数。
高3位run state
剩下29位保存work account
线程池的状态
Ø RUNNING:能接受新提交的任务,并且也能处理阻塞队列中的任务
Ø SHUTDOWN:不再接受新提交的任务,但可以处理存量任务
Ø STOP:不再接受新提交的任务,也不处理存量任务
Ø TIDYING:所有的任务都已终止
Ø TERMINATED:terminated()方法执行完后进入该状态
工作线程的生命周期
线程池的大小如何选定
Ø CPU密集型:线程数=按照核数或者核数+1设定
Ø I/O密集型:线程数=CPU核数*(1+平均等待时间/平均工作时间)