Java并发编程--基本概念
摘要
-
本文介绍java并发编程相关技术
-
本文基于
jdk1.8
-
并发编程是为了解决什么问题的?
性能+线程安全
Java并发编程
并发与并行
-
并发
Concurrent
:指应用能够交替执行不同的任务,比如单 CPU 核心下执行多线程并非是同时执行多个任务,如果你开两个线程执行,就是在你几乎不可能察觉到的速度不断去切换这两个任务,已达到"同时执行效果",其实并不是的,只是计算机的速度太快,我们无法察觉到而已。 -
并行
Parallel
:指应用能够同时在多个CPU核心下执行不同的任务,例:吃饭的时候可以边吃饭边打电话,这两件事情可以同时执行 -
两者区别:一个是单核交替执行,一个是多核同时执行。
线程的状态/生命周期
Java 中线程的状态分为 6 种:
-
初始(
NEW
):新创建了一个线程对象,但还没有调用 start()方法。 -
运行(
RUNNABLE
):Java 线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 CPU 的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得 CPU 时间片后变为运行中状态(running)。 -
阻塞(
BLOCKED
):表示线程阻塞于锁。 -
等待(
WAITING
):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。 -
超时等待(
TIMED_WAITING
):该状态不同于 WAITING,它可以在指定的时间后自行返回。 -
终止(
TERMINATED
):表示该线程已经执行完毕。
Thread与Runnable
-
Thread 才是 Java 里对线程的唯一抽象,Runnable 只是对任务(业务逻辑)的抽象。Thread 可以接受任意一个 Runnable 的实例并执行。
-
官方说法是在 Java 中有两种方式创建一个线程用以执行,一种是派生自Thread 类,另一种是实现 Runnable 接口。当然本质上 Java 中实现线程只有一种方式,都是通过 new Thread()创建线程对象,调用 Thread#start 启动线程。至于基于 callable 接口的方式,因为最终是要把实现了 callable 接口的对象通过 FutureTask 包装成 Runnable,再交给 Thread 去执行,所以这个其实可以和实现 Runnable 接口看成同一类。
-
java的多线程最终是交由操作系统来维护和调度的。只有调用Thread对象的start方法才能启动一个线程,start方法不能多次调用,重复调用会抛出异常。Thread 的 start 方法中调用了 start0()方法,而start0()是个 native 方法,这就说明Thread#start 一定和操作系统是密切相关的。
-
创建并启动线程
- 扩展自Thread类
1
2UseThread useThread = new UseThread();
useThread.start();- 实现Runnable接口
1
2UseRunnable useRunnable = new UseRunnable();
new Thread(useRunnable).start(); -
static方法
Thread.currentThread()
:获取当前线程对象Thread.sleep(10)
:休眠10毫秒Thread.activeCount()
:获取当前活动线程的数量,活动线程是指尚未终止的线程,包括正在运行、等待或阻塞的线程。包括守护线程和非守护线程Thread.yield()
: 暂停当前线程,给其他线程执行的机会1.具体作用如下:提示调度器当前线程愿意放弃当前的 CPU 执行时间片,给其他具有相同优先级的线程执行的机会。不保证一定能让其他线程执行,只是给其他线程执行的机会更大。通常情况下,操作系统的线程调度器会按照一定的算法分配 CPU 时间给各个线程,而 yield() 方法可以用于向调度器发出提示,表明当前线程愿意让出 CPU 时间,让其他线程有更多的机会执行。
2.当一个线程调用 yield() 方法后,它会进入就绪状态,让出当前的 CPU 时间片,并允许其他线程有更大的机会获得 CPU 时间。然后,调度器会在众多就绪状态的线程中选择一个线程来运行,但选择哪个线程运行是由调度器决定的,可能会选择当前线程继续执行,也可能选择其他线程执行。所以,调用 yield() 方法后,当前线程可能会被立即重新调度并继续执行,也可能在稍后的时间被调度器重新选中并继续执行,也可能在一段时间内都没有被重新调度。Thread.interrupted()
: 用于检查当前线程是否被中断,并返回一个布尔值。调用interrupted()
方法会清除当前线程的中断状态。线程是中断状态时,则只有第一次调用interrupted()
方法会返回 true。而Thread对象的isInterrupted()
方法不会清除中断状态。
-
守护线程:主线程结束,则守护线程立即停止
1 | // 创建并启动线程: |
-
join()
: 将指定线程运行完成后再运行后面的代码
1 | //创建并启动线程: |
-
setPriority()
:设置线程优先级1.优先级的范围从 1~10,其中1表示最低优先级,10表示最高优先级。默认优先级是 5,优先级高的线程分配时间片的数量要多于优先级低的线程。设置线程优先级时,针对频繁阻塞(休眠或者 I/O 操作)的线程需要设置较高优先级,而偏重计算(需要较多 CPU 时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。需要注意的是,线程优先级只是给操作系统提供一个建议,操作系统不保证严格按照优先级来调度线程。在不同的 JVM 以及操作系统上,线程规划会存在差异,有些操作系统甚至会忽略对线程优先级的设定。慎重使用。
2.Java 线程调度是抢占式调度的,而且 Java 中的线程优先级是通过映射到操作系统的原生线程上实现的,所以线程的调度最终取决于操作系统,操作系统中线程的优先级有时并不能和 Java 中的一一对应,所以Java 优先级并不是特别靠谱。 -
中断线程:
1 | //创建并启动线程: |
死锁(Deadlock)
-
死锁(Deadlock)是指两个或多个线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,彼此互相等待对方释放资源,导致程序无法继续执行的状态。
-
死锁通常发生在多个线程同时持有多个共享资源,并试图获取对方持有的资源时。
-
死锁的发生必须具备以下四个必要条件:
- 1)互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。
- 2)请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
- 3)不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
- 4)环路等待条件:指在发生死锁时,必然存在一个进程–资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的 P0 正在等待一个 P1 占用的资源;P1正在等待 P2 占用的资源,……,Pn 正在等待已被 P0 占用的资源。
-
只要打破四个必要条件之一就能有效预防死锁的发生
- 打破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造。
- 打破不可抢占条件:当一进程占有一独占性资源后又申请一独占性资源而无法满足,则退出原占有的资源。
- 打破占有且申请条件:采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请。
- 打破循环等待条件:实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。
-
死锁的危害
- 1、线程不工作了,但是整个程序还是活着的
- 2、没有任何的异常信息可以供我们检查。
- 3、一旦程序发生了发生了死锁,是没有任何的办法恢复的,只能重启程序,对生产平台的程序来说,这是个很严重的问题。
-
如何查看运行的程序是否有死锁线程
- 通过 jps 查询应用的 id,再通过 jstack id 查看应用的锁的持有情况,进程状态为 BLOCKED 表示死锁
- jdk1.8以后,jstack 专门给出了死锁的检查,一般在显示信息的最下方展示是否发现死锁信息
-
如何避免死锁
- 1、内部通过顺序比较,确定拿锁的顺序;
- 2、采用尝试拿锁的机制。
尝试拿锁会存在
活锁
的问题,即多个线程在尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到,而将本来已经持有的锁释放的过程。
解决办法:每个线程休眠随机数,错开拿锁的时间。
synchronized
:可以作用在方法或代码块上
-
方法: 当前对象锁,等价于
synchronized (this)
1 | public synchronized void incCountMethod(){ |
-
方法: 当前类锁,等价于
synchronized (Object.class)
1 | public static synchronized void incCountMethod(){ |
-
代码块:
1 | //任意对象锁 |
wait
与notify
/notifyAll
:等待唤醒机制
-
一个线程:
synchronized (pool);
while(某种条件)
线程等待:pool.wait();
另一个线程:synchronized (pool);
通知其他等待的线程:pool.notifyAll();
-
synchronized
作用在方法上时,就是当前对象,直接在方法内使用wait();
-
wait()
和wait(long timeout)
都是用于线程间进行协作和同步的方法,用于在对象上进行等待。wait()
方法是没有超时参数的形式,它使当前线程进入等待状态,直到其他线程调用相同对象上的notify()
或notifyAll()
方法来唤醒等待的线程,或者当前线程被中断(InterruptedException
)。wait(long timeout)
方法是带有超时参数的形式,它使当前线程进入等待状态,但最多等待指定的时间(以毫秒为单位)。如果超过指定时间还未被唤醒,线程将自动唤醒并继续执行。这个方法可以防止线程永久地等待下去,即使没有其他线程调用相同对象上的notify()
或notifyAll()
方法。因此,wait()
方法是一直等待直到被唤醒或中断,而wait(long timeout)
方法是等待一段时间后自动唤醒,或者在被唤醒之前超过了指定的等待时间。需要注意的是,这两个方法必须在同步代码块(synchronized
)内部调用,并且在调用这些方法前,线程必须拥有对象的监视器(即获取了对象的锁)。否则,将会抛出IllegalMonitorStateException
异常。 -
尽可能用
notifyall()
,谨慎使用notify()
,因为notify()
只会唤醒一个线程,我们无法确保被唤醒的这个线程一定就是我们需要唤醒的线程 -
yield()
、sleep()
被调用后,都不会释放当前线程所持有的锁。调用wait()
方法后,会释放当前线程持有的锁,而且当前被唤醒后,会重新去竞争锁,锁竞争到后才会执行wait()
方法后面的代码。调用notify()
系列方法后,对锁无影响,线程只有在synchronized
同步代码执行完后才会自然而然的释放锁,所以notify()
系列方法一般都是synchronized
同步代码的最后一行。 -
为什么你应该在循环中检查等待条件?
处于等待状态的线程可能会收到错误警报和伪唤醒(被唤醒时不一定满足等待条件),如果不在循环中检查等待条件,程序就会在没有满足结束条件的情况下退出。因此,当一个等待线程醒来时,不能认为它原来的等待状态仍然是有效的,在
notify()
方法调用之后和等待线程醒来之前这段时间它可能会改变。这就是在循环中使用wait()
方法效果更好的原因。
小贴士
Java中的等待唤醒机制有哪些?
- synchronized + wait/notify/notifyAll
- ReentrantLock + Condition(await/singal/singalAll)
- CAS + park/unpark(CAS是LockSupport底层实现机制)
Callable<T>
、Future<T>
与FutureTask<T>
:
-
Callable
位于java.util.concurrent
包下,它也是一个接口,在它里面也只声明了一个方法,只不过这个方法叫做call()
,这是一个泛型接口,call()
函数返回的类型就是传递进来的T
类型。 -
FutureTask
类实现了RunnableFuture
接口,RunnableFuture
继承了Runnable
接口和Future
接口,而FutureTask
实现了RunnableFuture
接口。所以它既可以作为Runnable
被线程执行,又可以作为Future
得到Callable
的返回值。 -
自定义
Callable<T>
的实现类,其有返回值,比如:public class UseCallable implements Callable<Integer>
-
创建
FutureTask
:FutureTask<Integer> futureTask = new FutureTask<>(new UseCallable());
-
启动线程:
new Thread(futureTask).start();
-
获取返回值:
futureTask.get();
-
中断线程:
futureTask.cancel(true);
volatile:最轻量的通信/同步机制,保证变量在多个线程间的可见性,即值被一个线程修改,其它线程立刻可见
1 | private static volatile boolean ready; |
-
volatile
不能保证数据在多个线程下同时写时的线程安全 -
volatile
最适用的场景:一个线程写,多个线程读。
ThreadLocal :保证线程变量独享
1 | ThreadLocal<String> threadLocal = new ThreadLocal<>(); |
-
初始化方法
1 | //方式1 |
-
为了避免内存泄露,ThreadLocal变量用完后要进行销毁:
threadLocal.remove();
CAS
-
CAS 是
Compare and Swap
(比较并交换)的缩写,是一种常见的并发编程技术,也是原子类实现线程安全的基础操作。CAS 操作包括三个参数:一个内存位置(通常是一个变量的内存地址)、期望的值和新值。CAS 操作会先比较内存位置的当前值与期望的值是否相等,如果相等,则将内存位置的值替换为新值,否则不进行任何操作。ßCAS 操作是原子的,即在进行比较和交换的过程中不会被其他线程干扰。 -
CAS 实现原子操作的三大问题
- ABA 问题
下文有对ABA问题的介绍
- 循环时间长开销大
自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。
- 只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。
从 Java 1.5开始,JDK 提供了AtomicReference
类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行 CAS 操作。
- ABA 问题
原子操作类:
-
Integer:AtomicInteger,AtomicIntegerArray
1 | AtomicInteger ai = new AtomicInteger(10); |
-
Long:
AtomicLong
,AtomicLongArray
-
Long:
LongAdder
,加、减、求和等操作性能高于AtomicLong
小贴士
AtomicLong 是利用了底层的 CAS 操作来提供并发性的,调用了 Unsafe 类的getAndAddLong 方法,该方法是个 native 方法,它的逻辑是采用自旋的方式不断更新目标值,直到更新成功。
在并发量较低的环境下,线程冲突的概率比较小,自旋的次数不会很多。但是,高并发环境下,N 个线程同时进行自旋操作,会出现大量失败并不断自旋的情况,此时 AtomicLong 的自旋会成为瓶颈。
这就是 LongAdder 引入的初衷——解决高并发环境下 AtomicLong 的自旋瓶颈问题。
AtomicLong 中有个内部变量 value 保存着实际的 long 值,所有的操作都是针对该变量进行。也就是说,高并发环境下,value 变量其实是一个热点,也就是 N 个线程竞争一个热点。
LongAdder 的基本思路就是分散热点,将 value 值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行 CAS 操作,这样热点就被分散了,冲突的概率就小很多。
如果要获取真正的 long 值,只要将各个槽中的变量值累加返回。
这种做法和 ConcurrentHashMap 中的“分段锁”其实就是类似的思路。
LongAdder 提供的 API 和 AtomicLong 比较接近,两者都能以原子的方式对long 型变量进行增减。
但是 AtomicLong 提供的功能其实更丰富,尤其是 addAndGet、decrementAndGet、compareAndSet 这些方法。
addAndGet、decrementAndGet 除了单纯的做自增自减外,还可以立即获取增减后的值,而 LongAdder 则需要做同步控制才能精确获取增减后的值。如果业务需求需要精确的控制计数,做计数比较,AtomicLong 也更合适。
另外,从空间方面考虑,LongAdder 其实是一种“空间换时间”的思想,从这一点来讲 AtomicLong 更适合。
总之,低并发、一般的业务场景下 AtomicLong 是足够了。如果并发量很多,存在大量写多读少的情况,那 LongAdder 可能更合适。
-
Double:
DoubleAdder
-
对象:
AtomicReference
,AtomicReferenceArray
-
对象:
AtomicStampedReference
:引入版本号,用于解决ABA问题
小贴士
如果你想比较AtomicReference中存储的User对象,你需要确保User类正确实现了适当的equals()和hashCode()方法。
在Java中,对象的比较通常是通过equals()方法来实现的。equals()方法用于判断两个对象是否相等。当你使用AtomicReference进行比较时,它将使用equals()方法来比较存储在AtomicReference中的对象。
默认情况下,Object类的equals()方法比较的是对象的引用,而不是内容。因此,如果你想在AtomicReference中比较User对象的内容而不是引用,你需要在User类中重写equals()方法来进行内容比较。
1 | public class User { |
在上述示例中,我们重写了equals()方法以比较User对象的name和age属性。我们使用了自动生成的hashCode()方法来生成哈希码,以保证在使用哈希表等数据结构时的正确性。
重写了equals()和hashCode()方法后,你可以使用AtomicReference
1 | AtomicReference<User> atomicReference = new AtomicReference<>(new User("Alice", 25)); |
而AtomicStampedReference是为了解决ABA问题而设计的,并提供了对对象引用的比较以及对标记(stamp)的比较。
AtomicStampedReference通过引入一个标记(stamp)来解决ABA问题。它不仅比较对象引用,还比较对象的标记值。当对象和标记值都相等时,才认为对象相等。
ABA
-
ABA 问题指的是在并发环境下,某个线程对一个值进行比较并操作时,可能出现以下情况:
-
初始状态下,值为 A。
-
线程 1 将值从 A 修改为 B。
-
线程 1 又将值从 B 修改回 A。
-
线程 2 检查值,发现值仍然是 A,于是做出操作。
从线程 2 的角度来看,值似乎没有被修改过,但实际上经历了从 A 到 B 再到 A 的变化。这种情况可能会导致意外的结果或错误的判断。
ABA 问题的发生是因为 CAS 操作只关注当前值和期望值是否相等,而不考虑期间发生的其他变化。
在上述示例中,CAS 操作在进行比较时,发现当前值仍然是 A,与期望值相等,因此会执行操作,但它无法感知到值的中间变化。
ABA 问题可能会对某些并发算法和数据结构产生影响,例如自旋锁、无锁算法等。
为了解决 ABA 问题,可以使用一种称为 “版本号” 的技术,每次修改值时都会增加一个版本号,这样在进行 CAS 操作时除了比较值本身,还会比较版本号,从而避免了 ABA 问题的发生。
在Java中,AtomicStampedReference
就是通过引入版本号(标记)来解决ABA问题的一种原子类。
重排序
-
在Java中,重排序是指编译器和处理器为了提高程序性能而对指令执行顺序进行重新排序的优化技术。重排序可以改变程序中指令的执行顺序,但不会改变程序的最终结果(即保持串行语义),因为这些重排序是在保持依赖关系的前提下进行的。
-
然而,重排序可能会导致并发安全问题。并发安全问题主要涉及到多线程的执行顺序和对共享数据的访问。
-
考虑以下示例代码:
1 | class Singleton { |
-
上述代码是一个经典的基于双重检查锁定的懒汉式单例模式。在单线程环境下,这段代码是没有问题的。但是在多线程环境下,由于重排序的存在会导致线程安全问题,为什么呢?
-
这是因为
instance = new Singleton();
虽然只有一行代码,但是其实在具体执行的时候有好几步操作:- 1、JVM 为
Singleton
的对象实例在内存中分配空间 - 2、进行对象初始化,完成
new
操作 - 3、JVM 把这个空间的地址赋给我们的引用
instance
- 1、JVM 为
-
因为 JVM 内部的实现原理会导致重排序,就会产生一种情况,第 3 步会在第 2 步之前执行。
-
于是在多线程下就会产生问题:
- A 线程正在 syn 同步块中执行
instance = new Singleton();
, - 此时 B 线程也来执行
getInstance()
,进行了instance == null
的检查, - 因为第 3 步会在第 2 步之前执行,B 线程检查发现
instance
不为null
,会直接拿着instance
实例使用, - 但是这时 A 线程还在执行对象初始化,这就导致 B 线程拿到的
instance
实例可能只初始化了一半,B 线程访问instance
实例中的对象域就很有可能出错。
- A 线程正在 syn 同步块中执行
-
这些问题是由于编译器和处理器进行的重排序导致的,并发安全问题不会在单线程环境下出现,只有在多线程环境下才会显现。
-
另外,即使在同步块内部没有发生重排序,当一个线程在初始化实例时,由于处理器和内存之间的交互延迟,也可能存在可见性问题。
-
为了解决这些问题,可以使用
volatile
关键字来修饰instance
,volatile
会禁止编译器和处理器进行重排序,同时使用volatile
修饰的变量时,会先清除当前线程的本地缓存再从主内存中重新加载数据,以确保可见性:
1 | private volatile static Singleton instance; |
管程与MESA模型
-
管程(Monitor): 是一种并发编程的概念模型,旨在解决多线程程序中的互斥访问和同步问题。它提供了一种结构化的方式来管理共享资源,并确保线程在访问共享资源时的安全性。
-
MESA(Meta-Environment for Scheduling Agents)模型: 是现在正在广泛使用的管程模型。
AQS
-
java.util.concurrent
包中的大多数同步器实现都是围绕着共同的基础行为,比如等待队列、条件队列、独占获取、共享获取等,而这些行为的抽象就是基于AbstractQueuedSynchronizer
(简称AQS
)实现的,AQS
是一个抽象同步框架,可以用来实现一个依赖状态的同步器。 -
JDK中提供的大多数的同步器如
Lock
,Latch
,Barrier
等,都是基于AQS框架来实现的。
-
基于AQS构建的
ReentrantLock
和CountDownLatch
等同步类就是借鉴了MESA模型中的概念和技术,如互斥锁、条件变量等,以提供线程间的同步和互斥功能。
JMM
-
Java线程之间的通信由Java内存模型(Java Memory Model,简称JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。
并发三大特性
原子性
-
一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。
-
在 Java中,对基本数据类型的变量的读取和赋值操作是原子性操作(64位处理器),自增/自减操作并不是原子性的。
-
如何保证原子性:
- 通过
synchronized
关键字保证原子性 - 通过
Lock
锁保证原子性 - 通过
CAS
保证原子性
- 通过
可见性
-
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
-
如何保证可见性:
- 通过
volatile
关键字保证可见性 - 通过
内存屏障
保证可见性 - 通过
synchronized
关键字保证可见性 - 通过
Lock
锁保证可见性
- 通过
-
Java中可见性底层有两种实现:
-
- 内存屏障,以下实现都是基于内存屏障
- synchronized
- Threed.sleep(10)
- volatile
-
- cup上下文切换
- Threed.yield()
- Threed.sleep(0)
-
-
synchronized关键字的作用
- 是确保多个线程访问共享资源时的互斥性和可见性
-
锁的内存语义
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。
- 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
-
volatile内存语义
- 写:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
- 读:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。
有序性
-
即程序执行的顺序按照代码的先后顺序执行。
-
为了提升性能,编译器和处理器常常会对指令做重排序,所以存在有序性问题。
-
如何保证有序性:
- 通过
volatile
关键字保证有序性 - 通过
内存屏障
保证有序性 - 通过
synchronized
关键字保证有序性 - 通过
Lock
锁保证有序性
- 通过
总结
-
1.保证了可见性就保证了有序性
-
2.
volatile
并不能保证原子性,但可以保证多线程操作共享变量的可见性以及禁止指令重排序 -
3.
synchronized
关键字不仅保证可见性,同时也保证了原子性(互斥性) -
4.JMM通过
内存屏障
来实现内存的可见性以及禁止重排序