JAVA多线程原理篇

Java内存模型基础知识

6.1 并发编程模型的两个关键问题

线程间如何通信?即:线程之间以何种机制来交换信息
线程间如何同步?即:线程以何种机制来控制不同线程间操作发生的相对顺序
有两种并发模型可以解决这两个问题:

  • 消息传递并发模型
  • 共享内存并发模型

这两种模型之间的区别如下表所示:
在这里插入图片描述
在Java中,使用的是共享内存并发模型

6.2 Java内存模型的抽象结构

6.2.1 运行时内存的划分

先谈一下运行时数据区,下面这张图相信大家一点都不陌生:Java运行时数据区域
在这里插入图片描述

对于每一个线程来说,栈都是私有的,而堆是共有的。

也就是说在栈中的变量(局部变量、方法定义参数、异常处理器参数)不会在线程之间共享,也就不会有内存可见性(下文会说到)的问题,也不受内存模型的影响。而在堆中的变量是共享的,本文称为共享变量。

所以,内存可见性是针对的共享变量。

6.2.2 既然堆是共享的,为什么在堆中会有内存不可见问题?

这是因为现代计算机为了高效,往往会在高速缓存区中缓存共享变量,因为cpu访问缓存区比访问内存要快得多。

线程之间的共享变量存在主内存中,每个线程都有一个私有的本地内存,存储了该线程以读、写共享变量的副本。本地内存是Java内存模型的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等。

Java线程之间的通信由Java内存模型(简称JMM)控制,从抽象的角度来说,JMM定义了线程和主内存之间的抽象关系。JMM的抽象示意图如图所示:
在这里插入图片描述

JMM抽象示意图

从图中可以看出:

  1. 所有的共享变量都存在主内存中。 每个线程都保存了一份该线程使用到的共享变量的副本。
  2. 如果线程A与线程B之间要通信的话,必须经历下面2个步骤:
    i.线程A将本地内存A中更新过的共享变量刷新到主内存中去。
    ii.线程B到主内存中去读取线程A之前已经更新过的共享变量。

所以,线程A无法直接访问线程B的工作内存,线程间通信必须经过主内存。

注意,根据JMM的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存中读取。

所以线程B并不是直接去主内存中读取共享变量的值,而是先在本地内存B中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存B去主内存中读取这个共享变量的新值,并拷贝到本地内存B中,最后线程B再读取本地内存B中的新值。

那么怎么知道这个共享变量的被其他线程更新了呢?这就是JMM的功劳了,也是JMM存在的必要性之一。JMM通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证。

Java中的volatile关键字可以保证多线程操作共享变量的可见性以及禁止指令重排序,synchronized关键字不仅保证可见性,同时也保证了原子性(互斥性)。在更底层,JMM通过内存屏障来实现内存的可见性以及禁止重排序。为了程序员的方便理解,提出了happens-before,它更加的简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则以及这些规则的具体实现方法。这里涉及到的所有内容后面都会有专门的章节介绍。

6.2.3 JMM与Java内存区域划分的区别与联系

上面两小节分别提到了JMM和Java运行时内存区域的划分,这两者既有差别又有联系:

区别

两者是不同的概念层次。JMM是抽象的,他是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开的。而Java运行时内存的划分是具体的,是JVM运行Java程序时,必要的内存划分。

联系

都存在私有数据区域和共享数据区域。一般来说,JMM中的主内存属于共享数据区域,他是包含了堆和方法区;同样,JMM中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。

实际上,他们表达的是同一种含义,这里不做区分。

7 volatile

7.1 几个基本概念

在介绍volatile之前,我们先回顾及介绍几个基本的概念。

7.1.1 内存可见性

在Java内存模型那一章我们介绍了JMM有一个主内存,每个线程有自己私有的工作内存,工作内存中保存了一些变量在主内存的拷贝。

内存可见性,指的是线程之间的可见性,当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值。

7.1.2 重排序

为优化程序性能,对原有的指令执行顺序进行优化重新排序。重排序可能发生在多个阶段,比如编译重排序、CPU重排序等。

7.1.3 happens-before规则

是一个给程序员使用的规则,只要程序员在写代码的时候遵循happens-before规则,JVM就能保证指令在多线程之间的顺序性符合程序员的预期。

7.2 volatile的内存语义

在Java中,volatile关键字有特殊的内存语义。volatile主要有以下两个功能:

保证变量的内存可见性
禁止volatile变量与普通变量重排序(JSR133提出,Java 5 开始才有这个“增强的volatile内存语义”)
以一段示例代码开始:

public class VolatileExample {
    
    
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
    
    
        a = 1; // step 1
        flag = true; // step 2
    }

    public void reader() {
    
    
        if (flag) {
    
     // step 3
            System.out.println(a); // step 4
        }
    }
}

在这段代码里,我们使用volatile关键字修饰了一个boolean类型的变量flag

