JVM学习.04. Java内存模型与线程模型

简介: 该篇内容主要介绍JVM如何实现多线程,多线程间由于共享和竞争数据而导致的一系列问题以及解决方案。

1、前言

该篇内容主要介绍JVM如何实现多线程,多线程间由于共享和竞争数据而导致的一系列问题以及解决方案。

2、内存模型(JMM)

Java内存模型(Java Memory Model,简称JMM)的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量存储到内存和从内存中取出变量值的底层细节。

2.1、主内存与工作内存

我们在《JVM学习 01.JVM内存模型》中讲到了JVM的内存模型。那么这里又讲到了Java的内存模型。那么这两者又什么关联呢?

首先,JVM内存模型分为:方法区,堆,虚拟机栈,本地方法栈,程序计数器;而Java内存模型分为:主内存、工作内存。

对应关系为:

Java主内存 = JVM堆 + JVM方法区。规定了所有的变量都存储在主内存。

Java工作内存 = 虚拟机栈 + 本地方法栈 + 程序计数器。存储了每条线程所使用的变量的主内存副本。

  • 线程堆变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。
  • 不同线程之间也无法直接访问对方工作内存中的变量(即线程间相互隔离)
  • 线程间变量值的传递均需要通过主内存完成。

线程,主内存,工作内存三者的交互如下图:

image.png

关于主内存和工作内存之间的交互协议有如下8种操作:

  • lock,锁定操作。作用于主内存变量,把变量标识为线程独占的状态。
  • unlock,释放锁。作用于主内存变量,把一个锁定的变量释放出来,释放后其他线程才能使用。
  • read,读取。作用于主内存变量,把一个变量从主内存传输到线程的工作内存中。以便load使用。
  • load,载入。作用于工作内存变量,把read操作传输到工作内存中的变量值放入到变量副本中。
  • use,使用。作用于工作内存变量,把工作内存中一个变量值传递给执行引擎。
  • assign,赋值。作用于工作内存变量,把一个执行引擎接收的值赋给工作内存变量。
  • store,存储。作用于工作内存变量,把工作内存中一个变量值传到主内存中,以便write操作使用。
  • write,写入。作用于主内存变量,把store操作得到的变量值放入主内存变量中。

2.2、volatile

volatile是JVM提供的最轻量级的同步机制,但是它并不容易被正确,完整的理解。通常遇到多线程资源竞争问题的时候,一律使用synchronized来进行同步。

volatile有两项特性:可见性,防止指令重排。

2.2.1、可见性

volatile可见性是指被volatile修饰的变量对所有线程可见,这里的可见性是指当一个线程修改了这个变量值,新值对其他线程来说是可以立即得知的。

思考:volatile修饰的变量对所有线程立即可见,那么对该变量所有的写操作都会立刻反应到其他线程中。也就是说这个变量对所有线程来说都是一致的,那么是不是说明它在并发运算下是线程安全的?

答案是否!!!

看一段代码:

/**
 * @author Shamee loop
 * @date 2023/3/24
 */
public class VolatileDemo {
    public static volatile int num = 0;
    public static void add(){
        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    num++;
                }
            }).start();
        }
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        // 如果是线程安全,那么num输出的值应该是20000.
        System.out.println(num);
    }
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            num = 0;
            add();
        }
    }
}

这里模拟执行了10次。如果线程安全,那么执行10次的结果应该都是20000。但是实际上却是:

image.png

volatile变量在各个线程的工作内存中是不存在一致性问题的,但Java中的运算符并非原子性操作,这就导致了volatile变量的运算在并发下一样是不安全的。

由于volatile变量只能保证可见性,如果遇到以下两条规则的运算场景中,还是需要通过枷锁来保证原子性:

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一线程修改变量的值
  • 变量不需要与其他的状态变量共同参与不变约束

上述代码加锁后:

for (int i = 0; i < 20; i++) {
    new Thread(() -> {
        for (int j = 0; j < 1000; j++) {
            synchronized (VolatileDemo.class) {
                num++;
            }
        }
    }).start();
}

执行结果:

image.png

2.2.2、禁止指令重排

普通变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都额能获取到正确的结果,而不能保证变量赋值操作的顺序和程序代码真正执行的顺序一致。

