一 多线程的操作原则
1 多线程 AVO 原则
A:即 Atomic,原子性操作原则。对基本数据类型的变量读和写是保证原子性的,要么都成功,要么都失败,这些操作不可中断。
V:即 volatile,可见性原则。后续的小节会对 volatile 关键字进行深入的讲解,此处只需要理解概念性问题即可。使用 volatile 关键字,保证了变量的可见性,到主存拿数据,不是到缓存里拿。
O:即 order, 就是有序性。代码的执行顺序,在代码编译前的和代码编译后的执行顺序不变。
2 单 CPU 时代的多线程
概念:单核 CPU 上,同一时刻只能有一条线程运行,单核 CPU 上运行的单线程程序和多线程程序,从运行效率上看没有差别。换而言之,单 CPU 时代,没有真正的多线程并发效果,从这一点来看,多线程与 CPU 硬件的升级息息相关。
单 CPU 时代下的多线程:在单 CPU 时代多任务是共享一个 CPU 的,当一个任务占用 CPU 运行时,其他任务就会被挂起,当占用 CPU 的任务时间片用完后,会把 CPU 让给其他任务来使用,所以在单 CPU 时代多线程编程是没有太大意义的,并且线程间频繁的上下文切换还会带来额外开销。
上图所示为在单个 CPU 上运行两个线程,线程 A 和线程 B 是轮流使用 CPU 进行任务处理的,也就是在某个时间内单个 CPU 只执行一个线程上面的任务。当线程 A 的时间片用完后会进行线程上下文切换,也就是保存当前线程 A 的执行上下文,然后切换到线程 B 来占用 CPU 运行任务。
3 多 CPU 时代的多线程
如下图所示为双 CPU 配置,线程 A 和线程 B 各自在自己的 CPU 上执行任务,实现了真正的并行运行。
在多线程编程实践中,线程的个数往往多于 CPU 的个数,所以一般都称多线程并发编程而不是多线程并行编程。
4 为什么要进行多线程并发
意义:多核 CPU 时代的到来打破了单核 CPU 对多线程效能的限制。 多个 CPU 意味着每个线程可以使用自己的 CPU 运行,这减少了线程上下文切换的开销。
但随着对应用系统性能和吞吐量要求的提高,出现了处理海量数据和请求的要求,这些都对高并发编程有着迫切的需求。
5 线程安全问题
谈到线程安全问题,我们先说说什么是共享资源。
共享资源:所谓共享资源,就是说该资源被多个线程所持有或者说多个线程都可以去访问该资源。线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果和问题。
对于线程安全问题,在进行实际的开发操作过程中,我们要分析一下几点内容,确保多线程环境下的线程安全问题。
- 确定是否是多线程环境:多线程环境下操作共享变量需要考虑线程的安全性;
- 确定是否有增删改操作:多线程环境下,如果对共享数据有增加,删除或者修改的操作,需要谨慎。为了保证线程的同步性,必须对该共享数据进行加锁操作,保证多线程环境下,所有的线程能够获取到正确的数据。如生产者与消费者模型,售票模型;
- 多线程下的读操作:如果是只读操作,对共享数据不需要进行锁操作,因为数据本身未发生增删改操作,不会影响获取数据的准确性。
6 共享变量内存可见性问题
先来看下共享变量和内存可见性的定义。
共享变量:非线程私有的变量,共享变量存放于主内存中,所有的线程都有权限对变量进行增删改查操作。
内存可见性:由于数据是存放于内存中的,内存可见性意味着数据是公开的,所有线程都可对可见性的数据进行增删改查操作。
Java 内存模型规定,将所有的变量都存放在主内存(共享内存)中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存,也就是我们所说的线程私有内存,线程读写变量时操作的是自己工作内存中的变量。
当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存。
总结:对于内存可见的共享变量,在对其进行操作时,一定要注意线程的安全问题,保证线程的安全以及数据的准确性,是多线程并发编程的重点。
二 线程的状态详解
1 操作系统线程的生命周期
定义:当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。在线程的生命周期中,它要经过新建 (New)、就绪(Runnable)、运行(Running)、阻塞 (Blocked),和死亡 (Dead) 5 种状态。
从线程的新建 (New) 到死亡 (Dead),就是线程的整个生命周期。
下面我们分别对 5 种不同的状态进行概念解析。
新建 (New):操作系统在进程中新建一条线程,此时线程是初始化状态。
就绪 (Runnable):就绪状态,可以理解为随时待命状态,一切已准备就绪,随时等待运行命令。
运行 (Running):CPU 进行核心调度,对已就绪状态的线程进行任务分配,接到调度命令,进入线程运行状态。
阻塞 (Blocked):线程锁导致的线程阻塞状态。共享内存区域的共享文件,当有两个或两个以上的线程进行非读操作时,只允许一个线程进行操作,其他线程在第一个线程未释放锁之前不可进入操作,此时进入的一个线程是运行状态,其他线程为阻塞状态。
死亡 (Dead):线程工作结束,被操作系统回收。
2 Java 的线程的生命周期及状态
定义: 在 Java 线程的生命周期中,它要经过新建(New),运行(Running),阻塞(Blocked),等待(Waiting),超时等待(Timed_Waiting)和终止状态(Terminal)6 种状态。
从线程的新建(New)到终止状态(Terminal),就是线程的整个生命周期。
Tips :与操作系统相比, Java 线程是否少了 “就绪” 状态 ?其实 Java 线程依然有就绪状态,只不过 Java 线程将 “就绪(Runnable)" 和 “运行(Running)” 两种状态统一归结为 “运行(Running)” 状态。
我们来看下 Java 线程的 6 种状态的概念。
新建 (New):实现 Runnable 接口或者继承 Thead 类可以得到一个线程类,new 一个实例出来,线程就进入了初始状态。
运行 (Running):线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一方式。
阻塞 (Blocked):阻塞状态是线程在进入 synchronized 关键字修饰的方法或者代码块时,由于其他线程正在执行,不能够进入方法或者代码块而被阻塞的一种状态。
等待 (Waiting):执行 wait () 方法后线程进入等待状态,如果没有显示的 notify () 方法或者 notifyAll () 方法唤醒,该线程会一直处于等待状态。
超时等待 (Timed_Waiting):执行 sleep(Long time)方法后,线程进入超时等待状态,时间一到,自动唤醒线程。
终止状态 (Terminal):当线程的 run () 方法完成时,或者主线程的 main () 方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。
3 新建(New)状态详解
实例:
public class ThreadTest implements Runnable{
@Override
public void run() {
System.out.println("线程:"+Thread.currentThread()+" 正在执行...");
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new ThreadTest()); //线程 创建(NEW)状态
}
}
这里仅仅对线程进行了创建,没有执行其他方法。 此时线程的状态就是新建 (New) 状态。
Tips:新建(New)状态的线程,是没有执行 start () 方法的线程。
4 运行(Running)状态详解
定义: 线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一方式。
实例:
public class ThreadTest implements Runnable{
.......
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new ThreadTest()); //线程 创建(NEW)状态
t1. start(); //线程进入 运行(Running)状态
}
}
当线程调用 start () 方法后,线程才进入了运行(Running)状态。
5 阻塞(Blocked)状态详解
定义: 阻塞状态是线程阻塞在进入 synchronized 关键字修饰的方法或者代码块时的状态。
我们先来分析如下代码。
实例:
public class DemoTest implements Runnable{
@Override
public void run() {
testBolockStatus();
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new DemoTest()); //线程 t1创建(NEW)状态
t1.setName("T-one");
Thread t2 = new Thread(new DemoTest()); //线程 t2创建(NEW)状态
t2.setName("T-two");
t1. start(); //线程 t1 进入 运行(Running)状态
t2. start(); //线程 t2 进入 运行(Running)状态
}
public static synchronized void testBolockStatus(){ // 该方法被 synchronized修饰
System.out.println("我是被 synchronized 修饰的同步方法, 正在有线程" +
Thread.currentThread().getName() +
"执行我,其他线程进入阻塞状态排队。");
}
}
代码分析:
首先,请看关键代码:
t1. start(); //线程 t1 进入 运行(Running)状态
t2. start(); //线程 t2 进入 运行(Running)状态
我们将线程 t1 和 t2 进行 运行状态的启动,此时 t1 和 t2 就会执行 run () 方法下的 sync testBolockStatus () 方法。
然后,请看关键代码:
public static synchronized void testBolockStatus(){ // 该方法被 synchronized修饰
testBolockStatus () 方法是被 synchronized 修饰的同步方法。当有 2 条或者 2 条以上的线程执行该方法时, 除了进入方法的一条线程外,其他线程均处于 “阻塞” 状态。
最后,我们看下执行结果:
我是被 synchronized 修饰的同步方法, 正在有线程T-one执行我,其他线程进入阻塞状态排队。
我是被 synchronized 修饰的同步方法, 正在有线程T-two执行我,其他线程进入阻塞状态排队。
执行结果解析:我们有两条线程, 线程名称分别为: T-one 和 T-two。
- 执行结果第一条: T-one 的状态当时为 运行(Running)状态,T-two 状态为 阻塞(Blocked)状态;
- 执行结果第二条: T-two 的状态当时为 运行(Running)状态,T-one 状态为 阻塞(Blocked)状态。
6 等待(Waiting)状态详解
定义: 执行 wait () 方法后线程进入等待状态,如果没有显示的 notify () 方法或者 notifyAll () 方法唤醒,该线程会一直处于等待状态。
我们通过代码来看下,等待(Waiting)状态。
实例:
public class DemoTest implements Runnable{
@Override
public void run() {
try {
testBolockStatus();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new DemoTest()); //线程 t1创建(NEW)状态
t1.setName("T-one");
t1. start(); //线程进入 运行 状态
}
public synchronized void testBolockStatus() throws InterruptedException {
System.out.println("我是线程:" + Thread.currentThread().getName() + ". 我进来了。");
this.wait(); //线程进入 等待状态 ,没有其他线程 唤醒, 会一直等待下去
System.out.println("我是被 synchronized 修饰的同步方法, 正在有线程" +
Thread.currentThread().getName() +
"执行我,其他线程进入阻塞状态排队。");
}
}
注意看下关键代码:
this.wait(); //线程进入 等待状态 ,没有其他线程 唤醒, 会一直等待下去
这里调用了 wait () 方法。线程进入 等待(Waiting)状态。如果没有其他线程唤醒,会一直维持等待状态。
运行结果:
我是线程:T-one. 我进来了。
没有办法打印 wait () 方法后边的执行语句,因为线程已经进入了等待状态。
7 超时等待(Timed-Waiting)状态详解
定义: 执行 sleep(Long time)方法后,线程进入超时等待状态,时间一到,自动唤醒线程。
我们通过代码来看下,超时等待(Timed-Waiting)状态。
实例:
public class DemoTest implements Runnable{
@Override
public void run() {
.....
}
public static void main(String[] args) throws InterruptedException {
.....
}
public synchronized void testBolockStatus() throws InterruptedException {
System.out.println("我是线程:" + Thread.currentThread().getName() + ". 我进来了。");
Thread.sleep(5000); //超时等待 状态 5 秒后自动唤醒线程。
System.out.println("我是被 synchronized 修饰的同步方法, 正在有线程" +
Thread.currentThread().getName() +
"执行我,其他线程进入阻塞状态排队。");
}
}
注意看下关键代码:
Thread.sleep(5000); //超时等待 状态 5 秒后自动唤醒线程。
这里调用了 sleep () 方法。线程进入超时等待(Timed-Waiting)状态。超时等待时间结束,自动唤醒线程继续执行。
运行结果:5 秒后,打印第二条语句。
我是线程:T-one. 我进来了。
我睡醒了。我是被 synchronized 修饰的同步方法, 正在有线程T-one执行我,其他线程进入阻塞状态排队。
8 终止(Terminal)状态定义
定义: 当线程的 run () 方法完成时,或者主线程的 main () 方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。
三 synchronized 关键字
1 synchronized 关键字介绍
概念:synchronized 同步块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。
线程的执行:代码在进入 synchronized 代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的 wait 系列方法时释放该内置锁。
内置锁:即排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。
Tips:由于 Java 中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而 synchronized 的使用就会导致上下文切换。
后续章节我们会引入 Lock 接口和 ReadWriteLock 接口,能在一定场景下很好地避免 synchronized 关键字导致的上下文切换问题。
2 synchronized 关键字的作用
作用:在并发编程中存在线程安全问题,使用 synchronized 关键字能够有效的避免多线程环境下的线程安全问题,线程安全问题主要考虑以下三点:
- 存在共享数据,共享数据是对多线程可见的,所有的线程都有权限对共享数据进行操作;
- 多线程共同操作共享数据。关键字 synchronized 可以保证在同一时刻,只有一个线程可以执行某个同步方法或者同步代码块,同时 synchronized 关键字可以保证一个线程变化的可见性;
- 多线程共同操作共享数据且涉及增删改操作。如果只是查询操作,是不需要使用 synchronized 关键字的,在涉及到增删改操作时,为了保证数据的准确性,可以选择使用 synchronized 关键字。
3 synchronized 的三种使用方式
Java 中每一个对象都可以作为锁,这是 synchronized 实现同步的基础。synchronized 的三种使用方式如下:
- 普通同步方法(实例方法):
锁是当前实例对象
,进入同步代码前要获得当前实例的锁; - 静态同步方法:
锁是当前类的 class 对象
,进入同步代码前要获得当前类对象的锁; - 同步方法块:
锁是括号里面的对象
,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
接下来会对这三种使用方式进行详细的讲解,也是本节课程的核心内容。
4 synchronized 作用于实例方法
为了更加深刻的体会 synchronized 作用于实例方法的使用,我们先来设计一个场景,并根据要求,通过代码的实例进行实现。
场景设计:
- 创建两个线程,分别设置线程名称为 threadOne 和 threadTwo;
- 创建一个共享的 int 数据类型的 count,初始值为 0;
- 两个线程同时对该共享数据进行增 1 操作,每次操作 count 的值增加 1;
- 对于 count 数值加 1 的操作,请创建一个单独的 increase 方法进行实现;
- increase 方法中,先打印进入的线程名称,然后进行 1000 毫秒的 sleep,每次加 1 操作后,打印操作的线程名称和 count 的值;
- 运行程序,观察打印结果。
结果预期:因为 increase 方法有两个打印的语句,不会出现 threadOne 和 threadTwo 的交替打印,一个线程执行完 2 句打印之后,才能给另外一个线程执行。
实例:
public class DemoTest extends Thread {
//共享资源
static int count = 0;
/**
* synchronized 修饰实例方法
*/
public synchronized void increase() throws InterruptedException {
System.out.println(Thread.currentThread().getName() + "获取到锁,其他线程在我执行完毕之前,不可进入。" );
sleep(1000);
count++;
System.out.println(Thread.currentThread().getName() + ": " + count);
}
@Override
public void run() {
try {
increase();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
DemoTest test = new DemoTest();
Thread t1 = new Thread(test);
Thread t2 = new Thread(test);
t1.setName("threadOne");
t2.setName("threadTwo");
t1.start();
t2.start();
}
}
结果验证:
threadTwo 获取到锁,其他线程在我执行完毕之前,不可进入。
threadTwo: 1
threadOne 获取到锁,其他线程在我执行完毕之前,不可进入。
threadOne: 2
从结果可以看出,threadTwo 进入该方法后,休眠了 1000 毫秒,此时线程 threadOne 依然没有办法进入,因为 threadTwo 已经获取了锁,threadOne 只能等待 threadTwo 执行完毕后才可进入执行,这就是 synchronized 修饰实例方法的使用。
Tips:仔细看 DemoTest test = new DemoTest () 这就话,我们创建了一个 DemoTest 的实例对象,对于修饰普通方法,synchronized 关键字的锁即为 test 这个实例对象。
5 synchronized 作用于静态方法
Tips:对于 synchronized 作用于静态方法,锁为当前的 class,要明白与修饰普通方法的区别,普通方法的锁为创建的实例对象。为了更好地理解,我们对第 4 点讲解的代码进行微调,然后观察打印结果。
代码修改:其他代码不变,只修改如下部分代码。
- 新增创建一个实例对象 testNew ;
- 将线程 2 设置为 testNew 。
public static void main(String[] args) throws InterruptedException {
DemoTest test = new DemoTest();
DemoTest testNew = new DemoTest();
Thread t1 = new Thread(test);
Thread t2 = new Thread(testNew);
t1.setName("threadOne");
t2.setName("threadTwo");
t1. start();
t2. start();
}
结果验证:
threadTwo 获取到锁,其他线程在我执行完毕之前,不可进入。
threadOne 获取到锁,其他线程在我执行完毕之前,不可进入。
threadTwo: 1
threadOne: 2
结果分析:我们发现 threadTwo 和 threadOne 同时进入了该方法,为什么会出现这种问题呢?
因为我们此次的修改是新增了 testNew 这个实例对象,也就是说,threadTwo 的锁是 testNew ,threadOne 的锁是 test。
两个线程持有两个不同的锁,不会产生互相 block。相信讲到这里,同学对实例对象锁的作用也了解了,那么我们再次将 increase 方法进行修改,将其修改成静态方法,然后输出结果。
代码修改:
public static synchronized void increase() throws InterruptedException {
System.out.println(Thread.currentThread().getName() + "获取到锁,其他线程在我执行完毕之前,不可进入。" );
sleep(1000);
count++;
System.out.println(Thread.currentThread().getName() + ": " + count);
}
结果验证:
threadOne获取到锁,其他线程在我执行完毕之前,不可进入。
threadOne: 1
threadTwo获取到锁,其他线程在我执行完毕之前,不可进入。
threadTwo: 2
结果分析:我们看到,结果又恢复了正常,为什么会这样?
关键的原因在于,synchronized 修饰静态方法,锁为当前 class,即 DemoTest.class。
public class DemoTest extends Thread {}
无论 threadOne 和 threadTwo 如何进行 new 实例对象的创建,也不会改变锁是 DemoTest.class 的这一事实。
6 synchronized 作用于同步代码块
Tips:对于 synchronized 作用于同步代码,锁为任何我们创建的对象,只要是个对象即可,如 new Object () 可以作为锁,new String () 也可作为锁,当然如果传入 this,那么此时代表当前对象。
我们将代码恢复到第 4 点的知识,然后在第 4 点知识的基础上,再次对代码进行如下修改:
代码修改:
/**
* synchronized 修饰实例方法
*/
static final Object objectLock = new Object(); //创建一个对象锁
public static void increase() throws InterruptedException {
System.out.println(Thread.currentThread().getName() + "获取到锁,其他线程在我执行完毕之前,不可进入。" );
synchronized (objectLock) {
sleep(1000);
count++;
System.out.println(Thread.currentThread().getName() + ": " + count);
}
}
代码解析:我们创建了一个 objectLock 作为对象锁,除了第一句打印语句,让后三句代码加入了 synchronized 同步代码块,当 threadOne 进入时,threadTwo 不可进入后三句代码的执行。
结果验证:
threadOne 获取到锁,其他线程在我执行完毕之前,不可进入。
threadTwo 获取到锁,其他线程在我执行完毕之前,不可进入。
threadOne: 1
threadTwo: 2
四 生产者与消费者案例
1 生产者与消费者模型介绍
定义: 生产者消费者模式是一个十分经典的多线程并发协作的模式。
意义:弄懂生产者消费者问题能够让我们对并发编程的理解加深。
介绍:所谓生产者 - 消费者问题,实际上主要是包含了两类线程,一种是生产者线程用于生产数据,另一种是消费者线程用于消费数据,为了解耦生产者和消费者的关系,通常会采用共享的数据区域。
共享的数据区域就像是一个仓库,生产者生产数据之后直接放置在共享数据区中,并不需要关心消费者的行为。而消费者只需要从共享数据区中去获取数据,就不再需要关心生产者的行为。
2 生产者与消费者三种实现方式
在实现生产者消费者问题时,可以采用三种方式:
- 使用 Object 的 wait/notify 的消息通知机制,本文我们采用该方式结合 synchronized 关键字进行生产者与消费者模式的实现;
- 使用 Lock 的 Condition 的 await/signal 的消息通知机制;
- 使用 BlockingQueue 实现。本文主要将这三种实现方式进行总结归纳。
3 wait 与 notify
Java 中,可以通过配合调用 Object 对象的 wait () 方法和 notify () 方法或 notifyAll () 方法来实现线程间的通信。
wait 方法:我们之前对 wait 方法有了基础的了解,在线程中调用 wait () 方法,将阻塞当前线程,并且释放锁,直至等到其他线程调用了调用 notify () 方法或 notifyAll () 方法进行通知之后,当前线程才能从 wait () 方法出返回,继续执行下面的操作。
notify 方法:即唤醒,notify 方法使原来在该对象上 wait 的线程退出 waiting 状态,使得该线程从等待队列中移入到同步队列中去,等待下一次能够有机会获取到对象监视器锁。
notifyAll 方法:即唤醒全部 waiting 线程,与 notify 方法在效果上一致。
4 生产者与消费者案例
为了更好地理解并掌握生产者与消费者模式的实现,我们先来进行场景设计,然后再通过实例代码进行实现并观察运行结果。
场景设计:
- 创建一个工厂类 ProductFactory,该类包含两个方法,produce 生产方法和 consume 消费方法;
- 对于 produce 方法,当没有库存或者库存达到 10 时,停止生产。为了更便于观察结果,每生产一个产品,sleep 5000 毫秒;
- 对于 consume 方法,只要有库存就进行消费。为了更便于观察结果,每消费一个产品,sleep 5000 毫秒;
- 库存使用 LinkedList 进行实现,此时 LinkedList 即共享数据内存;
- 创建一个 Producer 生产者类,用于调用 ProductFactory 的 produce 方法。生产过程中,要对每个产品从 0 开始进行编号;
- 创建一个 Consumer 消费者类,用于调用 ProductFactory 的 consume 方法;
- 创建一个测试类,main 函数中创建 2 个生产者和 3 个消费者,运行程序进行结果观察。
实例:创建一个工厂类 ProductFactory
class ProductFactory {
private LinkedList<String> products; //根据需求定义库存,用 LinkedList 实现
private int capacity = 10; // 根据需求:定义最大库存 10
public ProductFactory() {
products = new LinkedList<String>();
}
// 根据需求:produce 方法创建
public synchronized void produce(String product) {
while (capacity == products.size()) { //根据需求:如果达到 10 库存,停止生产
try {
System.out.println("警告:线程("+Thread.currentThread().getName() + ")准备生产产品,但产品池已满");
wait(); // 库存达到 10 ,生产线程进入 wait 状态
} catch (InterruptedException e) {
e.printStackTrace();
}
}
products.add(product); //如果没有到 10 库存,进行产品添加
try {
Thread.sleep(5000); //根据需求为了便于观察结果,每生产一个产品,sleep 5000 ms
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程("+Thread.currentThread().getName() + ")生产了一件产品:" + product+";当前剩余商品"+products.size()+"个");
notify(); //生产了产品,通知消费者线程从 wait 状态唤醒,进行消费
}
// 根据需求:consume 方法创建
public synchronized String consume() {
while (products.size()==0) { //根据需求:没有库存消费者进入wait状态
try {
System.out.println("警告:线程("+Thread.currentThread().getName() + ")准备消费产品,但当前没有产品");
wait(); //库存为 0 ,无法消费,进入 wait ,等待生产者线程唤醒
} catch (InterruptedException e) {
e.printStackTrace();
}
}
String product = products.remove(0) ; //如果有库存则消费,并移除消费掉的产品
try {
Thread.sleep(5000);//根据需求为了便于观察结果,每消费一个产品,sleep 5000 ms
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程("+Thread.currentThread().getName() + ")消费了一件产品:" + product+";当前剩余商品"+products.size()+"个");
notify();// 通知生产者继续生产
return product;
}
}
实例:Producer 生产者类创建
class Producer implements Runnable {
private ProductFactory productFactory; //关联工厂类,调用 produce 方法
public Producer(ProductFactory productFactory) {
this.productFactory = productFactory;
}
public void run() {
int i = 0 ; // 根据需求,对产品进行编号
while (true) {
productFactory.produce(String.valueOf(i)); //根据需求 ,调用 productFactory 的 produce 方法
i++;
}
}
}
实例:Consumer 消费者类创建
class Consumer implements Runnable {
private ProductFactory productFactory;
public Consumer(ProductFactory productFactory) {
this.productFactory = productFactory;
}
public void run() {
while (true) {
productFactory.consume();
}
}
}
实例: 创建测试类,2 个生产者,3 个消费者
public class DemoTest extends Thread{
public static void main(String[] args) {
ProductFactory productFactory = new ProductFactory();
new Thread(new Producer(productFactory),"1号生产者"). start();
new Thread(new Producer(productFactory),"2号生产者"). start();
new Thread(new Consumer(productFactory),"1号消费者"). start();
new Thread(new Consumer(productFactory),"2号消费者"). start();
new Thread(new Consumer(productFactory),"3号消费者"). start();
}
}
结果验证:
线程(1号生产者)生产了一件产品:0;当前剩余商品1个
线程(3号消费者)消费了一件产品:0;当前剩余商品0个
警告:线程(2号消费者)准备消费产品,但当前没有产品
警告:线程(1号消费者)准备消费产品,但当前没有产品
线程(2号生产者)生产了一件产品:0;当前剩余商品1个
线程(2号消费者)消费了一件产品:0;当前剩余商品0个
警告:线程(1号消费者)准备消费产品,但当前没有产品
线程(2号生产者)生产了一件产品:1;当前剩余商品1个
线程(3号消费者)消费了一件产品:1;当前剩余商品0个
线程(1号生产者)生产了一件产品:1;当前剩余商品1个
线程(3号消费者)消费了一件产品:1;当前剩余商品0个
线程(2号生产者)生产了一件产品:2;当前剩余商品1个
线程(1号消费者)消费了一件产品:2;当前剩余商品0个
警告:线程(2号消费者)准备消费产品,但当前没有产品
线程(2号生产者)生产了一件产品:3;当前剩余商品1个
...
...
结果分析:
从结果来看,生产者线程和消费者线程合作无间,当没有产品时,消费者线程进入等待;当产品达到 10 个最大库存是,生产者进入等待。这就是经典的生产者 - 消费者模型。
五 volatile 关键字
plus解释: https://blog.csdn.net/qq_40589140/article/details/133382692
1 volatile 关键字介绍
概念:volatile 关键字解决内存可见性问题,是一种弱形式的同步。
介绍:该关键字可以确保当一个线程更新共享变量时,更新操作对其他线程马上可见。当一个变量被声明为 volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。
当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。
2 volatile 与 synchronized 的区别
相似处:volatile 的内存语义和 synchronized 有相似之处,具体来说就是,当线程写入了 volatile 变量值时就等价于线程退出 synchronized 同步块(把写入工作内存的变量值同步到主内存),读取 volatile 变量值时就相当于进入 synchronized 同步块( 先清空本地内存变量值,再从主内存获取最新值)。
区别:使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太笨重,因为它会带来线程上下文的切换开销。具体区别如下:
- volatile 本质是在告诉 jvm 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住;
- volatile 仅能使用在变量级别;synchronized 则可以使用在变量、方法、和类级别的;
- volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性;
- volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞;
- volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化
3 volatile 原理
原理介绍:Java 语言提供了一种弱同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其他线程。
当把变量声明为 volatile 类型后,编译器与运行时都会注意到这个变量是共享的,volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。
Tips:在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile 变量是一种比 sychronized 关键字更轻量级的同步机制。
我们来通过下图对非 volatile 关键字修饰的普通变量的读取方式进行理解,从而更加细致的了解 volatile 关键字修饰的变量。
当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到 CPU 缓存中。如果计算机有多个 CPU,每个线程可能在不同的 CPU 上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。
而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache。
4 volatile 关键字的使用
为了对 volatile 关键字有着更深的使用理解,我们通过一个非常简单的场景的设计来进行学习。
场景设计:
- 创建一个 Student 类,该类有一个 String 属性,name;
- 将 name 的 get 和 set 方法设置为同步方法;
- 使用 synchronized 关键字实现;
- 使用 volatile 关键字实现。
这是一个非常简单的场景,场景中只涉及到了一个类的两个同步方法,通过对两种关键字的实现,能更好的理解 volatile 关键字的使用。
实例: synchronized 关键字实现
public class Student {
private String name;
public synchronized String getName() {
return name;
}
public synchronized void setName(String name) {
this.name = name;
}
}
实例: volatile 关键字实现
public class Student {
private volatile String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
总结:在这里使用 synchronized 和使用 volatile 是等价的,都解决了共享变量 name 的内存可见性问题。
但是前者是独占锁,同时只能有一个线程调用 get()方法,其他调用线程会被阻塞,同时会存在线程上下文切换和线程重新调度的开销,这也是使用锁方式不好的地方。
而后者是非阻塞算法,不会造成线程上下文切换的开销。
六 CAS 操作原理
1 什么是 CAS
概念:CAS
是 CompareAndSwap
的简称,是一种用于在多线程环境下实现同步功能的机制。
从字面上理解就是比较并更新。简单来说,从某一内存上取值 V,和预期值 A 进行比较,如果内存值 V 和预期值 A 的结果相等,那么我们就把新值 B 更新到内存,如果不相等,那么就重复上述操作直到成功为止。
2 CAS 诞生的背景
synchronized 时代:在多线程中为了保持数据的准确性,避免多个线程同时操作某个变量,很多情况下利用关键字 synchronized 实现同步锁。
使用 synchronized 关键字可以使操作的线程排队等待运行,可以说是一种悲观策略,认为线程会修改数据,所以开始就把持有锁的线程锁住,其他线程只能是挂起状态,等待锁的释放,所以同步锁带来了效率问题。
synchronized 时代效率问题:在线程执行的时候,获得锁的线程在运行,其他被挂起的线程只能等待着持有锁的线程释放锁才有机会运行,在效率上都浪费在等待上。
在很多的线程切换的时候,由于有同步锁,就要涉及到锁的释放,加锁,这又是一个很大的时间开销。
volatile 时代:与锁(阻塞机制)的方式相比有一种更有效地方法,非阻塞机制,同步锁带来了线程执行时候之间的阻塞,而这种非阻塞机制在多个线程竞争同一个数据的时候不会发生阻塞的情况,这样在时间上就可以节省出很多的时间。
我们会想到用 volatile,使用 volatile 不会造成阻塞,volatile 保证了线程之间的内存可见性和程序执行的有序性可以说已经很好的解决了上面的问题。
volatile 时代原子操作问题:一个很重要的问题就是,volatile 不能保证原子性,对于复合操作,例如 i++ 这样的程序包含三个原子操作:取值,增加,赋值。
3 CAS 操作诞生的意义
意义:从上边 CAS 操作诞生的背景所说的,CAS(Compare And Swap 比较和交换)解决了 volatile 不能保证原子性的问题。从而 CAS 操作即能够解决锁的效率问题,也能够保证操作的原子性。
Tips:在 JDK1.5 新增的 java.util.concurrent (JUC java 并发工具包) 就是建立在 CAS 之上的。相比于 synchronized 这种堵塞算法, CAS 是非堵塞算法的一种常见实现。所以 JUC 在性能上有了很大的提升。
4 CAS 操作原理
CAS 主要包含三个操作数,内存位置 V,进行比较的原值 A,和新值 B。
当位置 V 的值与 A 相等时,CAS 才会通过原子方式用新值 B 来更新 V,否则不会进行任何操作。无论位置 V 的值是否等于 A,都将返回 V 原有的值。
上面说到了同步锁是一种悲观策略,CAS 是一种乐观策略,每次都开放自己,不用担心其他线程会修改变量等数据,如果其他线程修改了数据,那么 CAS 会检测到并利用算法重新计算。
CAS 也是同时允许一个线程修改变量,其他的线程试图修改都将失败,但是相比于同步锁,CAS 对于失败的线程不会将他们挂起,他们下次仍可以参加竞争,这也就是非阻塞机制的特点。
5 ABA 问题
ABA 问题描述:
- 假设有两个线程,线程 1 和线程 2,线程 1 工作时间需要 10 秒,线程 2 工作需要 2 秒;
- 主内存值为 A,第一轮线程 1 和线程 2 都把 A 拿到自己的工作内存;
- 第 2 秒,线程 2 开始执行,线程 2 工作完成把 A 改成了 B ;
- 第 4 秒,线程 2 把 B 又改成了 A,然后就线程 2 进入休眠状态;
- 第 10 秒,线程 1 工作完成,看到期望为 A 真实值也是 A 认为没有人动过,其实 A 已经经过了修改,只不过又改了回去,然后线程 1 进行 CAS 操作。
ABA 问题解决:为了解决这个问题,在每次进行操作的时候加上一个版本号或者是时间戳即可。
七 Unsafe 类方法介绍
1 方法介绍
方法 | 作用 |
---|---|
objectFieldOffset(Field) | 返回指定的变量在所属类中的内存偏移地址,该偏移地址仅仅在该 UnSafe 函数中访问指定字段时使用。 |
arrayBaseOffset(Class) | 获取取数组中第一个元素的地址。 |
arrayIndexScale(Class) | 获取数组中一个元素占用的字节。 |
compareAndSwapLong(Object,long,long,long) | 比较对象 obj 中偏移量为 offset 的变量的值是否与 expect 相等,相等则使用 update 值更新,然后返回 true,否则返回 false。 |
longgetLongvolatile(Object,long) | 获取对象 obj 中偏移量为 offset 的变量对应 volatile 语义的值。 |
void putLongvolatile(Object,long,long) | 设置 obj 对象中 offset 偏移的类型为 long 的 field 的值为 value, 支持 volatile 语义。 |
putOrderedLong(Object,long,long) | 设置 obj 对象中 offset 偏移地址对应的 long 型 field 的值为 value。这是一个有延迟的 putLongvolatile 方法,并且不保证值修改对其他线程立刻可见。只有在变量使用 volatile 修饰并且预计会被意外修改时才使用该方法。 |
unpark(Object) | 唤醒调用 park 后阻塞的线程。 |
2 park 方法介绍
方法描述: void park(booleanisAbsolute,longtime):阻塞当前线程,其中参数 isAbsolute 等于 false 且 time 等于 0 表示一直阻塞。
方法解读:time 大于 0 表示等待指定的 time 后阻塞线程会被唤醒,这个 time 是个相对值,是个增量值,也就是相对当前时间累加 time 后当前线程就会被唤醒。如果 isAbsolute 等于 true,并且 time 大于 0,则表示阻塞的线程到指定的时间点后会被唤醒。
这里 time 是个绝对时间,是将某个时间点换算为 ms 后的值。另外,当其他线程调用了当前阻塞线程的 interrupt 方法而中断了当前线程时,当前线程也会返回,而当其他线程调用了 unPark 方法并且把当前线程作为参数时当前线程也会返回。
3 JDK8 新增的函数
方法 | 作用 |
---|---|
getAndSetLong(Object, long, long) | 获取对象 obj 中偏移量为 offset 的变量 volaile 语义的当前值,并设置变量 volaile 语义的值为 update。 |
getAndAddLong(Object,long,long) | 方法获取 object 中偏移量为 offset 的 volatile 变量的当前值,并设置变量值为原始值加上 addValue |
八 Unsafe 类的使用
1 Unsafe 类简介
Unsafe 类是 Java 整个并发包底层实现的核心,它具有像 C++ 的指针一样直接操作内存的能力,而这也就意味着其越过了 JVM 的限制。
Unsafe 类有如下的特点:
- Unsafe 不受 JVM 管理,也就无法被自动 GC,需要手动 GC,容易出现内存泄漏;
- Unsafe 的大部分方法中必须提供原始地址 (内存地址) 和被替换对象的地址,偏移量需自行计算,一旦出现问题必然是 JVM 崩溃级别的异常,会导致整个应用程序直接 crash;
- 直接操作内存,也意味着其速度更快,在高并发的条件之下能够很好地提高效率。
2 Unsafe 类的创建
Unsafe 类是不可以通过 new 关键字直接创建的。Unsafe 类的构造函数是私有的,而对外提供的静态方法 Unsafe.getUnsafe () 又对调用者的 ClassLoader 有限制 ,如果这个方法的调用者不是由 Boot ClassLoader 加载的,则会报错。
实例:通过 main 方法进行调用,报错。
import sun.misc.Unsafe;
import sun.misc.VM;
import sun.reflect.Reflection;
public class DemoTest {
public static void main(String[] args) {
getUnsafe();
}
public static Unsafe getUnsafe() {
Class unsafeClass = Reflection.getCallerClass();
if (!VM.isSystemDomainLoader(unsafeClass.getClassLoader())) {
throw new SecurityException("Unsafe");
} else {
return null;
}
}
}
运行结果:
Exception in thread "main" java.lang.InternalError: CallerSensitive annotation expected at frame 1
at sun.reflect.Reflection.getCallerClass(Native Method)
at leeCode.DemoTest.getUnsafe(DemoTest.java:12)
at leeCode.DemoTest.main(DemoTest.java:9)
报错原因: Java 源码中由开发者自定义的类都是由 Appliaction ClassLoader 加载的,也就是说 main 函数所依赖的 jar 包都是 ClassLoader 加载的,所以会报错。
所以正常情况下我们无法直接使用 Unsafe ,如果需要使用它,则需要利用反射
实例:通过反射,成功加载 Unsafe 类。
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class DemoTest {
public static void main(String[] args) {
Unsafe unsafe = getUnsafe();
System.out.println("Unsafe 加载成功:" + unsafe);
}
public static Unsafe getUnsafe() {
Unsafe unsafe = null;
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
unsafe = (Unsafe) field.get(null);
} catch (Exception e) {
e.printStackTrace();
}
return unsafe;
}
}
结果验证:
Unsafe 加载成功:sun.misc.Unsafe@677327b6
总结:Unsafe 类的加载必须使用反射进行,否则会报错。
3 Unsafe 类操作对象属性
操作对象属性的常用方法有:
- public native Object getObject(Object o, long offset):获取一个 Java 对象中偏移地址为 offset 的属性的值,此方法可以突破修饰符的限制,类似的方法有 getInt ()、getDouble () 等,同理还有 putObject () 方法;
- public native Object getObjectVolatile(Object o, long offset):强制从主存中获取目标对象指定偏移量的属性值,类似的方法有 getIntVolatile (),getDoubleVolatile () 等,同理还有 putObjectVolatile ();
- public native void putOrderedObject(Object o, long offset, Object x):设置目标对象中偏移地址 offset 对应的对象类型属性的值为指定值。这是一个有序或者有延迟的 putObjectVolatile () 方法,并且不保证值的改变被其他线程立即看到。只有在属性被 volatile 修饰并且期望被修改的时候使用才会生效,类似的方法有 putOrderedInt () 和 putOrderedLong ();
- public native long objectFieldOffset(Field f):返回给定的非静态属性在它的类的存储分配中的位置 (偏移地址),然后可根据偏移地址直接对属性进行修改,可突破属性的访问修饰符限制。
实例:
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class DemoTest {
private String name;
public static void main(String[] args) {
Unsafe unsafe = getUnsafe();
try {
DemoTest directMemory = (DemoTest) unsafe.allocateInstance(DemoTest.class);
//获取name属性
long nameOffset = unsafe.objectFieldOffset(DemoTest.class.getDeclaredField("name"));
//设置name属性
unsafe.putObject(directMemory, nameOffset, "并发编程");
System.out.println("属性设置成功:" + directMemory.getName());
} catch (Exception e) {
e.printStackTrace();
}
}
public static Unsafe getUnsafe() {
Unsafe unsafe = null;
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
unsafe = (Unsafe) field.get(null);
} catch (Exception e) {
e.printStackTrace();
}
return unsafe;
}
public void setName(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
结果验证:
属性设置成功:并发编程
4 Unsafe 操作数组元素
Unsafe 操作数组元素主要有如下两个方法:
- public native int arrayBaseOffset(Class arrayClass):返回数组类型的第一个元素的偏移地址 (基础偏移地址);
- public native int arrayIndexScale(Class arrayClass):返回数组中元素与元素之间的偏移地址的增量,配合 arrayBaseOffset () 使用就可以定位到任何一个元素的地址。
实例:
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class DemoTest {
private static String[] names = {"多线程", "Java", "并发编程"};
public static void main(String[] args) {
Unsafe unsafe = getUnsafe();
try {
Class<?> a = String[].class;
int base = unsafe.arrayBaseOffset(a);
int scale = unsafe.arrayIndexScale(a);
// base + i * scale 即为字符串数组下标 i 在对象的内存中的偏移地址
System.out.println(unsafe.getObject(names, (long) base + 2 * scale));
} catch (Exception e) {
e.printStackTrace();
}
}
public static Unsafe getUnsafe() {
Unsafe unsafe = null;
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
unsafe = (Unsafe) field.get(null);
} catch (Exception e) {
e.printStackTrace();
}
return unsafe;
}
}
结果验证:
并发编程
通过对数组的元素的地址进行内存偏移,最后得到的结果为最后一个元素,并发编程。base + 2 * scale 表示字符串数组下标 i 在对象的内存中的偏移地址,偏移两个元素,得到最后一个元素。