dcddc

西米大人的博客

0%

系统学习JAVA线程

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
2
3
4
if (!vector.contains(element)) 
vector.add(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
2
3
4
5
6
7
8
9
10
11
12
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}

public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;
}

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
2
3
4
5
6
7
private static final int NEW          = 0; //任务新建和执行中
private static final int COMPLETING = 1; //瞬态,任务已经执行完毕或抛异常了,出参赋值给outcome前的状态
private static final int NORMAL = 2; //终态,任务正常执行结束,出参赋值给outcome后的状态
private static final int EXCEPTIONAL = 3; //终态,任务执行异常,throwable赋值给outcome后的状态
private static final int CANCELLED = 4; //终态,任务从NEW到CANCELED,如果任务还没执行,则不会执行。如果正在执行,cancel方法没效果,只是状态变为canceled
private static final int INTERRUPTING = 5; //瞬态,调用cancel方法且需要中断任务时,在调用任务线程的interupt中断方法前的状态
private static final int INTERRUPTED = 6; //终态,已调用任务线程的interupt中断方法

重点说下对cancel的理解:

  • 异步任务还没执行或还没执行完,状态为NEW,此时可取消,返回true,future.get方法抛任务取消异常。否则如果执行完了或者执行异常了或者之前已经取消了,则不能取消,返回false
  • 如果任务还没执行,则取消生效,任务不会执行
  • 如果任务在执行中
    • cancel方法不设置中断线程,则对任务没有任何效果,任务状态改为Canceled
    • cancel方法设置中断线程,则调用thread.interupt做线程中断,任务状态最终为INTERUPTED

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class FutureDemo {
public static void main(String[] args) throws InterruptedException {

//ThreadPoolExecutor executorService = (ThreadPoolExecutor) Executors.newFixedThreadPool(1);

ThreadPoolExecutor executorService = new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
// 预创建线程
executorService.prestartCoreThread();

Future future = executorService.submit(new Callable<Object>() {
@Override
public Object call() {
System.out.println("start to run callable");
Long start = System.currentTimeMillis();
while (!Thread.currentThread().isInterrupted()) {
Long current = System.currentTimeMillis();
if ((current - start) > 5000) {
System.out.println("当前任务执行已经超过5s");
return 1;
}
}
System.out.println("当前任务已被中断");
return -1;
}
});

// 保证异步任务已经在执行了
Thread.sleep(1000);

/**
* future.cancel方法说明:
* 异步任务还没执行或还没执行完,状态为NEW,此时可取消,返回true,否则如果执行完了或者执行异常了或者之前已经取消了,则不能取消,返回false
* 如果任务还没执行,则取消生效,任务不会执行
* 如果任务在执行中
* cancel方法不设置中断线程,则对任务没有任何效果
* cancel方法设置中断线程,则调用thread.interupt做线程中断,任务状态最终为INTERUPTED
*/
System.out.println(future.cancel(true));

try {
/**
* 上面future.cancel返回true,但任务已经在执行中了且没有中断,实际不影响任务执行
* 不过Future(实际为FutureTask)的状态已经改为Canceled,get方法抛任务取消异常
* 所以只要任务被取消成功,就无法通过future.get拿到结果
*/
System.out.println(future.get());
Thread.sleep(500);

} catch (Exception e) {
//NO OP
e.printStackTrace();
} finally {
executorService.shutdown();
}
}
}