指令排序是指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理。但并不是所有的指令都会被重新排序。比如:

  • 指令1,把变量A+1;
  • 指令2,把变量A*10;
  • 指令3,把变量B-1;

由于指令1和指令2是相互依赖的,因此在处理过程中会严格按照指定顺序。但是指令3就不一定了。处理器只要能够保障返回的是正确结果即可。

那么为什么又要禁止指令重排序呢?

显然重排序是处理器对指令处理的优化处理。如果是单个处理器访问时,当然不会出现问题。但是如果多个处理器并发访问同一块内存,这个时候就需要内存屏障来保障一致性。而volatile关键字,会在指令的操作中加上lock修饰,lock指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过的内存屏障”的效果。

2.3、原子性、可见性、有序性

原子性:

原子性指一个操作或者多个操作,要么全部执行,并且执行的过程不会被任何因素打断,要么就都不执行。

Java中的基本类型的访问和读写都是具备原子性的。通常我们所使用的同步块(synchronized关键字修饰)之间的操作也是具备原子性的

可见性:

可见性就是指当一个线程修改了共享变量时,其他线程能够立即得知这个修改动作。上面讲到的volatile就是这个。

除了volatile以外,synchronized和final也可以实现可见性。

synchronized实现可见性是对一个变量执行unlock操作前,必须先把变量同步回主内存中(执行store,write操作)。

final实现可见性是指被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this引用传递出去,那么在其他线程中就能看见final字段值。

有序性:

有序性是指如果在本线程内观察,所有操作都是有序的(指线程内似表现为串行);如果在一个线程中观察另一个线程,所有操作都是无序的(值指令重排序和工作内存同主内存同步延迟现象)。

volatile和synchronized保证了线程间操作的有序性。

volatile前面介绍了本身包含了禁止指令重排序的语义。

synchronized则保证了一个变量在同一时刻只允许一条线程对其进行lock操作。

可以发现,synchronized关键字能同时保证原子性,可见性,有序性。但往往越是这样的并发控制,如果被滥用,通常就会伴随着很大的性能问题。

3、Java与线程

关于线程的实现有三种方式:内核线程实现(1:1),用户线程实现(1:N),使用用户线程+轻量级进程混合实现(N:M)。

3.1、线程实现

3.1.1、内核线程

内核线程(KLT)实现的方式就是直接由操作系统内核支持的线程。每个内核线程可以视为内核得到一个分身,这样操作系统就有能力同时处理多件事情。一般程序不会直接使用内核线程,而是使用一种高级接口:“轻量级进程(LWP)”,其实就是我们通常意义上说的线程。

实现模型如下图(网上借的图):

image.png

用户进程中,通过 LWP 使用系统的 内核线程 。由于其一对一的关系,又称为1:1实现。

由于 用户线程 与 LWP 一一对应,LWP 是独立的调度单元,因此某个LWP在 用户进程调用过程中 发生阻塞,以及在 系统调用中 发生了阻塞,都不会影响整个进程的执行。

缺点是LWP会消耗一定的内核资源,且仅能支持的数量较少。

3.1.2、用户线程

用户线程(UT)指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在以及实现。用户线程的所有状态由用户程序自己处理。

区别于内核线程模型,用户线程的调度不依赖于内核,占用内核资源极少,可以突破量的限制,并且减少切换时间损耗。

但难以利用多核CPU的优势,一旦发生系统调用中断,其他线程也会被中断。

实现模型如下图(网上借的图):

image.png

3.1.3、混合实现

结合了内核线程和用户线程的优点。既存在用户线程,也存在轻量级进程。用户线程还是建立在用户空间,所以可以支持大规模的用户线程并发;而轻量级进程则作为用户线程和内核线程的桥梁,提供线程调度功能和处理器映射。

优点是大大降低了整个进程被完全阻塞的风险。

实现模型如下图(网上借的图):

image.png

3.2、线程调度

调度方式有两种:协同式、抢占式。

协同式:这种方式是原始方式,线程的执行时间由线程本身控制,一个线程执行完后主动通知另一个线程。该方式最大的好处就是实现简单(Lua的协同例程就是采用这种方式),目前已经很少使用,很容易造成阻塞。

