Java并发编程原理一

简介

java是一个支持多线程的开发语言。多线程可以在包含多个CPU核心的机器上同时处理多个不同的任务,优化资源的使用率,提升程序的效率。在一些对性能要求比较高场合,多线程是java程序调优的重要方面。

Java并发编程主要涉及以下几个部分:

  1. 并发编程三要素
    原子性:即一个不可再被分割的颗粒。在Java中原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
    有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
    可见性:当多个线程访问同一个变量时,如果其中一个线程对其作了修改,其他线程能立即获取到最新的值。
  2. 线程的五大状态
    创建状态:当用 new 操作符创建一个线程的时候
    就绪状态:调用 start 方法,处于就绪状态的线程并不一定马上就会执行 run 方法,还需要等待CPU的调度
    运行状态:CPU 开始调度线程,并开始执行 run 方法
    阻塞状态:线程的执行过程中由于一些原因进入阻塞状态比如:调用 sleep 方法、尝试去得到一个锁等等
    死亡状态:run 方法执行完 或者 执行过程中遇到了一个异常
  3. 悲观锁与乐观锁
    悲观锁:每次操作都会加锁,会造成线程阻塞。
    乐观锁:每次操作不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止,不会造成线程阻塞。
  4. 线程之间的协作
    线程间的协作有:wait/notify/notifyAll等
  5. synchronized 关键字
    synchronized是Java中的关键字,是一种同步锁。它修饰的对象有以下几种:
    修饰一个代码块:被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象
    修饰一个方法:被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象
    修饰一个静态的方法:其作用的范围是整个静态方法,作用的对象是这个类的所有对象
    修饰一个类:其作用的范围是synchronized后面括号括起来的部分,作用的对象是这个类的所有对象。
  6. CAS
    CAS全称是Compare And Swap,即比较替换,是实现并发应用到的一种技术。操作包含三个操作数—(内存位置(V)、预期原值(A)和新值(B))。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。
    CAS存在三大问题:ABA问题,循环时间长开销大,以及只能保证一个共享变量的原子操作。
  7. 线程池
    如果我们使用线程的时候就去创建一个线程,虽然简单,但是存在很大的问题。如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。线程池通过复用可以大大减少线程频繁创建与销毁带来的性能上的损耗。

一、多线程使用

Thread和Runnable

java中的线程

java执行线程有两种方法:

  • 继承Thread类
  • 实现Runnable接口

Java中线程的特征和状态

  1. 所有的Java程序不论是否并发,都有一个名为主线程的Thread对象。执行该程序时,Java虚拟机(JVM)将创建一个新的Thread,并在该线程上执行main() 方法。这是非并发程序中唯一的线程,也是并发应用程序中的第一个线程。

  2. Java中的线程共享应用程序中的所有资源,包括内存和打开的文件,快速而简单的共享信息。但是必须使用同步避免数据竞争。

  3. Java中的所有线程都有一个优先级,这个整数值介于Thread.MIN_PRIORITY(1)和Thread.MAX_PRIORITY(10)之间,默认优先级是Thread.NORM_PRIORITY(5)。线程的执行顺序并没有保证,通常,较高优先级的线程将在较低优先级的钱程之前执行。

  4. 在Java中可以创建两种线程
    - 守护线程
    - 非守护线程
    区别在于它们如何影响程序的结束。

    Java程序结束执行过程的情形:

    • 程序执行Runtime类的exit()方法, 而且用户有权执行该方法。
    • 应用程序的所有非守护线程均已结束执行,无论是否有正在运行的守护线程。

    守护线程通常用在作为垃圾收集器或缓存管理器的应用程序中,执行辅助任务。在线程start之前调用isDaemon()方法检查线程是否为守护线程,也可以使用setDaemon()方法将某个线程确立为守护线程。

  5. Thread.State类中定义线程的状态如下:

    • NEW:尚未启动的线程的线程状态
    • RUNNABLE:可运行线程的线程状态。处于可运行状态的线程正在Java虚拟机中执行,但它可能正在等待来自操作系统的其他资源,如处理器
    • BLOCKED : 等待监视器锁定的被阻止线程的线程状态。处于阻塞状态的线程正在等待监视器锁进入同步块/方法,或在调用对象后重新进入同步块/方法。
    • WAITING:Thread 对象正在等待另一个线程的动作。
    • TIME_WAITING:Thread对象正在等待另一个线程的操作,但是有时间限制。
    • TERMINATED:终止线程的线程状态。线程已完成执行。

    getState()方法获取Thread对象的状态,可以直接更改线程的状态。

    在给定时间内, 线程只能处于一个状态。这些状态是JVM使用的状态,不能映射到操作系统的线程状态。
    在这里插入图片描述