所谓内存可见性,指的是当一个线程对volatile修饰的变量进行写操作(比如step 2)时,JMM会立即把该线程对应的本地内存中的共享变量的值刷新到主内存;当一个线程对volatile修饰的变量进行读操作(比如step 3)时,JMM会把立即该线程对应的本地内存置为无效,从主内存中读取共享变量的值。

在这一点上,volatile与锁具有相同的内存效果,volatile变量的写和锁的释放具有相同的内存语义,volatile变量的读和锁的获取具有相同的内存语义。

假设在时间线上,线程A先执行方法writer方法,线程B后执行reader方法。那必然会有下图:
在这里插入图片描述
而如果flag变量没有用volatile修饰,在step 2,线程A的本地内存里面的变量就不会立即更新到主内存,那随后线程B也同样不会去主内存拿最新的值,仍然使用线程B本地内存缓存的变量的值a = 0,flag = false

7.3 volatile的用途

从volatile的内存语义上来看,volatile可以保证内存可见性且禁止重排序

在保证内存可见性这一点上,volatile有着与锁相同的内存语义,所以可以作为一个“轻量级”的锁来使用。但由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁可以保证整个临界区代码的执行具有原子性。所以在功能上,锁比volatile更强大;在性能上,volatile更有优势。

在禁止重排序这一点上,volatile也是非常有用的。比如我们熟悉的单例模式,其中有一种实现方式是“双重锁检查”,比如这样的代码:

public class Singleton {
    
    

    private static Singleton instance; // 不使用volatile关键字

    // 双重锁检验
    public static Singleton getInstance() {
    
    
        if (instance == null) {
    
     // 第7行
            synchronized (Singleton.class) {
    
    
                if (instance == null) {
    
    
                    instance = new Singleton(); // 第10行
                }
            }
        }
        return instance;
    }
}

如果这里的变量声明不使用volatile关键字,是可能会发生错误的。它可能会被重排序:

instance = new Singleton(); // 第10行

// 可以分解为以下三个步骤
1 memory=allocate();// 分配内存 相当于c的malloc
2 ctorInstanc(memory) //初始化对象
3 s=memory //设置s指向刚分配的地址

// 上述三个步骤可能会被重排序为 1-3-2,也就是:
1 memory=allocate();// 分配内存 相当于c的malloc
3 s=memory //设置s指向刚分配的地址
2 ctorInstanc(memory) //初始化对象

而一旦假设发生了这样的重排序,比如线程A在第10行执行了步骤1和步骤3,但是步骤2还没有执行完。这个时候另一个线程B执行到了第7行,它会判定instance不为空,然后直接返回了一个未初始化完成的instance!

所以JSR-133对volatile做了增强后,volatile的禁止重排序功能还是非常有用的。

9.第九章 synchronized与锁

首先需要明确的一点是:Java多线程的锁都是基于对象的,Java中的每一个对象都可以作为一个锁。

还有一点需要注意的是,我们常听到的类锁其实也是对象锁。

Java类只有一个Class对象(可以有多个实例对象,多个实例共享这个Class对象),而Class对象也是特殊的Java对象。所以我们常说的类锁,其实就是Class对象的锁。

9.1 Synchronized关键字

说到锁,我们通常会谈到synchronized这个关键字。它翻译成中文就是“同步”的意思。

我们通常使用synchronized关键字来给一段代码或一个方法上锁。它通常有以下三种形式:

// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
    
    
    // code
}

// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
    
    
    // code
}

// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
    
    
    Object o = new Object();
    synchronized (o) {
    
    
        // code
    }
}

我们这里介绍一下“临界区”的概念。所谓“临界区”,指的是某一块代码区域,它同一时刻只能由一个线程执行。在上面的例子中,如果synchronized关键字在方法上,那临界区就是整个方法内部。而如果是使用synchronized代码块,那临界区就指的是代码块内部的区域。

通过上面的例子我们可以看到,下面这两个写法其实是等价的作用:

// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
    
    
    // code
}

// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
    
    
    synchronized (this) {
    
    
        // code
    }
}
同理,下面这两个方法也应该是等价的:

// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
    
    
    // code
}

// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
    
    
    synchronized (this.getClass()) {
    
    
        // code
    }
}

9.2 几种锁

Java 6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁“。在Java 6 以前,所有的锁都是”重量级“锁。所以在Java 6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:

  • 无锁状态
  • 偏向锁状态
  • 轻量级锁状态
  • 重量级锁状态
    无锁就是没有对资源进行锁定,任何线程都可以尝试去修改它,无锁在这里不再细讲。

几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件会比较苛刻,锁降级发生在Stop The World期间,当JVM进入安全点的时候,会检查是否有闲置的锁,然后进行降级。

关于锁降级有两点说明:

  1. 不同于大部分文章说锁不能降级,实际上HotSpot JVM 是支持锁降级的,文末有链接。

  2. 上面提到的Stop The World期间,以及安全点,这些知识是属于JVM的知识范畴,本文不做细讲。

猜你喜欢

转载自blog.csdn.net/weixin_42838061/article/details/120058711