抢占式:每个线程由系统来分配执行时间,线程切换不由线程本身决定。如Java中Thread::yield()可以主动让出执行时间,但是如果想要主动获取执行时间,线程本身加缪没办法了。

目前Java所使用的线程调度方式就是抢占式调度。虽然Java线程调度由系统自动完成,但用户可以设置线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)来”引导“操作系统给某些线程多分配一些时间来处理,另一些线程可以少点时间。当两个线程同时处于ready状态,优先级越高越容易被系统优先执行。

Java语言有10个级别的线程优先级,其实在代码中就是1 - 10 十个int常量(默认5),通过setPriority(int x)来设置。

3.3、线程状态

Java语言定义了6中线程状态。任意一个时间点,一个线程有且只有一种状态,并且可以通过特定方法切换不同状态。

  1. 新建(New):创建后尚未启用
  2. 运行(Runnable):包括了Running和Ready,此状态的线程由可能正在执行,也有可能正在等待操作系统分配执行时间
  3. 无限期等待(Waiting):该状态线程不会被分配执行时间,要等待被显式唤醒。以下几种情况下线程会处于该状态:
  1. 没设置Timeout参数的Object::wait()方法;
  2. 没设置Timeout参数的Object::join()方法;
  3. LockSupport::park()方法
  1. 限期等待(Timed Waiting):该状态线程不会被分配执行时间,不过无需等待被其他线程显式唤醒,在一定时间后由系统自动唤醒。以下几种情况下线程会处于该状态:
  1. Thread::sleep()方法。
  2. 设置了Timeout参数的Object::wait()方法。
  3. 设置了Timeout参数的Thread::join()方法。
  4. LockSupport::parkNanos()方法。
  5. LockSupport::parkUntil()方法。
  1. 阻塞(Blocked):线程被阻塞了。其中由阻塞状态和等待状态。
  1. 阻塞状态:在等待获取到一个排他锁,这个时间将在另一个线程放弃这个锁的时候发生;
  2. 等待状态:等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
  1. 结束(Terminated):种植线程状态,线程结束运行。

状态的转换关系如下图:

image.png

4、Java与协程

上面我们讲到了Java线程的实现和调度方式,到现在,Java中依然采用这种并发编程方式和同步机制运作,但是在某些场景下,也显现出了疲态。

比如面对如今的Web应用的服务要求,其请求数量和计算量急速增长。现在的B/S系统中,一次对外业务请求的响应,往往需要分布在不同服务器上的大量服务共同协作完成(微服务)。这之后Java目前的并发机制就出现了一些些问题,如今的1:1内核线程模型是Java虚拟机线程实现的主流选择方式,但是这种方式的缺陷是切换,调度成本高,且系统能容纳的线程数量有限。现在每个请求本身的执行时间变得很短,数量很多的前提下,可能用户线程切换的开销会接近于计算本身的开销,这就有点得不偿失了。

因此Java研究了新的解决方案,便是协程。可以理解为线程中的模拟线程,之所以叫做协程,是起初用户线程模型演化过来,而最初用户线程模型是被设计成协同式调度,因此后来就成为协程。

协程的主要优势就是轻量。通常虚拟机不显示设置-Xss或-XX:ThreadStackSize下,在64位Linux上HotSpot线程栈容量默认1M,此外内核数据结构大概16KB。如果式一个协程,栈通常在几百个字节到几KB之间。一个JVM中线程池容量能达到几百算很大了,但是支持协程应用中,同时并存的数量可以达到万或十万级别。

但是目前Java协程还未成熟,相应的Go中就有了比较成熟的虚拟线程的方式。还挺期待Java的应用。

5、小结

本篇整理了Java内存模型与一些线程模型相关的知识点。希望对于Java程序在JVM内的执行有了更深层次的了解。后续还会努力更新中......一起加油学习吧。