Thread类和Runnable接口
Runnable接口只定义了一个方法:run() 方法,当执行start()方法重启线程时,它将调用run() 方法。

Thread类其他常用方法:

  • 获取和设置Thread对象信息的方法。
    getId():该方法返回Thread对象的标识符。该标识符是在钱程创建时分配的一个正整数。在线程的整个生命周期中是唯一且无法改变的。
    getName()/setName():这两种方法允许你获取或设置Thread对象的名称。这个名称是一个String对象,也可以在Thread类的构造函数中建立。
    getPriority()/setPriority():你可以使用这两种方法来获取或设置Thread对象的优先级。
    isDaemon()/setDaemon():这两种方法允许你获取或建立Thread对象的守护条件。
    getState():该方法返回Thread对象的状态。
  • interrupt():中断目标线程,给目标线程发送一个中断信号,线程被打上中断标记。
  • interrupted():判断目标线程是否被中断,但是将清除线程的中断标记。
  • isinterrupted():判断目标线程是否被中断,不会清除中断标记。
  • sleep(long ms):该方法将线程的执行暂停ms时间。
  • join():暂停线程的执行,直到调用该方法的线程执行结束为止。可以使用该方法等待另一个Thread对象结束。
  • setUncaughtExceptionHandler():当线程执行出现未校验异常时,该方法用于建立未校验异常的控制器。
  • currentThread():Thread类的静态方法,返回实际执行该代码的Thread对象。

Callable

Callable接口是一个与Runnable接口十分类似的接口。Callable接口的主要特征如下:

  • 接口。有简单类型参数,与call()方法的返回类型相对应
  • 声明了call()方法。执行器运行任务时,该方法会被执行器执行。它必须返回声明中指定类型的对象。
  • call()方法可以抛出任何一种校验异常。可以实现自己的执行器并重载afterExecute()方法来处理这些异常。
package callable;

import java.util.concurrent.Callable;

public class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        while (true){
            Thread.sleep(5000);
            return "this is MyCallable's call()";
        }
    }
}
package callable;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class Main1 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        MyCallable myCallable = new MyCallable();
        FutureTask<String> futureTask = new FutureTask<String>(myCallable);
        new Thread(futureTask).start();
        String o = futureTask.get();
        System.out.println(o);
    }
}
package callable;

import java.util.concurrent.*;

public class Main2 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                5, 5, 1, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10)
        ) {
            @Override
            protected void afterExecute(Runnable r, Throwable t) {
                super.afterExecute(r, t);
            }
        };
        Future<String> future = executor.submit(new MyCallable());
        String s = future.get();
        System.out.println(s);
        executor.shutdown();
    }
}

synchronized关键字(锁)

synchronized*关键字是用来给某个对象加锁

锁的本质

当多个线程同时访问一个资源时,就需要给这个资源加锁,在同一个时间段内,只允许一个线程访问,加锁的可以是一个对象、一个文件或者一个变量等。

锁的本质是一个对象
	1. 这个对象内部得有一个标志位(state变量),记录自己有没有被某个线程占用。最简单的情况是这个state有0、1两个取值,0表示没有线程占用这个锁,1表示有某个线程占用了这个锁。
	2. 如果这个对象被某个线程占用,记录这个线程的thread ID。
	3.  这个对象维护一个thread id list,记录其他所有阻塞的、等待获取拿这个锁的线程。在当前线程释放锁之后从这个thread id list里面取一个线程唤醒。

synchronized作用域:

  • 对象实例: 可以防止多个线程同时访问这个对象的synchronized方法,如果一个对象有多个synchronized方法,只要一个线程访问了其中一个synchronized方法,其他线程就不能同时访问其中的任意一个synchronized方法。不同实例对象的synchronized方法是不互相干扰的。也就是说,其他线程同样可以同时访问相同类下的不同实例对象中的synchronized方法。
  • 类: 可以防止多个线程同时访问这个类所创建的所有对象中的synchronized方法。它可以对这个类创建的所有对象起作用。

实现原理:

在对象头里,有一块数据叫Mark Word。在64位机器上,Mark Word是8字节(64位)的,这64位中有2个重要字段:锁标志位和占用该锁的thread ID。因为不同版本的JVM实现,对象头的数据结构会有各种差异。

详情请看:https://juejin.cn/post/6973571891915128846#heading-16

wait()与notify()方法

1、为什么wait()、notify()方法要和 synchronized 关键字一起用?

public class MyClass1 {
    private Object obj1 = new Object();
    public void method1() throws InterruptedException {
        synchronized(obj1) {
//...
            obj1.wait();
//...
        }
    }
    public void method2() {
        synchronized(obj1) {
//...
            obj1.notify();
//...
        }
    }
}

如上一段代码,启动两个线程,线程A调用method1,线程B调用method2。两个线程调用同一个对象,两个线程之间要通信。该对象本身就需要同步。所以,在调用wait()、notify()之前,要先通过synchronized关键字同步给对象,也就是给对象加锁。

2、为什么wait()、notify()方法要放在Object类里边?

因为synchronized关键字可以给任何对象加锁,那么相对应的,任何对象都必须可以解锁。所以wait()和notify()只能放在Object里了。

3、为什么执行wait()方法的时候必须先释放?

如上图,当线程进入 synchronized(obj1) 中后,就对obj1对象上锁,此时调用wait()方法进入阻塞状态,如果不释放锁,其他进程会一直处于等待状态,获取不到锁;那么其他线程永远也不会调用到obj1.notify() 方法,会发生死锁。

所以: 在wait()的内部,会先释放锁,再进入阻塞状态。之后,它被另外一个线程用notify()唤醒,重新获取锁!其次,wait()调用完成后,执行后面的业务逻辑代码,然后退出synchronized同步块,再次释放锁

InterruptedException与interrupt()方法。

1、在什么时候回抛出InterruptedException 异常?

并不是所有的阻塞都会抛出InterruptedException异常,只有那些声明了会抛出InterruptedException的函数才会抛出异常。

例:
在这里插入图片描述
比如 **wait()、join()、sleep()**等这些。

2、thread.isInterrupted()与Thread.interrupted()的区别

看Thread类中的源码:
在这里插入图片描述
在这里插入图片描述
源码中的两个方法,其实都是调用了**isInterrupted()**方法,只不过传参不一样,ClearInterripted参数的意思是 “是否清除阻塞”, thread.interrupted()相当于给线程发送了一个唤醒的信号,所以如果线程此时恰好处于WAITING或者TIMED_WAITING状态,就会抛出一个InterruptedException,并且线程被唤醒。而如果线程此时并没有被阻塞,则线程什么都不会做。

3、轻量级阻塞与重量级阻塞

能够被中断的阻塞称为轻量级阻塞,对应的线程状态为是WAITING或者TIMED_WAITING;而像
synchronized 这种不能被中断的阻塞称为重量级阻塞,对应的状态是 BLOCKED。

在这里插入图片描述
初始线程处于NEW状态,调用start()开始执行后,进入RUNNING或者READY状态。如果没有调用
任何的阻塞函数,线程只会在RUNNING和READY之间切换,也就是系统的时间片调度。这两种状态的
切换是操作系统完成的,除非手动调用yield()函数,放弃对CPU的占用。

一旦调用了图中的任何阻塞函数,线程就会进入WAITING或者TIMED_WAITING状态,两者的区别
只是前者为无限期阻塞,后者则传入了一个时间参数,阻塞一个有限的时间。如果使用了synchronized
关键字或者synchronized块,则会进入BLOCKED状态。

因此thread.interrupted()的精确含义是“唤醒轻量级阻塞”,而不是字面意思“中断一个线程”。

线程的优雅关闭

在java中,有stop()destory() 等方法,能够将运行中的线程代码强制杀死,但是这些方法官方已经不建议使用。因为如果强制杀死线程,则线程中的网络连接、读取的文件资源等就不能正常关闭。

因此,一个线程一旦运行起来,不要强行关闭,合理的做法是让其运行完(也就是方法执行完毕),干净地释放掉所有资源,然后退出。如果是一个不断循环运行的线程,就需要用到线程间的通信机制,让主线程通知其退出。

守护线程与非守护线程

当在一个JVM进程里面开多个线程时,这些线程被分成两类:守护线程非守护线程。默认都是非
守护线程。

