JAVA线程
知识点
JAVA线程和操作系统的内核线程的映射关系?
- 一一映射
线程的调度方式有哪两种?JAVA使用哪种?
- 协同式和抢占式。Java使用抢占式,由操作系统调度。
为什么JAVA的线程优先级在不同操作系统下的表现不同?
- JAVA定义的线程优先级需要映射到操作系统内核线程优先级,因此优先级表现与操作系统相关联
JAVA线程有哪五种状态?状态转移关系是?
- New、Runnable、Waiting、TimedWaiting、Block
共享对象按线程安全等级可划分为哪几类?
- 不可变、绝对线程安全、相对线程安全、线程兼容(线程不安全)
哪些类型声明为不可变final类型就可以保证一定线程安全?
- 基本数据类型、枚举类、String
什么是绝对线程安全?什么是相对线程安全?
- 绝对线程安全的对象,做任何操作都需要保证是线程安全的,包括复合操作,所以绝对安全的类很少,String是绝对线程安全的
- 相对线程安全的对象,调用任何方法都需要保证是线程安全的,但复合操作不能保证线程安全。我们一般使用的同步集合类都属于相对线程安全的,例如Vector、HashTable、ConcurrentHashMap
为什么Vector类是相对线程安全,而非绝对线程安全?
- 复合操作无法保证线程安全。例如常见的putIfAbsent操作,判断absent和put操作都是线程安全的,但作为一个复合操作并不能保证线程安全,可能判断absent成功后线程切换,其他线程添加了这个对象。
JAVA实现互斥同步的方式有哪些?
- synchronised、ReentrantLock
为什么说Synchronized同步方式是一个重量级操作?
- 通过操作系统的互斥量实现互斥同步,线程阻塞和唤醒需要在线程用户态和内核态之间切换,成本高
ReentrantLock和Synchronized的区别有哪些?
- ReentrantLock阻塞可被中断
- ReentrantLock支持同时存在多个wait/signal,通过condition实现,更灵活地唤醒阻塞在某个condition条件下的线程
- ReentrantLock支持公平锁
JAVA中非阻塞同步(乐观策略)的典型实现方式是什么?J
- CAS
什么是CAS操作的”ABA问题”?
- CAS虽然成功,但可能Compare的时候对象的值是A,但在Set前,经历了从A到B再回到A的过程,因此虽然CAS成功,但过程中对象仍可能被并发修改
什么是自旋?什么是自适应自旋?
- 线程获取不到锁时,不挂起,而是跑一定次数的空循环,期间依然不断尝试获取锁。在锁很快得到释放时,这么做能避免线程阻塞唤醒带来的开销
- 基于之前自旋后获取锁的情况,动态调整自旋时间
什么是锁消除?什么是锁粗化?
- 不发生线程逃逸的代码,可以消除加锁操作,称为锁消除。连续执行的代码频繁加解锁,可以在外层只加一次锁,称为锁粗化
轻量级锁的加解锁过程是怎样的?什么情况下轻量级锁会升级为重量级锁?
- 加锁:先根据锁对象的MarkWord判断是否未被加锁,是则把MarkWord复制到栈帧上,再CAS将加锁对象的MarkWord区域指向栈帧上存储MarkWord的地址,成功则加锁成功。否则说明被其他线程抢占了,升为重量级锁,锁对象的MarkWord指向互斥量
- 解锁:CAS将栈空间的MarkWord写回到锁对象,如果失败,说明已经升级到重量级锁,需要唤醒被阻塞的线程
轻量级锁相比重量级锁(互斥量)的优势是?什么场景下更适合使用轻量级锁?
- 轻量级锁使用CAS避免了使用互斥量的开销,在加解锁期间如果不存在并发竞争锁对象,就不会升级为重量级锁,减少了使用互斥量的性能开销
- 锁竞争不激烈,线程间交替持有锁时,适合轻量级锁
偏向锁的工作原理是?适合什么场景?
- MarkWord区域指向持有锁的线程id,这样同一线程下次获取锁时,无需额外操作
- 只有同一个线程尝试获取锁,适合用偏向锁
偏向锁、轻量级锁、重量级锁之间的升降级流转过程是?
- 如果MarkWord处于无锁状态且偏向锁可用,则尝试获取偏向锁,失败则升级为轻量级锁
- 轻量级锁解锁成功,恢复到无锁状态,偏向锁不可用。解锁失败,升级为重量级锁,唤醒阻塞线程
- 重量级锁解锁成功,恢复到无锁状态,偏向锁不可用
线程模型
JAVA的线程是纯native实现的,因此完全依赖底层操作系统。JAVA线程最终会映射到操作系统的线程模型上
操作系统的线程模型有以下三类:
- 一对一线程模型。程序里创建的线程实际是内核线程封装的轻量级进程,程序线程与
内核线程
是一一映射的 - 一对多线程模型。程序里创建的线程与系统内核无关,是纯用户态的线程(
用户线程
)。一个内核线程映射多个用户线程
JAVA在Windows和Linux操作系统上,使用一对一线程模型,即JAVA线程会映射到内核线程
线程调度
线程调度有两种方式
- 协同式:线程控制型。线程执行完后通知操作系统进行线程切换。缺点是一旦线程执行时间过长,会阻塞其他的线程
- 抢占式:操作系统控制型。操作系统来调度线程的执行。JAVA使用这种方式
- JAVA支持设置线程优先级来影响线程调度,但因为JAVA线程与内核线程一一映射,而内核线程的优先级与对应的操作系统有关,因此JAVA定义的线程优先级需要映射到操作系统内核线程优先级。所以在不同操作系统中,JAVA线程优先级表现不同,不能完全依赖设置的优先级
线程状态
JAVA定义了五种线程状态
- 新建(New):线程创建且未启动前
- 运行(Runable):Ready和Running都对应这种状态
- 无限期等待(Waiting):线程执行时主动调用监视器对象的无参wait方法。需要被其他线程显式notify唤醒
- 限期等待(TimedWaiting):线程执行时主动调用sleep和wait带参方法,会让出执行权。但会在指定时间后回到Runable状态
- sleep和join不会释放锁,wait会释放锁
- 阻塞(Blocked):线程执行时遇到同步代码且未获得排它锁,则被动让出执行权。获得对象锁后回到Runable状态
wait & notify
wait和notify的总结如下:
- wait方法会使当前线程进入自己所持有的监视器锁(this object)的等待队列中, 并且放弃一切已经拥有的(这个监视器锁上的)同步资源, 然后挂起当前线程, 直到以下四个条件之一发生:
- 其他线程调用了this object的notify方法, 并且当前线程恰好是被选中来唤醒的那一个
- 其他线程调用了this object的notifyAll方法
- 其他线程中断了当前线程
- 指定的超时时间到了.(如果指定的超时时间是0, 则该线程会一直等待, 直到收到其他线程的通知)
- 当以上4个条件之一满足后, 该线程从wait set中移除, 重新参与到线程调度中, 并且和其他线程一样, 竞争锁资源, 一旦它又获得了监视器锁, 则它在调用wait方法时的所有状态都会被恢复, 这里要注意“假唤醒”的问题
- 如果用if判断唤醒条件,则notifyAll唤醒后可能产生虚假唤醒。例如生产消费模型,生产者notifyAll唤醒所有消费者,可能出现超额消费(超卖)。最好的解法是使用while判断唤醒条件,因为if判断唤醒条件,唤醒后不会再次校验唤醒条件,可能这时又不满足唤醒条件了,即发生了虚假唤醒。see:https://www.cnblogs.com/jichi/p/12694260.html
- 当前线程在进入wait set之前或者在wait set之中时, 如果被其他线程中断了, 则会抛出InterruptedException异常, 但是, 如果是在恢复现场的过程中被中断了, 则直到现场恢复完成后才会抛出InterruptedException
- 即使wait方法把当前线程放入this object的wait set里, 也只会释放当前监视器锁(this object), 如果当前线程还持有了其他同步资源, 则即使它在this object中的等待队列中, 也不会释放
- notify方法会在所有等待监视器锁的线程中任意选一个唤醒, 具体唤醒哪一个, 交由该方法的实现者自己决定.
- 线程调用notify方法后不会立即释放监视器锁,只有退出同步代码块后,才会释放锁(与之相对,调用wait方法会立即释放监视器锁)
- 线程被notify或notifyAll唤醒后会继续和其他普通线程一样竞争锁资源
线程安全
线程安全的极简定义:
并发下对共享对象的操作都可以获得正确的结果
共享数据的五类线程安全等级
按共享数据的线程安全等级由强至弱排序,可以将共享数据分为五类:
不可变、绝对线程安全、相对线程安全、线程兼容、线程对立
不可变
不可变类型的共享数据,无需进行任何同步措施,一定是线程安全的
如果共享数据是基本数据类型,只要定义时使用final修饰就可以保证它不会变
如果共享数据类型是引用类型,需要保证对象的行为不会对其状态(属性)产生任何影响。常见的例子是String对象,String类任何的方法都不会影响原来的值,只会返回一个新构造的字符串对象
绝对线程安全
绝对线程安全类型的共享数据,要求调用方不需要任何同步措施,就能保证对共享数据的操作是线程安全的
有些声明为线程安全的类,不一定是绝对线程安全的
例如Vector类,虽然它的所有方法都声明为同步来保证线程安全,但对于复合操作
依然需要调用方使用同步措施保证线程安全。例如经典的put-if-absent
情况,尽管contains、add方法都正确地同步了,但复合操作时,if条件判断为真,那个用来访问vector.contains方法的锁已经释放,在即将的vector.add方法调用之间有间隙,在多线程环境中,完全有可能被其他线程获得vector的lock并改变其状态,此后vector.add继续,但此时它已经基于一个错误的假设了
1 | if (!vector.contains(element)) |
相对线程安全
相对线程安全类型的共享数据,需要保证对共享对象单独的操作是线程安全的。但对于一些复合操作,就可能需要调用方额外的同步措施保证调用的正确性。上面Vector例子就能印证。所以说Vector类是相对线程安全的。类似的还有HashTable和一些其他的同步集合类。
线程兼容
线程兼容类型的共享数据,需要调用端正确使用同步措施保证对象并发环境下安全的使用。我们平时说一个类不是线程安全的,基本都是这种情况。
线程对立
线程对立类型的共享数据,无论调用方是否采取同步措施,都无法在多线程环境下并发操作对象。
线程同步机制
互斥同步
同步指多线程并发访问共享数据时,同一时刻数据只被一个线程使用。互斥是实现同步的方式
JAVA中最常使用的互斥同步方式就是synchronized关键字。使用synchronized需要明确指明锁定和解锁的对象,可以在代码里明确声明,如果没有,根据synchronized修饰的是类方法还是实例方法,取对应的class对象或实例对象作为锁对象
Synchronized
synchronized关键字编译后会在同步块前后形成两条字节码指令:monitorenter和monitorexit。enter时需要获得对象的锁,如果对象没被锁定或当前线程已经持有对象锁,把锁的计数器+1。exit时把锁的计数器-1。如果线程获取对象锁失败,则阻塞等待
由此可知,synchronized同步块对线程来说是可重入的
synchronized是重量级的一个操作:JAVA的线程会映射到系统内核线程,synchronized同步会频繁阻塞和唤醒线程,这些操作都要依赖操作系统完成,因此要频繁地在用户态和内核态之间切换,影响性能
ReentrantLock
和synchronized类似,都是可重入锁。但也有区别:
- 阻塞可中断:阻塞在等待对象锁的线程可以中断来放弃等待,改为处理其他的事情
- 公平锁:多个线程按申请对象锁的时间顺序依次获得锁。ReentrantLock支持公平锁和非公平锁两种模式,synchronized的锁是非公平的,任何等待锁的线程都有机会获得锁
- 更灵活的等待通知模式:synchronized的锁对象执行wait/notify方法可以实现一个条件下的等待通知。ReentrandLock可以创建多个condition(监视器对象),每个condition都可以自由调用await/signal完成等待通知,从而有选择性地通知那些等待在特定condition的线程,线程调度更加灵活
性能上,在JDK1.6发布后(1.6对synchronized做了优化),二者基本持平
非阻塞同步
互斥同步是一种重量级的、悲观的并发策略
那什么是乐观的并发策略呢?通俗说,就是先操作,再检测有没有并发存在,如果没有就操作成功了,如果有,就采取补偿措施(最常见的就是不断重试直至成功)
这种乐观策略的许多实现方式,都不需要线程挂起,因此称为非阻塞同步
得益于指令集对原子操作指令的丰富,使得非阻塞同步能够实现
JAVA的Unsafe类里提供了compareAndSwapXXX方法,该方法会即时编译为一条CAS(compareAndSwap指令,当前值比较成功后与期望值交换,不成功返回当前值)原子操作指令。基于该方法,诞生了一些非阻塞同步的实现类,例如JUC包里的整数原子类,其中的compareAndSet和getAndIncrement方法都用到了CAS指令。以AtomInteger的自增接口为例,它通过volatile可见性机制直接读到主内存最新值,然后作为当前值,+1后的值作为期望值,调用CAS指令
1 | public final int getAndIncrement() { |
CAS看起来很适合非阻塞同步,但也有一个漏洞,即ABA问题
:变量在读取时是A值,CAS阶段也成功,即当前值仍然是A,但无法能保证在读取和CAS操作期间,变量从未改变过,可能从A到B,又回到A。解决这个问题可以使用版本号,但意义不大,因为通常情况下,ABA问题不会影响并发操作的正确性。如果影响,可以使用传统的互斥同步。
锁优化
锁优化是为了更高效地实现线程间对共享对象的竞争
自旋与自适应自旋
自旋锁的目标是降低线程切换的成本
自旋的概念:
- 通常线程对共享数据的锁定时间很短。在多核处理器中,可以让多个线程并行执行。所以在线程获取不到锁时,可以执行一个忙循环(自旋),看看锁是否会很快释放。自旋次数默认是10次,否则一直自旋得不到锁时,反而浪费硬件资源。
自适应自旋:
- 基于对锁的自旋等待结果,动态调整自旋次数,称为自适应自旋
锁消除
JVM在即时编译阶段,对一些代码要求同步,但被检测到不可能存在共享数据竞争的锁进行消除。检测的方法是逃逸分析技术,判断一段代码里堆上的数据是否会逃逸从而被其他线程访问(线程逃逸),如果不会,则可以进行锁消除
锁粗化
对同一个锁对象一连串零碎的加锁同步代码,会把加锁范围扩大到最外层,避免频繁的加解锁造成线程阻塞与唤醒带来的性能损耗
轻量级锁
轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等
首先介绍Hotspot虚拟机中对象头的内存布局
:
- 对象未被锁定时,一部分存储对象自身的运行时数据,包括哈希码、GC分代年龄、锁标志位等,官方称为
Mark Word
- MarkWord空间是被复用的,在对象被锁定时,存储的内容会不同
- 另一部分存储指向方法区对象类型的指针
轻量级锁的加锁过程:
代码进入同步块时,如果同步对象没有被锁定,JVM会在当前线程的栈帧中分配一部分空间,存储锁对象MarkWord的拷贝称为DisplacedMarkWord
,然后CAS将锁对象MarkWord的值改为指向这部分栈帧空间的指针。如果修改成功,这个线程就拥有了锁对象,锁标志位改为轻量级锁状态。如果修改失败,说明锁对象已经被其他线程抢占,锁对象要膨胀为重量级锁,MarkWord存储指向互斥量的指针,锁标志位改为重量级锁状态,等待锁的线程进入阻塞状态
轻量级锁的解锁过程:
CAS将栈帧中DisplacedMarkWord的值写回到锁对象的MarkWord区域。如果MarkWord值并未指向栈地址,说明已经升级为重量级锁,解锁还需要唤醒被阻塞线程。
综上,轻量级锁使用CAS避免了使用互斥量的开销,在加解锁期间如果不存在并发竞争锁对象,就不会升级为重量级锁,减少了使用互斥量的性能开销。但如果在并发激烈的场景,轻量级锁会比重量级锁慢,因为额外产生了CAS操作
偏向锁
偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。
“偏向”的意思是,偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在Mark Word中CAS记录owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。
偏向锁、轻量级锁、重量级锁的使用场景
偏向锁:
- 无实际竞争,且将来只有第一个申请锁的线程会使用锁。
轻量级锁:
- 无实际竞争,多个线程交替使用锁;允许短时间的锁竞争。
重量级锁:
- 有实际竞争,且锁竞争时间长。
不同锁升降级流转
没有任何线程尝试获取锁对象时,处于未锁定状态
锁对象被初始锁定后,处于偏向锁状态
其他线程尝试获取偏向锁时,如果锁对象正被持有,升级为轻量级锁,如果未被持有,降级为未锁定状态,但后续不可再使用偏向锁
轻量级锁定阶段,如果有其他线程尝试获取锁,升级为重量级锁
轻量级锁和重量级锁解锁后都降级到未锁定状态,重量级锁还需要唤醒阻塞线程
异步任务FutureTask
FutureTask表示一个异步任务,它实现了Runnable和Future接口,其中Future接口提供了获取任务结果、取消任务的能力。线程池ThreadPoolExecutor的submit方法会创建一个异步任务FutureTask并执行,然后返回FuturTask,外部通过调用它的get方法获取异步结算结果
FutureTask内部有一个volatile state,表示当前任务的执行状态,含义如下
1 | private static final int NEW = 0; //任务新建和执行中 |
重点说下对cancel的理解:
- 异步任务还没执行或还没执行完,状态为NEW,此时可取消,返回true,future.get方法抛任务取消异常。否则如果执行完了或者执行异常了或者之前已经取消了,则不能取消,返回false
- 如果任务还没执行,则取消生效,任务不会执行
- 如果任务在执行中
- cancel方法不设置中断线程,则对任务没有任何效果,任务状态改为Canceled
- cancel方法设置中断线程,则调用thread.interupt做线程中断,任务状态最终为INTERUPTED
源码:
1 | public class FutureDemo { |