相关文章
|
5天前
|
安全 Java 开发者
深入解读JAVA多线程:wait()、notify()、notifyAll()的奥秘
在Java多线程编程中,`wait()`、`notify()`和`notifyAll()`方法是实现线程间通信和同步的关键机制。这些方法定义在`java.lang.Object`类中,每个Java对象都可以作为线程间通信的媒介。本文将详细解析这三个方法的使用方法和最佳实践,帮助开发者更高效地进行多线程编程。 示例代码展示了如何在同步方法中使用这些方法,确保线程安全和高效的通信。
25 9
|
7天前
|
Arthas 监控 Java
JVM进阶调优系列(9)大厂面试官:内存溢出几种?能否现场演示一下?| 面试就那点事
本文介绍了JVM内存溢出(OOM)的四种类型:堆内存、栈内存、元数据区和直接内存溢出。每种类型通过示例代码演示了如何触发OOM,并分析了其原因。文章还提供了如何使用JVM命令工具(如jmap、jhat、GCeasy、Arthas等)分析和定位内存溢出问题的方法。最后,强调了合理设置JVM参数和及时回收内存的重要性。
|
8天前
|
存储 安全 Java
Java多线程编程的艺术:从基础到实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及其实现方式,旨在帮助开发者理解并掌握多线程编程的基本技能。文章首先概述了多线程的重要性和常见挑战,随后详细介绍了Java中创建和管理线程的两种主要方式:继承Thread类与实现Runnable接口。通过实例代码,本文展示了如何正确启动、运行及同步线程,以及如何处理线程间的通信与协作问题。最后,文章总结了多线程编程的最佳实践,为读者在实际项目中应用多线程技术提供了宝贵的参考。 ####
|
5天前
|
Java Linux Windows
JVM内存
首先JVM内存限制于实际的最大物理内存,假设物理内存无限大的话,JVM内存的最大值跟操作系统有很大的关系。简单的说就32位处理器虽然可控内存空间有4GB,但是具体的操作系统会给一个限制,这个限制一般是2GB-3GB(一般来说Windows系统下为1.5G-2G,Linux系统下为2G-3G),而64bit以上的处理器就不会有限制。
8 1
|
5天前
|
监控 安全 Java
Java中的多线程编程:从入门到实践####
本文将深入浅出地探讨Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的摘要形式,本文将以一个简短的代码示例作为开篇,直接展示多线程的魅力,随后再详细解析其背后的原理与实现方式,旨在帮助读者快速理解并掌握Java多线程编程的基本技能。 ```java // 简单的多线程示例:创建两个线程,分别打印不同的消息 public class SimpleMultithreading { public static void main(String[] args) { Thread thread1 = new Thread(() -> System.out.prin
|
7天前
|
安全 Java
Java多线程集合类
本文介绍了Java中线程安全的问题及解决方案。通过示例代码展示了使用`CopyOnWriteArrayList`、`CopyOnWriteArraySet`和`ConcurrentHashMap`来解决多线程环境下集合操作的线程安全问题。这些类通过不同的机制确保了线程安全,提高了并发性能。
|
8天前
|
Java
java小知识—进程和线程
进程 进程是程序的一次执行过程,是系统运行的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。简单来说,一个进程就是一个执行中的程序,它在计算机中一个指令接着一个指令地执行着,同时,每个进程还占有某些系统资源如CPU时间,内存空间,文件,文件,输入输出设备的使用权等等。换句话说,当程序在执行时,将会被操作系统载入内存中。 线程 线程,与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比
18 1
|
1月前
|
存储 安全 Java
jvm 锁的 膨胀过程?锁内存怎么变化的
【10月更文挑战第3天】在Java虚拟机(JVM)中,`synchronized`关键字用于实现同步,确保多个线程在访问共享资源时的一致性和线程安全。JVM对`synchronized`进行了优化,以适应不同的竞争场景,这种优化主要体现在锁的膨胀过程,即从偏向锁到轻量级锁,再到重量级锁的转变。下面我们将详细介绍这一过程以及锁在内存中的变化。
37 4
|
1月前
|
缓存 算法 Java
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
这篇文章详细介绍了Java虚拟机(JVM)中的垃圾回收机制,包括垃圾的定义、垃圾回收算法、堆内存的逻辑分区、对象的内存分配和回收过程,以及不同垃圾回收器的工作原理和参数设置。
62 4
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
|
1月前
|
存储 缓存 算法
JVM核心知识点整理(内存模型),收藏再看!
JVM核心知识点整理(内存模型),收藏再看!
JVM核心知识点整理(内存模型),收藏再看!