在Java中有一个规定:当所有的非守护线程退出后,整个JVM进程就会退出。意思就是守护线程“不
算作数”,守护线程不影响整个 JVM 进程的退出

守护线程的创建方式是:在thread.start()方法前加一个参数 thread.setDaemon(true)。此时,非守护线程就会转化为守护线程了,当JVM退出后该线程能够继续运行。

二、并发的核心概念

并发与并行

并发: 是指在单核处理器的情况下,一个核心同时处理多个任务,通过操作系统快速的在多个任务之间进行切换,而实现的多个任务同时进行的场景。
并行: 是指在多核处理器的情况下,多个任务分别交给交给多个核心处理器同时一块运行。

同步

1、同步释义

同步是用来处理并发时的公共资源和并发顺序的。同步分为流程同步、资源同步

流程同步: 例如:当一个任务的开始依赖与另一个任务的结束的时候,第二个任务就不能再第一个任务结束前开始。
资源同步: 也可以说是数据同步,当两个或更多任务访问共享资源时,在任意时间里,只有一个任务可以访问该资源。

2、临界段

临界段是一段代码,是一个和同步密切相关的概念。由于它能够访问共享资源,所有在同一个时间段,只允许一个线程访问。

同步可以帮助你在完成并发任务的同时避免一些错误,但是它也为你的算法引入了一些开销。你必须非常仔细地计算任务的数量,这些任务可以独立执行,而无需并行算法中的互通信。这就涉及并发算法的粒度。如果算法有着粗粒度(低互通信的大型任务),同步方面的开销就会较低。然而,也许你不会用到系统所有的核心。如果算法有者细粒度(高互通信的小型任务),同步方面的开销就会很高,而且该算法的吞吐量可能不会很好。

并发系统中有不同的同步机制。从理论角度看,最流行的机制如下:

  • 信号量(semaphore):一种用于控制对一个或多个单位资源进行访问的机制。它有一个用于存放可用资源数量的变量,而且可以采用两种原子操作来管理该变量。互斥(mutex,mutual exclusion的简写形式)是一种特殊类型的信号量,它只能取两个值(即资源空闲和资源忙),而且只有将互斥设置为忙的那个进程才可以释放它。互斥可以通过保护临界段来帮助你避免出现竞争条件。
  • 监视器: 一种在共享资源上实现互斥的机制。它有一个互斥、一个条件变量、两种操作(等待条件和通报条件)。一旦你通报了该条件,在等待它的任务中只有一个会继续执行。

如果共享数据的所有用户都受到同步机制的保护,那么代码(或方法、对象)就是线程安全的。数据的非阻塞的CAS(compare-and-swap,比较和交换) 原语是不可变的,这样就可以在并发应用程序中使用该代码而不会出任何问题。

不可变对象

不可变对象和不可变参数的定义其实有点类似。

不可变对象是一种非常特殊的对象。在其初始化后,不能修改其可视状态(其属性值)。如果想修改一个不可变对象,那么你就必须创建一个新的对象。

不可变对象的主要优点在于它是线程安全的。你可以在并发应用程序中使用它而不会出现任何问题。

不可变对象的一个例子就是java中的String类。当你给一个String对象赋新值时,会创建一个新的String对象。

在这里插入图片描述

原子操作和原子变量

原子 的意思是不可分割的;原子操作是指这个操作里边的动作要不全部都执行,要不就全部都不执行,不存在执行一半,切换到另一个线程的状况。原子操作一般通过临界段来实现,以便整个操作实现同步机制。

原子变量是一种通过原子操作来设置和获取其值的变量。可以使用某种同步机制来实现一个原子变
量,或者也可以使用CAS以无锁方式来实现一个原子变量,而这种方式并不需要任何同步机制。

共享内存与消息传递

同步任务一般通过两种方式来实现消息同步,分别是:

  • 共享内存: 通常用在同一台机器运行多个任务的情况下,任务在读取和写入值的时候是使用的同一块内存区域。为了避免出现问题,对该共享内存的访问 必须在一个同步机制保护的临界段 内进行。
  • 消息传递:通常用于在不同计算机上运行多任务的情形。当一个任务需要与另一个任务通信时,它会发送一个遵循预定义协议的消息。如果发送方保持阻塞并等待响应,那么该通信就是同步的;如果发送方在发送消息后继续执行自己的流程,那么该通信就是异步的。

三、并发问题

数据竞争

