多线程的安全与同步

多线程的操作原则

多线程 AVO 原则

A:即 Atomic,原子性操作原则。对基本数据类型变量的读和写是保证原子性的,要么都成功,要么都失败,这些操作不可中断。
V:即 volatile,可见性原则。使用 volatile 关键字,保证了变量的可见性,到主存拿数据,不是到缓存里拿。
O:即 order, 就是有序性。代码的执行顺序,在代码编译前的和代码编译后的执行顺序不变。

单 CPU 时代的多线程

概念:单核 CPU 上,同一时刻只能有一条线程运行,单核 CPU 上运行的单线程程序和多线程程序,从运行效率上看没有差别。换而言之,单 CPU 时代,没有真正的多线程并发效果,从这一点来看,多线程与 CPU 硬件的升级息息相关。

在单 CPU 时代,多任务是共享一个 CPU 的,当一个任务占用 CPU 运行时,其他任务就会被挂起,当占用 CPU 的任务时间片用完后,会把 CPU 让给其他任务来使用,所以在单 CPU 时代多线程编程是没有太大意义的,并且线程间频繁的上下文切换还会带来额外开销。

多 CPU 时代的多线程

如下图所示为双 CPU 配置,线程 A 和线程 B 各自在自己的 CPU 上执行任务,实现了真正的并行运行。

在多线程编程实践中,线程的个数往往多于 CPU 的个数,所以一般都称多线程并发编程而不是多线程并行编程。

为什么要进行多线程并发

意义:多核 CPU 时代的到来打破了单核 CPU 对多线程效能的限制。 多个 CPU 意味着每个线程可以使用自己的 CPU 运行,这减少了线程上下文切换的开销。

而随着对应用系统性能和吞吐量要求的提高,出现了处理海量数据和请求的要求,这些都对高并发编程有着迫切的需求。

线程安全问题

谈到线程安全问题,我们先说说什么是共享资源。

共享资源:所谓共享资源,就是说该资源被多个线程所持有或者说多个线程都可以去访问该资源。线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果和问题。

对于线程安全问题,在进行实际的开发操作过程中,我们要分析一下几点内容,确保多线程环境下的线程安全问题。

  • 确定是否是多线程环境:多线程环境下操作共享变量需要考虑线程的安全性;
  • 确定是否有增删改操作:多线程环境下,如果对共享数据有增加,删除或者修改的操作,需要谨慎。为了保证线程的同步性,必须对该共享数据进行加锁操作,保证多线程环境下,所有的线程能够获取到正确的数据。如生产者与消费者模型,售票模型;
  • 多线程下的读操作:如果是只读操作,对共享数据不需要进行锁操作,因为数据本身未发生增删改操作,不会影响获取数据的准确性。

共享变量内存可见性问题

共享变量:非线程私有的变量,共享变量存放于主内存中,所有的线程都有权限对变量进行增删改查操作。

内存可见性:由于数据是存放于内存中的,内存可见性意味着数据是公开的,所有线程都可对可见性的数据进行增删改查操作。

Java 内存模型规定,将所有的变量都存放在主内存(共享内存)中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存,也就是我们所说的线程私有内存,线程读写变量时操作的是自己工作内存中的变量。

当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存。

线程的状态详解

操作系统线程的生命周期

定义:当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。在线程的生命周期中,它要经过新建 (New)、就绪(Runnable)、运行(Running)、阻塞 (Blocked),和死亡 (Dead) 5 种状态。

从线程的新建 (New) 到死亡 (Dead),就是线程的整个生命周期。

下面我们分别对 5 种不同的状态进行概念解析。

新建 (New):操作系统在进程中新建一条线程,此时线程是初始化状态。

就绪 (Runnable):就绪状态,可以理解为随时待命状态,一切已准备就绪,随时等待运行命令。

运行 (Running):CPU 进行核心调度,对已就绪状态的线程进行任务分配,接到调度命令,进入线程运行状态。

阻塞 (Blocked):线程锁导致的线程阻塞状态。共享内存区域的共享文件,当有两个或两个以上的线程进行非读操作时,只允许一个线程进行操作,其他线程在第一个线程未释放锁之前不可进入操作,此时进入的一个线程是运行状态,其他线程为阻塞状态。

死亡 (Dead):线程工作结束,被操作系统回收。

Java 的线程的生命周期及状态

定义: 在 Java 线程的生命周期中,它要经过新建(New),运行(Running),阻塞(Blocked),等待(Waiting),超时等待(Timed_Waiting)和终止状态(Terminal)6 种状态。

