文章目录
本系列文章:
Java并发编程学习之路(一)并发编程三要素、Thread、Runnable、interrupted、join、sleep、yield
Java并发编程学习之路(二)线程同步机制、synchronized、CAS、volatile、final、Lock、AQS
Java并发编程学习之路(三)ReentrantLock、ReentrantReadWriteLock、死锁、原子类
Java并发编程学习之路(四)线程池、FutureTask
Java并发编程学习之路(五)线程协作、wait/notify/notifyAll、Condition、await/signal/signalAll、生产者–消费者
Java并发编程学习之路(六)ThreadLocal、BlockingQueue、CopyOnWriteArrayList、ConcurrentHashmap
Java并发编程学习之路(七)CountDownLatch、CyclicBarrier、Semaphore、Exchanger、Phaser
Java并发编程学习之路(八)多线程编程例子
前言
计算机的组成
一个程序要运行,首先要被加载到内存,然后数据被运送到CPU的寄存器里。寄存器用来存储数据;PC为程序计数器,用来记录要执行的程序的位置;算术逻辑单元执行具体的计算,然后将结果再传送给内存。
CPU执行运算的大致过程:CPU读取指令,然后程序计数器存储程序的执行位置,然后从寄存器中读取原始数据,计算完成后,再将结果返回给内存,一直循环下去。
线程之间的调度由线程调度器负责,确定在某一时刻运行哪个线程。
线程上下文切换,简单来说,指的是CPU保存现场,执行新线程,恢复现场,继续执行原线程的一个过程。
一、并发编程理论基础
1.1 并发编程的优点
- 1、充分利用多核CPU的计算能力
通过并发编程的形式可以充分发挥多核CPU的计算能力。 - 2、方便进行业务拆分,提升应用性能
多线程并发编程是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
1.2 并发编程的缺点
- 1、频繁的上下文切换
时间片是CPU分配给各个线程的时间,因为时间非常短,所以CPU不断通过切换线程,达到一种"不同应用似乎是同时运行的错觉",时间片一般是几十毫秒。每次切换时,需要保存当前的状态起来,以便能够进行恢复先前状态,而这个切换时非常损耗性能,过于频繁反而无法发挥出多线程编程的优势。
通常减少上下文切换的方式有:
无锁并发编程
:类似ConcurrentHashMap锁分段的思想,不同的线程处理不同段的数据,这样在多线程竞争的条件下,可以减少上下文切换的时间。CAS算法
:利用Atomic下使用CAS算法来更新数据,使用了乐观锁,可以有效的减少一部分不必要的锁竞争带来的上下文切换。使用最少线程
:避免创建不需要的线程,比如任务很少,但是创建了很多的线程,这样会造成大量的线程都处于等待状态。协程
:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
- 2、产生线程安全问题
即死锁、线程饥饿等问题。
1.3 并行、并发、串行
- 并发:
多个任务在同一个 CPU 核上,按细分的时间片轮流执行
,从逻辑上来看任务是同时执行。 - 并行:单位时间内,
多个处理器或多核处理器同时处理多个任务
,是真正意义上的“同时进行”。 - 串行:有n个任务,由一个线程按顺序执行。由于任务、方法都在一个线程执行所以不存在线程不安全情况,也就不存在临界区的问题。
图示:
从图中可以看出:串行是利用一个资源,依次、首尾相接地把不同的事情做完;并发也是利用一个资源,在做一件事时的空闲时间去做另一件事;并行是投入多个资源,去做多件事。
多线程编程的实质就是将任务的处理方式由串行改成并发
。
1.4 什么是多线程,多线程的优缺点
多线程:多线程是指程序中包含多个执行流,即在一个程序中可以同时运行多个不同的线程来执行不同的任务
。
- 多线程的优点
可以提高 CPU 的利用率
。在多线程程序中,一个线程必须等待的时候,CPU 可以运行其它的线程而不是等待,这样就大大提高了程序的效率。 - 多线程的缺点
线程安全问题
多个线程共享数据时,如果没有采取相应的并发访问控制措施,就可能会产生数据一致性问题,如读取脏数据(过期的数据)、丢失更新(某些线程所做的更新被其他线程所做的更新覆盖)等。- 线程活性问题
即线程的状态出现了问题,比如死锁(某些线程一直处于等待其他线程释放锁)、活锁(一个线程在尝试某个操作单没有进展)、线程饥饿(某些线程永远不能被处理器运行)等问题。 - 上下文切换
上下文切换是多线程编程的必然副产物,它增加了系统的消耗,不利于系统的吞吐率。 - 可靠性
线程总是存在于特定的进程中的,如果这个进程因为某个线程出问题而停止运行,那么该进程中的所有线程也无法随之继续运行。
1.5 上下文切换
一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU采取的策略是交替地为每个线程分配时间片。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就是上下文切换
。
概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换
。
在时间片切换到别的任务和切换到当前任务的时候,操作系统需要保存和恢复相应线程的进度信息。这个进度信息就是上下文
,它一般包括通用寄存器的内容和程序计数器的内容。
上下文切换可以分为自发性上下文切换和非自发性上下文切换(通常说的上下文切换指的是第一种):
类型 | 含义 | 原因 |
---|---|---|
自发性上下文切换 | 由于自身因素导致的切出 | 以下任何一个方法都会引起自发性上下文切换: Thread.sleep(long mills) Object.wait() Thread.yiels() Thread.join() LockSupport.park() 线程发起了IO操作 等待其他线程持有的锁 |
非自发性上下文切换 | 由于线程调度器的原因被迫切出 | 当前线程的时间片用完 有一个比当前线程优先级更高的线程需要运行 Java虚拟机的垃圾回收动作 |
1.6 竟态
计算结果的正确性与时间有关的现象被称为竟态
。
看一个例子,假设我们向让两个线程交替执行,生成1-5之间的整数:
static int count = 0;
public static void main(String[] args) throws InterruptedException {
for(int i=0;i<5;i++){
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
increase();
}
});
thread1.setName("Thread"+i);
thread1.start();
}
}
public static void increase(){
count++;
System.out.println(Thread.currentThread().getName()+":"+count);
}
该程序的运行结果不定,可能的一种结果为:
Thread0:2
Thread2:3
Thread1:2
Thread3:4
Thread4:5
此时i的值在Thread0和Thread1线程中执行了count++
操作,但是却都是2,这个显然是不对的。
有时也会出现正确的结果:
Thread0:1
Thread2:3
Thread1:2
Thread3:4
Thread4:5
当多个线程同时访问、修改共享变量(可以被多个线程共同访问的变量
)时,就有可能出现竟态现象,也可能会出现读取脏数据问题,即线程读取到一个过时的数据、丢失更新问题。竟态不一定就导致计算结果的不正确,只是不排除计算结果有时正确、有时错误的可能。
竟态有两种模式:
- 1、read-modify-write(读-改-写)
该操作可以被分为这样几个步骤:1、读取一个共享变量的值;2、然后根据该值做一些计算;3、接着更新共享变量的值。 - 2、check-then-act(检测而后行动)
该操作可以被分为以下几个步骤:1、读取某个共享变量的值;2、然后决定下一步的动作是什么。
1.7 并发编程三要素
一般而言,如果一个类在单线程环境下能正常运行,并且在多线程环境下也能正常运行,那么就称其是线程安全
的。反之,一个类在单线程情况下能正常运行,但在多线程环境下无法正常运行,那么这个类就是非线程安全
的。线程安全问题概括来说表现为3个方面:原子性、可见性和有序性。
1.7.1 原子性
- 1、如何理解原子性
对于涉及共享变量的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,相应地我们称该操作具有原子性
。原子性问题由线程切换导致。
在理解原子操作时有两点需要注意:
- 原子操作是针对共享变量的操作而言的;
- 原子操作是在多线程环境下才有意义。
原子操作的“不可分割”具有两层含义:
- 1、访问(读、写)某个共享变量的操作,从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生,不会“看到”该操作执行部分的中间效果。
- 2、访问同一组共享变量的原子操作是不能够被交错的。
在Java中,对基本数据类型数据(long/double除外,仅包括byte、boolean、short、char、float和int)的变量和引用型变量的写操作都是原子的
。
虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行。
如果要保证long/double的写操作具有原子性,可以使用volatile变量修饰long/double变量。值得注意的是:volatile关键字仅能保证变量写操作的原子性,并不能保证其他操作(如read-modify-write操作和check-then-act操作)的原子性
。
Java中任何变量的读操作都是原子操作。 - 2、原子性问题的例子
一个关于原子性的典型例子:counter++这并不是一个原子操作,包含了三个步骤:
- 读取变量counter的值;
- 对counter加一;
- 将新值赋值给变量counter。
- 3、解决原子性问题方法
JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题
。
1.7.2 可见性
- 1、如何理解可见性
如果一个线程对某个共享变量进行更新后,后续访问该变量的线程可以读取到本次更新的结果,那么就称这个线程对该共享变量的更新对其它线程可见(一个线程对共享变量的修改,另一个线程能够立刻看到
)。可见性问题由缓存导致。 - 2、如何实现可见性
主要有三种实现可见性的方式:
volatile
,通过在汇编语言中添加lock指令,来实现内存可见性。synchronized
,当线程获取锁时会从主内存中获取共享变量的最新值,释放锁的时候会将共享变量同步到主内存中。final,被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象)
,那么其它线程就能看见 final 字段的值。
- 3、一些可见性场景
Java中默认的两种可见性的存在场景:
- 父线程在启动子线程之前对共享变量的更新对于子线程来说是可见的。
- 一个线程终止后该线程对共享变量的更新对于调用该线程的join方法的线程而言是可见的。
1.7.3 有序性
有序性指的是:程序执行的顺序按照代码的先后顺序执行
。有序性问题由编译优化导致。
volatile和synchronized都可以保证有序性
:
- volatile关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。
- synchronized 关键字同样可以保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。
1.8 阻塞与非阻塞
阻塞与非阻塞的重点在于线程等待消息时候的行为,也就是在等待消息的时候,当前线程是挂起状态,还是非挂起状态。
- 阻塞
调用在发出去后,在消息返回之前,当前线程会被挂起,直到有消息返回,当前线程才会被激活; - 非阻塞
调用在发出去后,不会阻塞当前线程,而会立即返回。
1.9 同步与异步
- 同步
当一个同步调用发出去后,调用者要一直等待调用结果的返回后,才能进行后续的操作。 - 异步
当一个异步调用发出去后,调用者不用管被调用方法是否完成,都会继续执行后面的代码。 异步调用,要想获得结果,一般有两种方式:
- 主动轮询异步调用的结果;
- 被调用方通过callback来通知调用方调用结果。
比如在超市购物,如果一件物品没了,你等仓库人员跟你调货,直到仓库人员跟你把货物送过来,你才能继续去收银台付款,这就类似同步调用。而异步调用就像网购,在网上付款下单后就不用管了,当货物到达后你收到通知去取就好。
在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程。
Tomcat 的异步Servlet也是类似的目的,让用户线程处理耗时较长的操作,避免阻塞Tomcat的工作线程。
UI程序中,开线程进行其他操作,避免阻塞UI线程。
1.10 公平策略与非公平策略
简单来说,如果一个线程先申请锁,先获得锁,就表示使用了公平策略。如果某个线程后申请锁,却先获得了锁,就表示使用了非公平策略。
一般来说,非公平调度策略的吞吐率较高
。它的缺点是:从申请者个体的角度来看,这些申请者获得相应资源的独占权所需时间的偏差可能比较大,即有的线程很快就能申请到资源,而有的线程则要经历若干次暂停与唤醒才能成功申请到资源,极端情况下可能导致饥饿现象
。
公平调度策略的吞吐率较低,这是其维护资源独占权的授予顺序的开销比较大(主要是线程的暂停与唤醒所导致的上下文切换)的结果。其优点是,从个体申请者的角度来看,这些申请者获得相应资源的独占权所需时间的偏差可能比较小,即每个资源申请者申请到资源所需的时间基本相同,并且不会导致饥饿现象。
1.11 线程调度
一个CPU,在任意时刻只能执行一条机器指令,每个线程只有获得CPU的使用权才能执行指令。多线程的并发运行,其实是指从宏观上看,各个线程轮流获得CPU的使用权,分别执行各自的任务。
线程调度模型有两种:
分时调度模型
分时调度模型是指让所有的线程轮流获得 cpu 的使用权,并且平均分配每个线程占用的 CPU 的时间片这个也比较好理解。抢占式调度模型
抢占式调度模型是指优先让运行池中优先级高的线程占用CPU,如果运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃 CPU。
Java虚拟机采用抢占式调度模型。
1.12 终止线程运行的几种情况
- 线程体中调用了 yield 方法让出了对 cpu 的占用权利;
- 线程体中调用了 sleep 方法使线程进入睡眠状态;
- 线程由于 IO 操作受到阻塞;
- 另外一个更高优先级线程出现,导致当前线程未分配到时间片;
- 在支持时间片的系统中,该线程的时间片用完。
- 使用stop方法强行终止,但是不推荐这个方法,因为stop是过期作废的方法。
- 使用interrupt方法中断线程。
1.13 进程与线程的区别
进程是程序向操作系统申请资源的基本单位,线程是进程中可独立执行的最小单位
。通常一个进程可以包含多个线程,至少包含一个线程,同一个进程中所有线程共享该进程的资源。
- 1、根本区别
进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位
。 - 2、资源开销
每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器,线程之间切换的开销小。 - 3、包含关系
一个进程里可以包含多个线程。 - 4、内存分配
同一进程的线程共享本进程的地址空间和资源,而线程之间的地址空间和资源是相互独立的
。 - 5、影响关系
一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。 - 6、执行过程
每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中
,由应用程序提供多个线程执行控制,两者均可并发执行。
1.14 Servlet线程安全吗
线程安全是编程中的术语,指某个方法在多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成
。
Servlet 不是线程安全的,Servlet 是单实例多线程的,当多个线程同时访问同一个方法,是不能保证共享变量的线程安全性的。
SpringMVC的Controller也同样不是线程安全的。
1.15 线程安全集合类
线程安全集合类可以分为三大类:
- 1、遗留的线程安全集合
如 Hashtable , Vector。 - 2、使用 Collections 装饰的线程安全集合
如:Collections.synchronizedCollection、Collections.synchronizedList、Collections.synchronizedMap、Collections.synchronizedSet、Collections.synchronizedNavigableMap、Collections.synchronizedNavigableSet、Collections.synchronizedSortedMap、Collections.synchronizedSortedSet。 - 3、java.util.concurrent.*
里面包含三类关键词:Blocking、CopyOnWrite、Concurrent。
- Blocking 大部分实现基于锁,并提供用来阻塞的方法;
- CopyOnWrite 之类容器修改开销相对较重;
- Concurrent 类型的容器:内部很多操作使用 cas 优化,一般可以提供较高吞吐量。
二、线程的初步使用
在Java中创建一个线程,可以理解创建一个Thread类(或其子类)的实例。线程的任务处理逻辑可以在Thread类的run实例方法中实现,运行一个线程实际上就是让Java虚拟机执行该线程的run方法。run方法相当于线程的任务处理逻辑的入口方法,它由虚拟机在运行相应线程时直接调用,而不是由相应代码进行调用
。
启动一个线程的方法是调用start方法,其实质是请求Java虚拟机运行相应的线程,而这个线程具体何时运行是由线程调度器决定的
。因此,start方法调用结束并不意味着相应线程已经开始运行。
2.1 创建线程的4种方式
线程的创建方式有4种,接下来一一介绍。
2.1.1 继承Thread类
使用方式:
- 继承Thread类;
- 重写run方法;
- 创建Thread对象;
- 通过start()方法启动线程。
示例:
/*继承Thread类*/
public class WelcomeThread extends Thread{
@Override
public void run() {
System.out.printf("test");
}
}
public class ThreadTest1 {
public static void main(String[] args) {
// 创建线程
Thread welcomeThread = new WelcomeThread();
// 启动线程
welcomeThread.start();
}
}
JDK1.8后,可以使用Lambda表达式来创建,示例:
new Thread(()->{
System.out.println("Lambda Thread Test!");
}).start();
2.1.2 实现Runnable接口
使用方式:
- 实现Runnable接口;
- 重写run方法;
- 创建Thread对象,将实现Runnable接口的类作为Thread的构造参数;
- 通过start()进行启动。
此种方式用到了代理模式,示例:
public class RunnableDemo implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
public static void main(String[] args) {
RunnableDemo runnableDemo = new RunnableDemo();
Thread thread = new Thread(runnableDemo);
thread.start();
}
}
前两种比较的话, 推荐使用第二种方式,原因:
- Java是单继承,将继承关系留给最需要的类。
Runnable可以实现多个相同的程序代码的线程去共享同一个资源
。当以Thread方式去实现资源共享时,实际上Thread内部,依然是以Runnable形式去实现的资源共享。
2.1.3 实现Callable接口
前两种方式比较常见,Callable的使用方式:
- 创建实现Callable接口的类;
- 以Callable接口的实现类为参数,创建FutureTask对象;
- 将FutureTask作为参数创建Thread对象;
- 调用线程对象的start()方法。
示例:
public class MyCallable implements Callable<Integer> {
@Override
public Integer call() {
System.out.println(Thread.currentThread().getName() + " call()方法执行中...");
return 1;
}
}
public class CallableTest {
public static void main(String[] args) {
FutureTask<Integer> futureTask = new FutureTask<Integer>(new MyCallable());
Thread thread = new Thread(futureTask);
thread.start();
}
}
使用该方法创建线程时,核心方法是call(),该方法有返回值,其返回值类型就是Callable接口中泛型对应的类型
。
2.1.4 使用Executors工具类创建线程池
由于线程的创建、销毁是一个比较消耗资源的过程,所以在实际使用时往往使用线程池(后续再详细介绍)。
在创建线程池时,可以使用现成的Executors工具类来创建,该工具类能创建的线程池有4种:newFixedThreadPool,newCachedThreadPool,newSingleThreadExecutor,newScheduledThreadPool。此处以newSingleThreadExecutor为例,其步骤为:
- 使用Executors类中的newSingleThreadExecutor方法创建一个线程池;
- 调用线程池中的execute()方法执行由实现Runnable接口创建的线程;或者调用submit()方法执行由实现Callable接口创建的线程;
- 调用线程池中的shutdown()方法关闭线程池。
示例:
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " run()方法执行中...");
}
}
public class SingleThreadExecutorTest {
public static void main(String[] args) {
ExecutorService executorService = Executors.newSingleThreadExecutor();
MyRunnable runnableTest = new MyRunnable();
for (int i = 0; i < 5; i++) {
executorService.execute(runnableTest);
}
System.out.println("线程任务开始执行");
executorService.shutdown();
}
}
2.2 使用线程的注意事项
2.2.1 线程每次只能使用一次
当线程的run方法执行结束,相应的线程的运行也就结束了。
线程每次只能使用一次
,即通过调用start方法来使用。在线程未结束前,多次调用start方法会抛出IllegalThreadStateException,Thread类中的start方法中可以看出该逻辑:
public synchronized void start() {
checkNotStarted();
hasBeenStarted = true;
nativeCreate(this, stackSize, daemon);
}
private void checkNotStarted() {
if (hasBeenStarted) {
throw new IllegalThreadStateException("Thread already started");
}
}
2.2.2 线程的 run()和 start()有什么区别
两者的区别:
start() 方法用于启动线程,run() 方法用于实现具体的业务逻辑
。run() 可以重复调用,而 start() 只能调用一次
。
调用start()方法来启动一个线程,无需等待run()方法体代码执行完毕,可以直接继续执行其他的代码, 此时线程是处于就绪状态,并没有运行。
2.2.3 为什么不能直接调用 run() 方法
新建一个线程,线程进入了新建状态。调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。
如果直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作
。看个例子:
public class JavaTest {
public static void main(String[] args) {
System.out.println("main方法中的线程名:"
+Thread.currentThread().getName()); //main方法中的线程名:main
Thread welcomeThread = new WelcomeThread();
System.out.println("以start方法启动线程");
welcomeThread.start(); //Thread子类中的线程名:Thread-0
System.out.println("以run方法启动线程");
welcomeThread.run(); //Thread子类中的线程名:main
}
}
class WelcomeThread extends Thread{
@Override
public void run() {
System.out.println("Thread子类中的线程名:"
+Thread.currentThread().getName());
}
}
总结: 调用 start 方法方可启动线程并使线程进入就绪状态
,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。
2.3 线程属性
Thread类的私有属性有许多,了解几个常用的即可:线程的编号(ID)、名称(Name)、线程类别(Daemon)和优先级(Priority)。这几个属性中,ID仅可读,其他都是可读写
。具体如下:
属性 | 属性类型及用途 | 注意事项 |
---|---|---|
编号(ID) | 类型:long 用于标识不同的线程,不同的线程拥有不同的编号 |
某个编号的线程运行结束后,该编号可能被后续创建的线程使用,因此该属性的值不适合用作某种唯一标识 |
名称(Name) | 类型:String 用于区分不同的线程,默认值的 格式为“Thread-线程编号” |
尽量为不同的线程设置不同的值 |
线程类别(Daemon) | 类型:boolean 值为true表示相应的线程为守护线程,否则表示相应的线程为用户线程。该属性的默认值与相应线程的父线程的该属性的值相同 |
该属性必须在相应线程启动之前设置,即调用setDaemon方法必须在调用start方法之前,否则会出现IllegalThreadStateException |
优先级(Priority) | 类型:int 优先级高的线程一般会被优先运行。优先级从1到10,默认值一般为5(普通优先级),数字越大,优先级越高。 对于具体的一个线程而言,其优先级的默认值与其父线程的优先级值相等。 |
一般使用默认的优先级即可,不恰当地设置该属性值可能会导致严重的问题(线程饥饿) |
获取4个属性值示例:
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
//10,Thread-0,5,false
System.out.println(Thread.currentThread().getId()+","
+Thread.currentThread().getName()+","
+Thread.currentThread().getPriority()+","
+Thread.currentThread().isDaemon());
}
}).start();
}
2.3.1 线程优先级
Java线程的优先级属性本质上只是一个给线程调度器的提示信息,以便于线程调度器决定优先调度哪些线程运行
。每个线程的优先级都在1到10 之间,1的优先级为最低,10的优先级为最高,在默认情况下优先级都是Thread.NORM_PRIORITY(常数 5)。虽然开发者可以定义线程的优先级,但是这并不能保证高优先级的线程会在低优先级的线程前执行。
线程优先级具有继承特性
,比如 A 线程启动 B 线程,则 B 线程的优先级和 A 是一样的。
设置和获取线程优先级的方法:
//为线程设定优先级
public final void setPriority(int newPriority)
//获取线程的优先级
public final int getPriority()
示例:
public class RunnableDemo implements Runnable {
@Override
public void run() {
int nowPriority = Thread.currentThread().getPriority();
System.out.println("1.优先级:"+nowPriority); //1.优先级:5
Thread.currentThread().setPriority(Thread.MAX_PRIORITY);
nowPriority = Thread.currentThread().getPriority();
System.out.println("2.优先级:"+nowPriority); //2.优先级:10
}
public static void main(String[] args) {
RunnableDemo runnableDemo = new RunnableDemo();
Thread thread = new Thread(runnableDemo);
thread.start();
}
}
2.3.2 守护线程和用户线程
Java 中的线程分为两种:守护线程和用户线程。任何线程都可以设置为守护线程和用户线程,通过方法setDaemon(true)
可以把该线程设置为守护线程,反之则为用户线程。
用户线程
:运行在前台,执行具体的任务,如程序的主线程、连接网络的子线程等都是用户线程。
守护线程
:运行在后台,为其他前台线程服务,比如垃圾回收线程,JIT(编译器)线程就可以理解为守护线程。一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作。
注意事项:
setDaemon(true)必须在start()方法前执行,否则会抛出 IllegalThreadStateException 异常
。- 在守护线程中产生的新线程也是守护线程。
- 不是所有的任务都可以分配给守护线程来执行,比如读写操作或者计算逻辑。
- 守护线程中不能依靠 finally 块的内容来确保执行关闭或清理资源的逻辑。因为我们上面也说过了一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作,所以守护 线程中的 finally 语句块可能无法被执行。
设置和获取线程是否是守护线程的方法:
//设置线程是否为守护线程
public final void setDaemon(boolean on)
//判断线程是否是守护线程
public final boolean isDaemon()
2.3.3 线程名称
相比于上面的两个属性,实际运用中,往往线程名称会被修改,目的是为了调试。获取和设置线程名称的方法:
//获取线程名称
public final String getName()
//设置线程名称
public final synchronized void setName(String name)
示例:
public class RunnableDemo implements Runnable {
@Override
public void run() {
String nowName = Thread.currentThread().getName();
System.out.println("1.线程名称:"+nowName); //1.线程名称:Thread-0
Thread.currentThread().setName("测试线程");
nowName = Thread.currentThread().getName();
System.out.println("2.线程名称:"+nowName); //2.线程名称:测试线程
}
public static void main(String[] args) {
RunnableDemo runnableDemo = new RunnableDemo();
Thread thread = new Thread(runnableDemo);
thread.start();
}
}
2.4 线程的生命周期
2.4.1 从代码角度理解
在Thread类中,线程状态是一个枚举类型:
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED
}
线程的状态可以通过public State getState()
来获取,该方法的返回值是一个枚举类型,线程状态定义如下:
- 1、NEW:一个已创建而未启动的线程处于该状态
- 2、RUNNABLE:该状态可以被看成一个复合状态
它包括两个子状态:READY和RUNNING。前者表示处于该状态的线程可以被线程调度器进行调度而使之处于RUNNING状态,后者表示线程正在运行状态。执行Thread.yield()的线程,其状态可能由RUNNING转换为READY。 - 3、BLOCKED:一个线程发起一个阻塞式IO操作后,或者申请一个由其他线程持有的独占资源(比如锁)时,相应的线程就在该状态
处于BLOCKED状态的线程并不会占处理器资源,当阻塞式IO操作完成后,或线程获得了其申请的资源,状态又会转换为RUNNABLE。 - 4、WAITING:一个线程执行了某些特定方法之后就会处于这种等待其他线程执行另外一些特定操作的状态
能够使线程变成WAITING状态的方法包括:Object.wait()、Thread.join(),能够使线程从WAITING状态变成RUNNABLE状态的方法有:Object.notify()、Object.notifyAll()。 - 5、TIMED_WAITING:该状态和WAITING类似,差别在于处于该状态的线程并非无限制地等待其他线程执行特定操作,而是处于带有时间限制的等待状态
当其他线程没有在特定时间内执行该线程所期待的特定操作时,该线程的状态自动转换为RUNNABLE。 - 6、TERMINATED:已经执行结束的线程处于该状态
Thread.run()正常返回或由于抛出异常而提前终止都会导致相应线程处于该状态。
2.4.2 从使用角度理解
在实际开发中,往往将线程的状态理解为5种:新建、可运行、运行、阻塞、死亡。
- 1、新建(new)
新创建了一个线程对象。用new方法创建一个线程后,线程对象就处于新建状态。 - 2、可运行(runnable)
线程对象创建后,当调用线程对象的 start()方法,该线程处于就绪状态,但还没分配到CPU,处于线程就绪队列,等待系统为其分配CPU。 - 3、运行(running)
可运行状态(runnable)的线程获得了cpu时间片,执行程序代码。
就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;
如果在给定的时间片内没有执行结束,就会被系统给换下来回到等待执行状态。 - 4、阻塞(block)
处于运行状态中的线程由于某种原因,暂时放弃对 CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪(runnable)状态,才有机会再次被CPU调用以进入到运行状态。
阻塞的情况分三种:
等待阻塞
运行状态中的线程执行 wait()方法,JVM会把该线程放入等待队列中,使本线程进入到等待阻塞状态;同步阻塞
线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),则JVM会把该线程放入锁池中,线程会进入同步阻塞状态;其他阻塞
通过调用线程的 sleep()或 join()或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。
- 5、死亡(dead)
线程run()、main()方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。
2.5 查看进程线程
- Linux命令
ps -fe
查看所有进程;
ps -fT -p <PID>
查看某个进程(PID)的所有线程;
kill
杀死进程;
top
按大写 H 切换是否显示线程;
top -H -p <PID>
查看某个进程(PID)的所有线程。 - Linux命令
jps
命令查看所有 Java 进程;
jstack <PID>
查看某个 Java 进程(PID)的所有线程状态。
2.6 Thread类的常用方法
以下是Thread类中较常用的几个方法,并不包含线程间协作的方法(如await、notify等),这些方法的使用随后介绍。其中的yield方法并不常用,但常常拿来和sleep、await等方法进行比较,所以也介绍下。
方法 | 功能 | 备注 |
---|---|---|
static Thread currentThread() | 返回当前线程,即当前代码的执行线程 | |
void run() | 用于实现线程的任务处理逻辑 | 该方法由Java虚拟机直接调用 |
void start() | 启动线程 | 调用该方法并不代表相应的线程已经被启动,线程是否启动是由虚拟机去决定的 |
void join() | 等待相应线程运行结束 | 若线程A调用线程B的join方法,那么线程A的运行会被暂停,直到线程B运行结束 |
static void yield() | 使当前线程主动放弃其对处理器的占用,这可能导致当前线程被暂停 | 这个方法是不可靠的,该方法被调用时当前线程仍可能继续运行 |
void interrupt() | 中断线程 |
|
static void sleep(long millis) | 使当前线程休眠(暂停运行)指定的时间 |
表格中的前4个方法,在之前已经使用过,不再赘述,接下来就介绍后4个。
2.6.1 interrupted
中断
可以理解为线程的一个标志位,它表示了一个运行中的线程是否被其他线程进行了中断操作,常常被用于线程间的协作
。
其他线程可以调用指定线程的interrupt()方法对其进行中断操作,同时指定线程可以调用isInterrupted()来感知其他线程对其自身的中断操作,从而做出响应。
另外,也可以调用Thread的静态方法interrupted()对当前线程进行中断操作,该方法会清除中断标志位。需要注意的是,当抛出InterruptedException时候,会清除中断标志位
,此时再调用isInterrupted,会返回false。
和中断相关的方法有3个:
方法名 | 详细解释 | 备注 |
---|---|---|
public void interrupt() | 中断该线程对象 |
如果该线程被调用了Object wait/Object wait(long),或者被调用sleep(long),join()/join(long)方法时会抛出interruptedException并且中断标志位将会被清除 |
public boolean isinterrupted() | 测试该线程对象是否被中断 |
中断标志位不会被清除 |
public static boolean interrupted() | 查看当前中断信号是true还是false并且清除中断信号 |
中断标志位会被清除 |
关于interrupt和isinterrupted的使用,看个例子:
public class JavaTest {
public static void main(String[] args) throws InterruptedException {
//sleepThread睡眠1000ms
final Thread sleepThread = new Thread() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
super.run();
}
};
//busyThread一直执行死循环
Thread busyThread = new Thread() {
@Override
public void run() {
while (true) ;
}
};
sleepThread.start();
busyThread.start();
sleepThread.interrupt();
busyThread.interrupt();
while (sleepThread.isInterrupted()) ;
System.out.println("sleepThread isInterrupted: " + sleepThread.isInterrupted());
System.out.println("busyThread isInterrupted: " + busyThread.isInterrupted());
}
}
测试结果:
在上面的代码中,开启了两个线程分别为sleepThread和BusyThread, sleepThread睡眠1s,BusyThread执行死循环。然后分别对着两个线程进行中断操作,可以看出sleepThread抛出InterruptedException后清除标志位,而busyThread就不会清除标志位。
另外,可以通过中断的方式实现线程间的简单交互,因为可以通过isInterrupted()
方法监控某个线程的中断标志位是否清零,针对不同的中断标志位进行不同的处理。
2.6.2 join
join方法也是一种线程间协作的方式
,很多时候,一个线程的输入可能非常依赖于另一个线程的输出。如果在一个线程threadA中执行了threadB.join(),其含义是:当前线程threadA会等待threadB线程终止后,threadA才会继续执行
。
方法名 | 详细注释 | 备注 |
---|---|---|
public final void join() throws InterruptedException | 等待这个线程死亡。 | 如果任何线程中断当前线程,如果抛出InterruptedException异常时,当前线程的中断状态将被清除 |
public final void join(long millis) throws InterruptedException | 等待这个线程死亡的时间最多为millis毫秒。 如果参数为 0,意味着永远等待。 |
如果millis为负数,抛出IllegalArgumentException异常 |
public final void join(long millis, int nanos) throws InterruptedException | 等待最多millis毫秒加上这nanos纳秒。 | 如果millis为负数或者nanos不在0-999999范围抛出IllegalArgumentException异常 |
看个例子:
public class JoinDemo {
public static void main(String[] args) {
Thread previousThread = Thread.currentThread();
for (int i = 1; i <= 10; i++) {
Thread curThread = new JoinThread(previousThread);
curThread.start();
previousThread = curThread;
}
}
static class JoinThread extends Thread {
private Thread thread;
public JoinThread(Thread thread) {
this.thread = thread;
}
@Override
public void run() {
try {
thread.join();
System.out.println(thread.getName() + " terminated.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
测试结果:
在上面的例子中一个创建了10个线程,每个线程都会等待前一个线程结束才会继续运行。可以通俗的理解成接力,前一个线程将接力棒传给下一个线程,然后又传给下一个线程…
2.6.3 sleep
sleep方法为:
public static native void sleep(long millis)
显然sleep是Thread的静态方法,它的作用是:让当前线程按照指定的时间休眠,其休眠时间的精度取决于处理器的计时器和调度器。需要注意的是如果当前线程获得了锁,sleep方法并不会失去锁
。
Thread.sleep方法经常拿来与Object.wait()方法进行比较,sleep和wait两者主要的区别:
- sleep()方法是Thread的静态方法,而wait是Object实例方法;
wait()方法必须要在同步方法或者同步块中调用,也就是必须已经获得对象锁
。而sleep()方法没有这个限制可以在任何地方使用。wait()方法会释放占有的对象锁,使得该线程进入等待池中
,等待下一次获取资源。而sleep()方法只是会让出CPU并不会释放掉对象锁;- sleep()方法在休眠时间达到后,如果再次获得CPU时间片就会继续执行,而wait()方法必须等待Object.notift/Object.notifyAll通知后,才会离开等待池,并且再次获得CPU时间片才会继续执行。
关于sleep方法的使用,看个例子:
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("第一个线程的执行时间:"+new Date());
}
}).start();
System.out.println("sleep2秒");
Thread.sleep(2000);
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("第二个线程的执行时间:"+new Date());
}
}).start();
}
结果示例:
可以看出,第2个线程的执行时间是晚于第1个线程2秒的。
2.6.4 yield
yield方法为:
public static native void yield()
yield方法的作用:使当前线程从执行状态(运行状态)变为可执行态(就绪状态)
。
yield方法是一个静态方法,一旦执行,它会是当前线程让出CPU。但是,让出了CPU并不是代表当前线程不再运行了。线程调度器可能忽略此此消息,并且如果在下一次竞争中,又获得了CPU时间片当前线程依然会继续运行。另外,让出的时间片只会分配给大于等于当前线程优先级的线程。
在线程中,用priority来表示优先级,priority的范围从1~10。在构建线程的时候可以通过 setPriority(int) 方法进行设置,默认优先级为5,优先级高的线程相较于优先级低的线程优先获得处理器时间片。
需要注意的是,sleep()和yield()方法,同样都是当前线程会交出处理器资源,而它们不同的是,sleep()交出来的时间片其他线程都可以去竞争,也就是说都有机会获得当前线程让出的时间片。而yield()方法只允许大于等于当前线程优先级的线程,竞争CPU时间片
。
2.6.5 interrupt、interrupted和isInterrupted方法的区别
interrupt:用于中断线程。调用该方法的线程的状态为将被置为”中断”状态
。
线程中断仅仅是设置线程的中断状态标识,不会停止线程。需要用户自己去监视线程的状态为并做处理。支持线程中断的方法(也就是线程中断后会抛出interruptedException 的方法)就是在监视线程的中断状态,一旦线程的中断状态标识被置为“中断状态”,就会抛出中断异常。
interrupted:是静态方法,查看当前中断信号是true还是false并且清除中断信号
。如果一个线程被中断了,第一次调用 interrupted 则返回 true,第二次和后面的就返回 false 了。
isInterrupted:查看当前中断信号是true还是false
。
2.6.6 sleep方法和yield方法有什么区别
- 1、
sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;yield()方法只会给相同优先级或更高优先级的线程以运行的机会
; - 2、
线程执行 sleep()方法后转入阻塞(blocked)状态,而执行 yield()方法后转入就绪(ready)状态
; - 3、sleep()方法声明抛出 InterruptedException(其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException),而 yield()方法没有声明任何异常;
- 4、sleep()方法比 yield()方法具有更好的可移植性,通常不建议使用yield()方法来控制并发线程的执行;
- 5、建议用TimeUnit的sleep代替Thread的sleep来获得更好的可读性。
2.7 多线程下载的例子
此处用多线程的方式,从网络下载一个文件,参考了网上的一篇博客来实现:多线程下载的原理和基本用法。
多线程下载文件可以简单分为以下几步:
- 1、获取目标文件的大小
在本地留好足量的空间来存储。 - 2、确定要开启几个线程
所开线程的最大数量=(CPU核数+1),本例子中开三个线程。 - 3、 计算平均每个线程需要下载多少个字节的数据
理想情况下多线程下载是按照平均分配原则的,即:单线程下载的字节数等于文件总大小除以开启的线程总条数,当不能整除时,则最后开启的线程将剩余的字节一起下载。 - 4、计算各个线程要下载的字节范围
在下载过程中,各个线程都要明确自己的开始索引(startIndex)和结束索引(endIndex)。 - 5、使用for循环开启子线程进行下载
- 6、获取各个线程的目标文件的开始索引和结束索引的范围
- 7、创建文件,接收下载的流
示例:
package ThreadTest;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.net.URL;
public class DownloadTest {
private static final String path = "http://down.360safe.com/se/360se9.1.0.426.exe";
public static void main(String[] args) throws Exception {
/*第一步:获取目标文件的大小*/
int totalSize = new URL(path).openConnection().getContentLength();
System.out.println("目标文件的总大小为:"+totalSize+"B");
/*第二步:确定开启几个线程。开启线程的总数=CPU核数+1;例如:CPU核数为4,则最多可开启5条线程*/
int availableProcessors = Runtime.getRuntime().availableProcessors();
System.out.println("CPU核数是:"+availableProcessors);
int threadCount = 3;
/*第三步:计算每个线程要下载多少个字节*/
int blockSize = totalSize/threadCount;
/*每次循环启动一条线程下载*/
for(int threadId=0; threadId<3;threadId++){
/*第四步:计算各个线程要下载的字节范围*/
/*每个线程下载的开始索引*/
int startIndex = threadId * blockSize;
/*每个线程下载的结束索引*/
int endIndex = (threadId+1)* blockSize-1;
/*如果是最后一条线程*/
if(threadId == (threadCount -1)){
endIndex = totalSize -1;
}
/*第五步:启动子线程下载*/
new DownloadThread(threadId,startIndex,endIndex).start();
}
}
private static class DownloadThread extends Thread{
private int threadId;
private int startIndex;
private int endIndex;
public DownloadThread(int threadId, int startIndex, int endIndex) {
super();
this.threadId = threadId;
this.startIndex = startIndex;
this.endIndex = endIndex;
}
@Override
public void run(){
System.out.println("第"+threadId+"条线程,下载索引:"+startIndex+"~"+endIndex);
/*每条线程要去找服务器拿取一段数据*/
try {
URL url = new URL(path);
HttpURLConnection connection = (HttpURLConnection)url.openConnection();
/*设置连接超时时间*/
connection.setConnectTimeout(5000);
/*第六步:获取目标文件的[startIndex,endIndex]范围*/
connection.setRequestProperty("range", "bytes="+startIndex+"-"+endIndex);
connection.connect();
/*获取响应码,当服务器返回的是文件的一部分时,响应码不是200,而是206*/
int responseCode = connection.getResponseCode();
if (responseCode == 206) {
//拿到目标段的数据
InputStream is = connection.getInputStream();
/*第七步:创建一个RandomAccessFile对象,将返回的字节流写到文件指定的范围*/
String fileName = getFileName(path);
/*创建一个可读写的文件,即把文件下载到D盘*/
RandomAccessFile raf = new RandomAccessFile("d:/"+fileName, "rw");
/*注意:让raf写字节流之前,需要移动raf到指定的位置开始写*/
raf.seek(startIndex);
/*将字节流数据写到文件中*/
byte[] buffer = new byte[1024];
int len = 0;
while((len=is.read(buffer))!=-1){
raf.write(buffer, 0, len);
}
is.close();
raf.close();
System.out.println("第 "+ threadId +"条线程下载完成 !");
} else {
System.out.println("下载失败,响应码是:"+responseCode);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
/*获取文件的名称*/
private static String getFileName(String path){
int index = path.lastIndexOf("/");
String fileName = path.substring(index+1);
return fileName ;
}
}
测试结果:
目标文件的总大小为:48695168B
CPU核数是:4
第0条线程,下载索引:0~16231721
第1条线程,下载索引:16231722~32463443
第2条线程,下载索引:32463444~48695167
第 1条线程下载完成 !
第 0条线程下载完成 !
第 2条线程下载完成 !
下载文件:
三、Java内存模型
3.1 JMM基础
出现线程安全问题,一般是因为主内存和工作内存数据不一致性
和重排序
导致的。要理解这两个问题,核心在于理解Java内存模型(JMM)。
在多线程条件下,多个线程肯定会相互协作完成一件事情,一般来说会涉及到多个线程间相互通信,告知彼此的状态以及当前的执行结果
等。同时,为了性能优化,还会涉及到编译器指令重排序和处理器指令重排序
。
在并发编程中主要需要解决两个问题:
线程之间如何通信
;线程之间如何完成同步
。通信是指线程之间以何种机制来交换信息,主要有两种:共享内存和消息传递
。Java内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量来完成隐式通信。
共享变量:在Java程序中所有实例域,静态域和数组元素都是放在堆内存中(所有线程均可访问到,是可以共享的),而局部变量,方法定义参数和异常处理器参数不会在线程间共享。
3.1.1 JMM抽象结构模型
Java线程之间的通信由Java内存模型(简称JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:
线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。
共享变量会先放在主存中,每个线程都有属于自己的工作内存,并且会把位于主存中的共享变量拷贝到自己的工作内存,之后的读写操作均使用位于工作内存的变量副本,并在某个时刻将工作内存的变量副本写回到主存中去。
图中的线程A和线程B之间要完成通信的话,要经历如下两步:
- 线程A从主内存中将共享变量读入线程A的工作内存后并进行操作,之后将数据重新写回到主内存中;
- 线程B从主内存中读取最新的共享变量。
3.1.2 内存间交互操作
JMM定义了 8 个操作来完成主内存和工作内存的交互操作。:
- 1、lock(锁定)
作用于主内存
中的变量,它把一个变量标识为一个线程独占的状态; - 2、unlock(解锁)
作用于主内存
中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定; - 3、read(读取)
作用于主内存
的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面的load使用; - 4、write(操作)
作用于主内存
的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。 - 5、load(载入)
作用于工作内存
中的变量,它把read操作从主内存中得到的变量值放入工作内存中的变量副本; - 6、use(使用)
作用于工作内存
中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作; - 7、assign(赋值)
作用于工作内存
中的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作; - 8、store(存储)
作用于工作内存
的变量,它把工作内存中一个变量的值传送给主内存中以便随后的write操作使用。
如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。图示:
3.2 内存屏障
内存屏障是对一类仅针对内存读、写操作指令的跨处理器架构(比如ARM)的称呼,其作用是禁止编译器、处理器重排序从而保障有序性
。
3.2.1 硬件级别的内存屏障
在不同的硬件上,有不同的内存屏障,比如在X86上,有以下内存屏障指令:
sfence: store| 在sfence指令前的写操作当必须在sfence指令后的写操作前完成。
lfence:load | 在lfence指令前的读操作当必须在lfence指令后的读操作前完成。
mfence:modify/mix | 在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成。
当然也可以通过原子指令的方式来实现内存屏障的功能。如x86上的”lock …” 指令是一个Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。软件锁通常使用了内存屏障或原子指令来实现变量可见性和保持程序顺序。
3.2.2 JVM级别的内存屏障
为了性能优化,JMM在不改变(单线程)正确语义的前提下,允许编译器和处理器对指令序列进行重排序。如果想阻止重排序,可以添加内存屏障。
JMM内存屏障(JVM级别的内存屏障
)分为四类:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | 确保Load1的数据的装载先于Load2及所有后续装载指令的装载 |
StoreStoreBarriers | Store1;StoreStore;Store2 | 确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储 |
LoadStore Barriers | Load1;LoadStore;Store2 | 确保Load1的数据的装载先于Store2及所有后续存储指令的存储 |
StoreLoad Barriers | Store1;StoreLoad;Load2 | 确保Store1的数据对其他处理器可见(刷新到内存)先于Load2及所有后续的装载指令的装载 |
3.2.3 volatile内存屏障
Java编译器会在生成指令序列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序
。为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:
"NO"表示禁止重排序。为了实现volatile内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。JMM采取的策略:
在每个volatile写操作的前面插入一个StoreStore屏障
;在每个volatile写操作的后面插入一个StoreLoad屏障
;在每个volatile读操作的后面插入一个LoadLoad屏障
;在每个volatile读操作的后面插入一个LoadStore屏障
。
需要注意的是:volatile写操作是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。这4种内存屏障的作用:
- StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;
- StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序
- LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序
- LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序
volatile写操作内存屏障图示;
volatile读操作内存屏障图示;
3.3 先行发生原则
除了用 volatile 和 synchronized 来保证有序性,JVM 还规定了先行发生原则,这些原则是默认的"规矩",可以让一个操作无需控制就能先于另一个操作完成。
- 1、单一线程原则
在一个线程内,在程序前面的操作先行发生于后面的操作。 - 2、管程锁定规则
一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
- 3、volatile 变量规则
对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
- 4、线程启动规则
Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。
- 5、线程加入规则
Thread 对象的结束先行发生于 join() 方法返回。
- 6、线程中断规则
对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。 - 7、对象终结规则
一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。 - 8、传递性
如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。
3.4 重排序与数据依赖性
编写的程序都要经过优化后(编译器和处理器对程序进行优化,以提高程序运行效率)才会被运行,优化分为很多种,其中有一种优化叫做重排序,重排序需要遵守as-if-serial规则和happens-before规则
。
3.4.1 重排序分类
一般重排序可以分为如下三种:
- 1、编译器优化的重排序
编译器在不改变单线程语义的前提下,可以重新调整语句的执行顺序; - 2、处理器优化的重排序(指令级并行重排序)
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序; - 3、内存系统的重排序
由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
3.4.2 重排序过程
在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。源代码所经历的重排序流程:
1属于编译器重排序
,而2和3统称为处理器重排序
。这些重排序会导致线程安全的问题,一个很经典的例子就是DCL(双重检验锁)问题。
针对编译器重排序,Java内存模型(JMM)的编译器重排序规则会禁止一些特定类型的编译器重排序。
针对处理器重排序,编译器在生成指令序列的时候会通过插入内存屏障指令来禁止某些特殊的处理器重排序。
3.4.3 数据依赖性
具体的定义为:如果两个操作访问同一个变量,且这两个操作有一个为写操作,此时这两个操作就存在数据依赖性。数据依赖性存在三种情况:1. 读后写;2.写后写;3. 写后读。如果重排序会对最终执行结果产生影响,编译器和处理器在重排序时,会遵守数据依赖性,不会改变存在数据依赖性关系的两个操作的执行顺序
。
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a = 1;b = a; | 写一个变量后,再读这个位置 |
写后写 | a = 1;a = 2; | 写一个变量后,再读这个变量 |
读后写 | a = b;b = 1; | 读一个变量后,再读这个变量 |
这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑
。
看个例子:
double pi = 3.14 //A
double r = 1.0 //B
double area = pi * r * r //C
这是一个计算圆面积的代码,由于A,B之间没有任何关系,对最终结果也不会存在影响,它们之间执行顺序可以重排序。因此执行顺序可以是A->B->C或者B->A->C执行最终结果都是3.14,即A和B之间没有数据依赖性。
3.4.4 为什么代码会重排序
在执行程序时,为了提高性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,重排序需要满足以下两个条件:
- 在单线程环境下不能改变程序运行的结果;
- 存在数据依赖关系的不允许重排序。
重排序不会影响单线程环境的执行结果,但是可能会破坏多线程的执行语义
。
3.5 as-if-serial规则和happens-before规则
3.5.1 as-if-serial规则
as-if-serial
语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变
。编译器和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。这也就意味着,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。
还看之前的例子:
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。
3.5.2 happens-before规则
JMM可以通过happens-before关系向开发者提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。具体的定义为:
如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
。两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法
。
第一条是JMM对开发者的承诺。从开发者的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。
第二条是JMM对编译器和处理器重排序的约束原则。JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。
JMM为开发者在上层提供了8条规则,这样开发者就可以根据规则去推论跨线程的内存可见性问题,而不用再去理解底层重排序的规则。
具体的happens-before规则:
- 1、程序顺序规则
一个线程中的每个操作,happens-before于该线程中的任意后续操作。 - 2、监视器锁规则
对一个锁的解锁,happens-before于随后对这个锁的加锁。 - 3、volatile变量规则
对一个volatile变量的写,happens-before于任意后续对这个volatile变量的读。 - 4、传递性
如果A happens-before B,且B happens-before C,那么A happens-before C。 - 5、start()规则
如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。 - 6、join()规则
如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。 - 7、程序中断规则
对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。 - 8、对象finalize规则
一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。
还看之前的例子:
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
利用程序顺序规则(规则1)存在三个happens-before关系:1. A happens-before B;2. B happens-before C;3. A happens-before C。这里的第三个关系是利用传递性进行推论的。A happens-before B,定义1要求A执行结果对B可见,并且A操作的执行顺序在B操作之前,但与此同时利用定义中的第二条,A,B操作彼此不存在数据依赖性,两个操作的执行顺序对最终结果都不会产生影响,在不改变最终结果的前提下,允许A,B两个操作重排序,即happens-before关系并不代表了最终的执行顺序
。
3.5.3 as-if-serial与happens-before的区别
- as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
- as-if-serial语义给编写单线程程序的开发者创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的开发者创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
- as-if-serial语义和happens-before共同的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
3.6 JMM理论小结
3.6.1 JMM的设计
JMM是语言级的内存模型,JMM处于中间层,自身包含了两个方面:1)内存模型;2)重排序以及happens-before规则。
站在JMM设计者的角度,在设计JMM时需要考虑两个关键因素:
- 1、开发者对内存模型的使用
开发者希望内存模型易于理解、易于编程。开发者希望基于一个强内存模型来编写代码。 - 2、编译器和处理器对内存模型的实现
编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。
JMM的设计图为:
3.6.2 happens-before与JMM的关系
一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于Java开发者来说,happens-before规则简单易懂,便于开发。
3.7 缓存与内存简介
因为 CPU 与 内存的速度差异很大,需要靠预读数据至缓存来提升效率。
缓存以缓存行为单位,每个缓存行对应着一块内存,一般是 64 byte(8 个 long)。
缓存的加入会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中。
CPU 要保证数据的一致性,如果某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效。