当多个线程同时对一个处于临界段(也就是说没有任何的同步机制)之外的变量进行赋值的时候,就会出现数据竞争,这个变量会同时被多个线程进行赋值,最终该变量的结果会取决于线程的执行顺序。

死锁

死锁 是指当两个(或多个)任务正在等待必须由另一线程释放的某个共享资源,而该线程又正在等待必须由
前述任务之一释放的另一共享资惊时,导致两个或多个线程同时卡住,不能往下进行的情况。

当系统中同时出现如下四种条件时,就会导致这种情形。我们将其称为Coffman 条件。

  • 互斥:死锁中涉及到的资源,必须是不可共享的,同一时间段只有一个任务能够使用该资源。
  • 不可剥夺:正在使用中的资源,不能够被别的任务抢占,剥夺,只能等待持有他们的任务释放。
  • 循环等待:任务1正等待任务2 所占有的资源, 而任务2 又正在等待任务3 所占有的资源,以此类推,最终任务n又在等待由任务1所占有的资源,这样就出现了循环等待。
  • 占有并等待条件: 一个任务在占有某一互斥的资源时又请求另一互斥的资源。当它在等待时,不会释放任何资源。

避免死锁的方式

  • 忽略:你可以假设自己的系统绝不会出现死锁,而如果发生死锁,结果就是你可以停止应用程序并且重新执行它。
  • 检测:系统中有一项专门分析系统状态的任务,可以检测是否发生了死锁。如果它检测到了死锁,可以采取一些措施来修复该问题,例如,结束某个任务或者强制释放某一资源。
  • 预防:如果你想防止系统出现死锁,就必须预防Coffman 条件中的一条或多条出现。
  • 规避:如果你可以在某一任务执行之前得到该任务所使用资源的相关信息,那么死锁是可以规避的。当一个任务要开始执行时,你可以对系统中空闲的资源和任务所需的资源进行分析,这样就可以判断任务是否能够开始执行。

活锁

如果系统中有两个任务,它们总是因对方的行为而改变自己的状态, 那么就出现了活锁。最终结果
是它们陷入了状态变更的循环而无法继续向下执行。

死锁和活锁的区别是:死锁是卡住不动了,活锁是虽然能往下运行,但是因为其他线程的影响,始终跳不出循环。

资源不足

当某个任务在系统中无法获取其继续执行所需要的资源时,就会出现资源不足的状况。
当有多个任务在等待某一资源且该资源被释放时,系统需要选择下一个可以使用该资源的任务。如果你的系统中没有设计良好的算法,那么系统中有些线程很可能要为获取该资源而等待很长时间。

要解决这一问题就要确保公平原则。所有等待某一资源的任务必须在某一给定时间之内占有该资源。可选方案之一就是实现一个算法,在选择下一个将占有某一资源的任务时,对任务已等待该资源的时间因素加以考虑。然而,实现锁的公平需要增加额外的开销,这可能会降低程序的吞吐量。

优先权反转

当一个低优先权的任务持有了一个高优先级任务所需的资源时,就会发生优先权反转。这样的话,低优先权的任务就会在高优先权的任务之前执行。

四、JMM(Java Memory Model)内存模型

JMM内存模型总结,详见https://zhuanlan.zhihu.com/p/29881777