从线程的新建(New)到终止状态(Terminal),就是线程的整个生命周期。

我们来看下 Java 线程的 6 种状态的概念。

新建 (New):实现 Runnable 接口或者继承 Thead 类可以得到一个线程类,new 一个实例出来,线程就进入了初始状态。

运行 (Running):线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一方式。

阻塞 (Blocked):阻塞状态是线程在进入 synchronized 关键字修饰的方法或者代码块时,由于其他线程正在执行,不能够进入方法或者代码块而被阻塞的一种状态。

等待 (Waiting):执行 wait () 方法后线程进入等待状态,如果没有显示的 notify () 方法或者 notifyAll () 方法唤醒,该线程会一直处于等待状态。

超时等待 (Timed_Waiting):执行 sleep(Long time)方法后,线程进入超时等待状态,时间一到,自动唤醒线程。

终止状态 (Terminal):当线程的 run () 方法完成时,或者主线程的 main () 方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。

新建(New)状态详解

实例

public class ThreadTest implements Runnable{@Overridepublic 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 () 方法的线程。

运行(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)状态。

阻塞(Blocked)状态详解

定义: 阻塞状态是线程阻塞在进入 synchronized 关键字修饰的方法或者代码块时的状态。
我们先来分析如下代码。

实例

public class DemoTest implements Runnable{@Overridepublic 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)状态。

等待(Waiting)状态详解

定义: 执行 wait () 方法后线程进入等待状态,如果没有显示的 notify () 方法或者 notifyAll () 方法唤醒,该线程会一直处于等待状态。

实例:

public class DemoTest implements Runnable{@Overridepublic 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 () 方法后边的执行语句,因为线程已经进入了等待状态。

超时等待(Timed-Waiting)状态详解

定义: 执行 sleep(Long time)方法后,线程进入超时等待状态,时间一到,自动唤醒线程。

实例

public class DemoTest implements Runnable{@Overridepublic 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执行我,其他线程进入阻塞状态排队。

终止(Terminal)状态定义

定义: 当线程的 run () 方法完成时,或者主线程的 main () 方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。

synchronized 关键字

synchronized 关键字介绍

概念:synchronized 同步块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。

线程的执行:代码在进入 synchronized 代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的 wait 系列方法时释放该内置锁。

内置锁:即排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。

Tips:由于 Java 中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而 synchronized 的使用就会导致上下文切换。
后续章节会引入 Lock 接口和 ReadWriteLock 接口,能在一定场景下很好地避免 synchronized 关键字导致的上下文切换问题。

synchronized 关键字的作用

作用:在并发编程中存在线程安全问题,使用 synchronized 关键字能够有效的避免多线程环境下的线程安全问题,线程安全问题主要考虑以下三点:

  • 存在共享数据,共享数据是对多线程可见的,所有的线程都有权限对共享数据进行操作;
  • 多线程共同操作共享数据。关键字 synchronized 可以保证在同一时刻,只有一个线程可以执行某个同步方法或者同步代码块,同时 synchronized 关键字可以保证一个线程变化的可见性;
  • 多线程共同操作共享数据且涉及增删改操作。如果只是查询操作,是不需要使用 synchronized 关键字的,在涉及到增删改操作时,为了保证数据的准确性,可以选择使用 synchronized 关键字。

synchronized 的三种使用方式

Java 中每一个对象都可以作为锁,这是 synchronized 实现同步的基础。synchronized 的三种使用方式如下:

  • 普通同步方法(实例方法):锁的是当前实例对象 ,进入同步代码前要获得当前实例的锁;
  • 静态同步方法:锁的是当前类的 class 对象 ,进入同步代码前要获得当前类对象的锁;
  • 同步方法块:锁的是括号里面的对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁。

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 {sleep(1000);count++;System.out.println(Thread.currentThread().getName() + ": " + count);}@Overridepublic 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:用synchronized修饰线程类中的普通方法只能锁住该线程的一个实例对象,这时候如果有两个该线程类的实例对象对共享变量进行操作,锁就失去作用了。

实例验证
其他代码不变,只修改如下部分代码:

  • 新增创建一个实例对象 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。

synchronized 作用于静态方法

对于上面出现的锁失效的问题要如何解决呢?
我们将 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。无论 threadOne 和 threadTwo 如何进行 new 实例对象的创建,也不会改变锁是 DemoTest.class 的这一事实。

synchronized 作用于同步代码块

Tips:对于 synchronized 作用于同步代码,锁为任何我们创建的对象,只要是个对象即可,如 new Object () 可以作为锁,new String () 也可作为锁,当然如果传入 this,那么此时代表当前对象。

我们之前代码基础上进行如下修改:
代码修改

/*** 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

生产者与消费者案例

生产者与消费者模型介绍

生产者消费者模式是一个十分经典的多线程并发协作的模式,弄懂生产者消费者问题能够让我们对并发编程的理解加深。

所谓生产者 - 消费者问题,实际上主要是包含了两类线程,一种是生产者线程用于生产数据,另一种是消费者线程用于消费数据,为了解耦生产者和消费者的关系,通常会采用共享的数据区域。

共享的数据区域就像是一个仓库,生产者生产数据之后直接放置在共享数据区中,并不需要关心消费者的行为。而消费者只需要从共享数据区中去获取数据,就不再需要关心生产者的行为。

生产者与消费者三种实现方式

在实现生产者消费者问题时,可以采用三种方式:
使用 Object 的 wait/notify 的消息通知机制;
使用 Lock 的 Condition 的 await/signal 的消息通知机制;
使用 BlockingQueue 实现。

wait 与 notify

Java 中,可以通过配合调用 Object 对象的 wait () 方法和 notify () 方法或 notifyAll () 方法来实现线程间的通信。

  • wait 方法:我们之前对 wait 方法有了基础的了解,在线程中调用 wait () 方法,将阻塞当前线程,并且释放锁,直至其他线程调用notify () 方法或 notifyAll () 方法进行通知之后,当前线程才能从 wait () 方法出返回,继续执行下面的操作。

  • notify 方法:即唤醒,notify 方法使原来在该对象上 wait 的线程退出 waiting 状态,使得该线程从等待队列移入到同步队列,等待下一次机会获取对象监视器锁。

  • notifyAll 方法:即唤醒全部 waiting 线程,与 notify 方法在效果上一致。

生产者与消费者案例

为了更好地理解并掌握生产者与消费者模式的实现,我们先来进行场景设计,然后再通过实例代码进行实现并观察运行结果。

场景设计

  • 创建一个工厂类 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; // 根据需求:定义最大库存 10public 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 关键字

volatile 关键字介绍

概念:volatile 关键字解决内存可见性问题,是一种弱形式的同步。

介绍:该关键字可以确保当一个线程更新共享变量时,更新操作对其他线程马上可见。当一个变量被声明为 volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。

当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。

volatile 与 synchronized 的区别

相似处:volatile 的内存语义和 synchronized 有相似之处,具体来说就是,当线程写入了 volatile 变量值时就等价于线程退出 synchronized 同步块(把写入工作内存的变量值同步到主内存),读取 volatile 变量值时就相当于进入 synchronized 同步块( 先清空本地内存变量值,再从主内存获取最新值)。

区别:使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太笨重,因为它会带来线程上下文的切换开销。具体区别如下:

  • volatile 本质是在告诉 jvm 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住;
  • volatile 仅能使用在变量级别;synchronized 则可以使用在变量、方法、和类级别的;
  • volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性;
  • volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞;
  • volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。

volatile 原理

原理介绍:Java 语言提供了一种弱同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其他线程。

当把变量声明为 volatile 类型后,编译器与运行时都会注意到这个变量是共享的,volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。

Tips:在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile 变量是一种比 sychronized 关键字更轻量级的同步机制。

我们来通过下图对非 volatile 关键字修饰的普通变量的读取方式进行理解,从而更加细致的了解 volatile 关键字修饰的变量。

当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到 CPU 缓存中。如果计算机有多个 CPU,每个线程可能在不同的 CPU 上被处理,这意味着每个变量可以拷贝到不同的 CPU cache 中。

而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache。、

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 操作原理

什么是 CAS

概念:CAS 是 CompareAndSwap 的简称,是一种用于在多线程环境下实现同步功能的机制。

从字面上理解就是比较并更新。简单来说,从某一内存上取值 V,和预期值 A 进行比较,如果内存值 V 和预期值 A 的结果相等,那么我们就把新值 B 更新到内存,如果不相等,那么就重复上述操作直到成功为止。

CAS 诞生的背景

synchronized 时代:在多线程中为了保持数据的准确性,避免多个线程同时操作某个变量,很多情况下利用关键字 synchronized 实现同步锁。

使用 synchronized 关键字可以使操作的线程排队等待运行,可以说是一种悲观策略,认为线程会修改数据,所以开始就把持有锁的线程锁住,其他线程只能是挂起状态,等待锁的释放,所以同步锁带来了效率问题。

synchronized 时代效率问题:在线程执行的时候,获得锁的线程在运行,其他被挂起的线程只能等待着持有锁的线程释放锁才有机会运行,很多时间浪费在等待上。

在很多的线程切换的时候,由于有同步锁,就要涉及到锁的释放,加锁,这又是一个很大的时间开销。

volatile 时代:与锁(阻塞机制)的方式相比有一种更有效地方法,非阻塞机制,同步锁带来了线程执行时相互的阻塞,而这种非阻塞机制在多个线程竞争同一个数据时不会发生阻塞,这样可以极大提升效率。

我们会想到用 volatile,使用 volatile 不会造成阻塞,volatile 保证了线程之间的内存可见性和程序执行的有序性可以说已经很好的解决了上面的问题。

volatile 时代原子操作问题:一个很重要的问题就是,volatile 不能保证原子性,对于复合操作,例如 i++ 这样的程序包含三个原子操作:取值,增加,赋值。

CAS 操作诞生的意义

意义: CAS(Compare And Swap 比较和交换)解决了 volatile 不能保证原子性的问题。从而 CAS 操作既能够解决锁的效率问题,也能够保证操作的原子性。

Tips:在 JDK1.5 新增的 java.util.concurrent (JUC java 并发工具包) 就是建立在 CAS 之上的。相比于 synchronized 这种堵塞算法, CAS 是非堵塞算法的一种常见实现。所以 JUC 在性能上有了很大的提升。

CAS 操作原理

CAS 主要包含三个操作数,内存位置 V,进行比较的原值 A,和新值 B。

当位置 V 的值与 A 相等时,CAS 才会通过原子方式用新值 B 来更新 V,否则不会进行任何操作。无论位置 V 的值是否等于 A,都将返回 V 原有的值。

上面说到了同步锁是一种悲观策略,CAS 则是一种乐观策略,每次都开放自己,不用担心其他线程会修改变量等数据,如果其他线程修改了数据,那么 CAS 会检测到并利用算法重新计算。

CAS 也是同时允许一个线程修改变量,其他的线程试图修改都将失败,但是相比于同步锁,CAS 对于失败的线程不会将他们挂起,他们下次仍可以参加竞争,这也是非阻塞机制的特点。

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 类方法介绍

JDK jar 包中的 Unsafe 类提供了硬件级别的原子性操作,Unsafe 类中的方法都是 native 方法,它们使用 JNI 的方式访问本地 C++实现库。

方法介绍

方法 作用
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
unpark(Object) 唤醒调用 park 后阻塞的线程。

park 方法介绍

方法描述: void park(booleanisAbsolute,longtime):阻塞当前线程,其中参数 isAbsolute 等于 false 且 time 等于 0 表示一直阻塞。

方法解读:time 大于 0 表示等待指定的 time 后阻塞线程会被唤醒,这个 time 是个相对值,是个增量值,也就是相对当前时间累加 time 后当前线程就会被唤醒。如果 isAbsolute 等于 true,并且 time 大于 0,则表示阻塞的线程到指定的时间点后会被唤醒。

这里 time 是个绝对时间,是将某个时间点换算为 ms 后的值。另外,当其他线程调用了当前阻塞线程的 interrupt 方法而中断了当前线程时,当前线程也会返回,而当其他线程调用了 unPark 方法并且把当前线程作为参数时当前线程也会返回。

JDK8 新增的函数

方法 作用
getAndSetLong(Object, long, long) 获取对象 obj 中偏移量为 offset 的变量 volaile 语义的当前值,并设置变量 volaile 语义的值为 update。
getAndAddLong(Object,long,long) 方法获取 object 中偏移量为 offset 的 volatile 变量的当前值,并设置变量值为原始值加上 addValue

Unsafe 类的使用

Unsafe 类简介

Unsafe 类是 Java 整个并发包底层实现的核心,它具有像 C++ 的指针一样直接操作内存的能力,而这也就意味着其越过了 JVM 的限制。

Unsafe 类有如下的特点:

  • Unsafe 不受 JVM 管理,也就无法被自动 GC,需要手动 GC,容易出现内存泄漏;
  • Unsafe 的大部分方法中必须提供原始地址 (内存地址) 和被替换对象的地址,偏移量需自行计算,一旦出现问题必然是 JVM 崩溃级别的异常,会导致整个应用程序直接 crash;
  • 直接操作内存,也意味着其速度更快,在高并发的条件之下能够很好地提高效率。

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 1at 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 类的加载必须使用反射进行,否则会报错。

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;}
}

结果验证

属性设置成功:并发编程

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 在对象的内存中的偏移地址,偏移两个元素,得到最后一个元素。

ps:以上内容来自对慕课教程的学习与总结。

Java并发编程进阶——多线程的安全与同步相关推荐

  1. 【Java 并发编程】多线程、线程同步、死锁、线程间通信(生产者消费者模型)、可重入锁、线程池

    并发编程(Concurrent Programming) 进程(Process).线程(Thread).线程的串行 多线程 多线程的原理 多线程的优缺点 Java并发编程 默认线程 开启新线程 `Ru ...

  2. Java并发编程进阶——并发锁

    1 JAVA 多线程锁介绍 1.1 悲观锁 定义:悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改(很悲观),所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于 ...

  3. Java 并发编程(多线程)

    线程和进程相关概念 创建线程的方式 线程的生命周期 线程之间如何通讯 线程调度策略 线程安全解决方案 synchronized和Lock的区别 死锁和解决方案 线程常用的方法 wait()和 slee ...

  4. 【Java并发编程实战14】构建自定义同步工具(Building-Custom-Synchronizers)

    JDK包含许多存在状态依赖的类,例如FutureTask.Semaphore和BlockingQueue,他们的一些操作都有前提条件,例如非空.任务已完成等. 创建状态依赖类的最简单的房就是在JDK提 ...

  5. Java并发编程的艺术(八)——闭锁、同步屏障、信号量详解

    1. 闭锁:CountDownLatch 1.1 使用场景 若有多条线程,其中一条线程需要等到其他所有线程准备完所需的资源后才能运行,这样的情况可以使用闭锁. 1.2 代码实现 // 初始化闭锁,并设 ...

  6. java 并发框架源码_某网Java并发编程高阶技术-高性能并发框架源码解析与实战(云盘下载)...

    第1章 课程介绍(Java并发编程进阶课程) 什么是Disruptor?它一个高性能的异步处理框架,号称"单线程每秒可处理600W个订单"的神器,本课程目标:彻底精通一个如此优秀的 ...

  7. 【Java并发编程】并发编程大合集

    转载请注明出处:http://blog.csdn.net/ns_code/article/details/17539599 为了方便各位网友学习以及方便自己复习之用,将Java并发编程系列内容系列内容 ...

  8. Java并发编程学习 + 原理分析(建议收藏)

    总结不易,如果对你有帮助,请点赞关注支持一下 微信搜索程序dunk,关注公众号,获取博客源码 Doug Lea是一个无私的人,他深知分享知识和分享苹果是不一样的,苹果会越分越少,而自己的知识并不会因为 ...

  9. Java并发编程的学习

    转载出处:http://blog.csdn.net/ns_code/article/details/17539599 为了方便各位网友学习以及方便自己复习之用,将Java并发编程系列内容系列内容按照由 ...

最新文章

  1. ubuntu14.04 安装 bcm43142无线网卡
  2. CTL_CODE说明
  3. java8新特性以及原因_JAVA8 十大新特性详解
  4. 一些上流的CSS3图片样式
  5. 联想Z6 Pro 5G探索版官宣 常程:有5G才最6
  6. gzip: File too large错误
  7. extjs4.1单击treepanel节点收缩叶子节点
  8. 10分钟搭建一套代码质量监控平台,开发从此不敢摸鱼
  9. 一分钟电脑自动关机的代码
  10. H5+JS表格全选和删除
  11. hog特征的matlab实现
  12. The eighth of Word-Day
  13. 阿里云服务器的登录方法
  14. fedora安装视频播放器
  15. 博弈论夏普利值!提高机器学习可解释性的新方法!
  16. 你还记得吗?这几种超级重要的统计学分布
  17. Android aab安装到手机
  18. 针不戳 java后端开发岗面经分享,面经+知识点+总结
  19. The return type of function ‘Custom Source‘ could not be determined automatically, due to type erasu
  20. python开源ide_前5个开源Python IDE

热门文章

  1. 北京邮电大学计算机学院考研经历之找导师
  2. 记录ubuntu启动卡在logo界面有鼠标进不了桌面的经历,以及安装ubuntu踩的坑
  3. Python学习笔记(五.数据分析 ——上)
  4. canon selphy cp900网络连接参数修改,实现无线路由转接
  5. 柔性产线的数字孪生加速器:Unity发布UMT工具包
  6. 如何通过几个简单的步骤编写一个漂亮的初级开发者简历
  7. mysql基础 Task04:集合运算
  8. hadoop适合与不适合的应用场景
  9. Python 匿名函数之 lambda
  10. 开发人员需要每周写工作周报吗?