Java虚拟机规范中定义了Java内存模型(JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台上都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存之间是如何工作的:规定了一个线程如何、何时可以看到其他线程修改过的共享变量,以及在必须时如何同步的访问共享变量。

总结一句话:JMM就是为了屏蔽不同机器,不同操作系统的内存访问差异,避免内存可见性

为什么会出现内存可见性问题?

我们常说的“2C4核",”4c8核“,等参数,是指的电脑有2个CPU,每个CPU有4个核心。或者有4个CPU,每个CPU有8个核心。

在这里插入图片描述
如上图片,在一个CPU 4核下,L1,L2,L3三级缓存与主内存之间的关系。线程就是运行在CPU的核心下的。
因为存在CPU缓存一致性协议,例如:MESI,多个CPU核心之间不会出现不同步的情况,不会有内存可见性问题。也就是说 L1,L2,L3和主内存之间,永远是内存同步的

但是!! 缓存一致性对性能有很大的损耗,为了解决这个问题,又进行了各种优化,例如:在计算单元和L1之间增加了Store Buffer、Load Buffer等各种Buffer。如下图:
在这里插入图片描述

L1、L2、L3和主内存之间是同步的,有缓存一致性协议的保证,但是Store Buffer、Load Buffer和L1之间却是异步的。向内存中写入一个变量,这个变量会保存在Store Buffer里面,稍后才异步地写入L1中,同时同步写入主内存中。

在这里插入图片描述
多CPU,每个CPU多核,每个核上边可能还有多个硬件线程,对于操作系统来说,就相当于一个个的逻辑CPU,每个逻辑CPU上都有自己的缓存,这些缓存和主内存之间不是完全同步的

对应到Java里,就是JVM抽象内存模型,如图所示:

在这里插入图片描述

重排序

Store Buffer的延迟写入是重排序的一种,称为内存重排序(Memory Ordering)。除此之外,还有编译器CPU的指令重排序。

重排序的类型:

  1. 编译器重排序。
    对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。
  2. CPU指令重排序。
    在指令级别,让没有依赖关系的多条指令并行。
  3. CPU内存重排序。
    CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。

例:

线程1执行:
X=1;
b=Y;

线程2执行:
Y=1;
a=X;

假设X,Y的初始值都为0,那我a,b的值可能为: a=1,b=0;a=0,b=1;a=1,b=1。

因为线程1和线程2的执行顺序是不确定的,所以理想状况下会出现以上三种值。其实,实际上也可能出现 a=0,b=0的状况。

出现a=0,b=0,参数的原因是:线程1先执行X=1,后执行a=Y,但此时X=1还在自己的Store Buffer里面,没有及时写入主内存中。所以,线程2看到的X还是0。线程2的道理与此相同。

虽然线程1觉得自己是按代码顺序正常执行的,但在线程2看来,a=Y和X=1顺序却是颠倒的。指令没
有重排序,是写入内存的操作被延迟了,也就是内存被重排序了,这就造成内存可见性问题

内存屏障

为了禁止编译器重排序和内存重排序,在编译器和CPU层都有对应的指令,也就是内存屏障(Memory Barrier)。这也正是JMMhappen-before规则的底层实现原理。

编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。

CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。

在理论层,可以将CPU的内存屏障分为4种:

  1. LoadLoad:禁止读和读的 重排序。
  2. StoreStore:禁止写和写的 重排序。
  3. LoadStore:禁止读和写的 重排序。
  4. StoreLoad:禁止写和读的 重排序。

Java在Unsafe类中提供了三个内存屏障函数,如下所示。

class Unsafe{
	//...
    public native void loadFence();

    public native void storeFence();

    public native void fullFence();
    //...
 }

Unsafe中的方法:

  1. loadFence=LoadLoad+LoadStore
  2. storeFence=StoreStore+LoadStore
  3. fullFence=loadFence+storeFence+StoreLoad

as-if-serial语义

1.单线程的重排序规则

无论什么语言,站在CPU和编译器的角度来说,不管怎么重排序,单线程程序的执行结果始终不能改变,这就是单线程程序的重排序规则。
即只要操作之间没有数据依赖性,编译器和CPU都可以任意重排序,因为执行结果不会改变,代码看起来就像是完全串行地一行行从头执行到尾,这也就是as-if-serial(看起来像串行)的语义。
对于单线程程序来说,编译器和CPU可能做了重排序,但开发者感知不到,也不存在内存可见性问题。

2、多线程的重排序规则

对于多线程来说,线程之间的数据依赖太复杂,编译器和CPU没有办法完全理解这种依赖关系,并据此做出合理化的优化。

编译器和CPU只能保证每个线程的as-if-serial语义。

线程之间的数据依赖和相互影响,只能依靠CPU和编译器的上层来确定。

上层要告知编译器和CPU在什么时候需要重排序,什么时候不能重排序。

happen-before原则

1、关于happen-before:

如果A happen-before B,意味着A的执行结果必须对B可见,也就是保证跨线程的内存可见性。A happen before B不代表A一定在B之前执行。因为,对于多线程程序而言,两个操作的执行顺序是不确定的。happen-before只确保如果A在B之前执行,则A的执行结果必须对B可见。定义了内存可见性的约束,也就定义了一系列重排序的约束。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性:

  • volatile关键字本身就包含了禁止指令重排序的语义
    synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入

基于happen-before的这种描述方法,JMM对开发者做出了一系列承诺:

  1. 单线程中的每个操作,happen-before 对应该线程中任意后续操作(也就是 as-if-serial语义保
    证)。
  2. 对volatile变量的写入,happen-before对应后续对这个变量的读取。
  3. 对synchronized的解锁,happen-before对应后续对这个锁的加锁。
    ……

JMM对编译器和CPU 来说,volatile 变量不能重排序;非 volatile 变量可以任意重排序。

2、happen-before的传递性:

happen-before还具有传递性,即若A happen-before B,B happen-before C,则A happen-before C。

volatile 关键字

64位写入的原子性:

对于一个long类型变量的赋值和取值操作,在多线程场景下,线程A调用set(100),线程B调
用get(),在某些场景下,返回值可能不是100。

public class MyClass {
	private long a = 0;
	// 线程A调用set(100)
	public void set(long a) {
		this.a = a;
	}
	// 线程B调用get(),返回值一定是100吗?
	public long get() {
		return this.a;
	}
}

因为JVM的规范并没有要求64位的long或者double的写入是原子的。在32位的机器上,一个64位变量的写入可能被拆分成两个32位的写操作来执行。这样一来,读取的线程就可能读到“一半的值”。解决办法也很简单,在long前面加上volatile关键字。

volatile的三重功效:

  • 保证64位写入的原子性
  • 保证内存可见性
  • 禁止重排序
public class Singleton {
	private static Singleton instance;
	public static Singleton getInstance() {
		if (instance == null) {
			synchronized(Singleton.class) {
				if (instance == null) {
					// 此处代码有问题
					instance = new Singleton();
				}
			}
		}
		return instance;
	}
}

如上代码:instance = new Singleton(); 其底层会分为三个操作:

  1. 分配一块内存
  2. 在内存上初始化成员变量
  3. 将instance 的引用指向这块内存

在这三个操作中,操作2和操作3可能重排序,即先把instance指向内存,再初始化成员变量,因为二者并没有先后的依赖关系。此时,另外一个线程可能拿到一个未完全初始化的对象。这时,直接访问里面的成员变量,就可能出错。这就是典型的“构造方法溢出”问题。

解决办法也很简单,就是为instance变量加上volatile修饰。

2、volatile实现原理

由于不同的CPU架构的缓存体系不一样,重排序的策略不一样,所提供的内存屏障指令也就有差异。

这里只探讨为了实现volatile关键字的语义的一种参考做法:

  1. 在volatile写操作的前面插入一个StoreStore屏障。保证volatile写操作不会和之前的写操作重排序。
  2. 在volatile写操作的后面插入一个StoreLoad屏障。保证volatile写操作不会和之后的读操作重排序。
  3. 在volatile读操作的后面插入一个LoadLoad屏障+LoadStore屏障。保证volatile读操作不会和之后的读操作、写操作重排序。

具体到x86平台上,其实不会有LoadLoad、LoadStore和StoreStore重排序,只有StoreLoad一种重排序(内存屏障),也就是只需要在volatile写操作后面加上StoreLoad屏障。

JSR-133对volatile语义的增强

在JSR-133之前的旧内存模型中,一个64位long或double类型的读写操作可以被拆分为两个32位的读/写操作来执行。从JSR-133开始(JDK5开始),仅仅只允许把一个64位long/double型变量的写操作拆分为32位的写操作来执行,任意的读操作都必须具有原子性(即任意读操作必须要在单个读事物中执行)。这也正体现了Java对happen-before规则的严格遵守。

final关键字

final关键字也具有相应的happen-before语义:

  1. 对final域的(构造方法内部),happen-before于后续对final域所在对象的读。
  2. 对final域所在对象的读,happen-before于后续对final域的读

通过这种happen-before语义的限定,保证了final域的赋值,一定在构造方法之前完成,不会出现另外一个线程读取到了对象,但对象里面的变量却还没有初始化的情形,避免出现构造方法溢出的问题。

happen-before规则总结

  1. 单线程中的每个操作,happen-before于该线程中任意后续操作。
  2. 对volatile变量的写,happen-before于后续对这个变量的读。
  3. 对synchronized的解锁,happen-before于后续对这个锁的加锁。
  4. 对final变量的写,happen-before于final域对象的读,happen-before于后续对final变量的读。

四个基本规则再加上happen-before的传递性,就构成JMM对开发者的整个承诺。在这个承诺以外的部分,程序都可能被重排序,都需要开发者小心地处理内存可见性问题。

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值