许多以Java多线程开发为主题的技术书籍,都会把对Java虚拟机和Java内存模型的讲解,作为讲授Java并发编程开发的主要内容,有的还深入到计算机系统的内存、CPU、缓存等予以说明。实际上,在实际的Java开发工作中,仅仅了解并发编程的创建、启动、管理和通信等基本知识还是不够的。一方面,如果要开发出高效、安全的并发程序,就必须深入Java内存模型和Java虚拟机的工作原理,从底层了解并发编程的实质;更进一步地,在现今大数据的时代,要开发出高并发、高可用、考可靠的分布式应用及各种中间件,更需要深入到计算机工作原理的底层去进行代码开发。

本文尝试以一个较为全面的角度,以Java虚拟机工作原理和Java内存模型为切入,配合一些计算机CPU缓存的知识,深入理解Java多线程开发中的难点,包括线程安全和线程通信等内容。

如果需要先行了解Java并发编程的基础知识,可参考以下随笔:

1. Java并发编程之线程创建和启动(Thread、Runnable、Callable和Future)

2. Java并发编程之线程生命周期、守护线程、优先级、关闭和join、sleep、yield、interrupt

3. Java并发编程之线程安全、线程通信

4. Java并发编程之ThreadGroup

CPU缓存模型

逻辑上来说,大部分计算机系统的高级编程语言及其编译器、虚拟机等构件,都是来源于计算机硬件系统的原理和要求,而不是相反。Java虚拟机和并发编程原理也不例外,因此第一部分先介绍一下困扰许多初学者的Java多线程开发的源头——CPU缓存模型。

计算机中,所有的计算都是在CPU寄存器中完成,而指令完成所需要的数据读取和写入,都需要从RAM主存获取。受硬件工艺的影响,现在的CPU处理速度已经远远超过主存的访问速度,差额基本是成千上万的差距。

因此,CPU缓存设计应运而生。如下为CPU缓存架构图和CPU缓存与主存的速度对比:

使用CPU缓存来处理数据的步骤大致为:

1. 把需要的数据从主存复制一份到CPU缓存中;

2. CPU从缓存中读取数据并计算;

3. 计算完成的数据刷新到主存中。

“缓存一致性问题”

如上的工作机制,会在多线程环境下导致缓存不一致的问题。为此,使用“总线加锁”(已淘汰)和“缓存一致性协议”来解决,它大致的思想是:

当CPU操作缓存中的数据时,如果发现该变量是一个共享变量,意味着其它缓存中也会有这个变量的副本,然后——

1. 如果是读操作,不做任何处理,只是从缓存中读取数据到寄存器

2. 如果是写操作,发出信号通知其它CPU将该变量的cache line置为无效状态,其它CPU在运行该变量读取的时候需要从主存更新数据。

Java虚拟机

受许多资料和书籍讲述不严谨所致,很多初学者往往简单地把Java虚拟机理解为类似编译器甚至解释器的存在,把Java虚拟机当做黑盒,认为输入了Java源代码,就可以输出计算机直接跑的程序了;因为JVM在不同操作系统上都有实现,所以可以做到“一份代码,多种机器运行的效果”。这样理解对小白或者外行人来说可能OK,但对于有想法深入学习Java的小伙伴,是远远不够的。

事实上,Java虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。包括编译器以及JRE在内的整套体系,构成了完整的JVM。JVM原生支持包括Java、Scala、Kotlin在内的语言编译后运行。而其中,JRE又是JVM的核心部分。JRE的体系结构图如下:

程序计数器:线程私有,每个线程都有独立的程序计数器,用于存放当前线程接下来将要执行的字节码指令、分支、循环、跳转、异常处理等信息。

Java虚拟机栈:线程私有,生命周期与线程相同。线程运行中,执行方法时都会创建“栈帧”,用于存放局部变量表、操作栈、动态链接、方法出口等信息。虚拟机栈的大小可以通过-xss来配置,需要特别注意的是:方法的调用是栈帧被压入和弹出的过程。在一定的容量之下,如果局部变量表等占用的内存越小,则可被压入的栈帧就越多,反之亦然。栈帧的内存大小称为宽度,栈帧的数量则称为深度,两者成反比。

本地方法栈:线程私有,JVM为本地方法(Java Native Interface, C/C++实现的程序)所划分的内存区域,用于被线程调用诸如网络通信、文件操作等方法。

堆:所有线程共享,Java运行期间几乎所有对象都存储于此。堆内存也会被细分为新生代、老生代等子堆。

方法区:多个线程共享,存储那些在类的加载阶段(详见下文)已经被JVM加载的类信息、常量、静态变量、即时编译器JIT编译后的代码等数据。Java8中,改区的持久代内存改为元空间。

特别地,Java程序中线程的数量,受Java虚拟机栈和堆影响较大,可以粗略地认为:一个Java进程的内存大小=堆内存 + 线程数量 * 线程私有栈内存。结合操作系统特性,可以明确一个计算线程数量的公式:线程数=(最大地址空间MaxProcessMemory - JVM堆内存 - 系统保留内存ReservedOsMemory)/ThreadStackSize(XSS)

JVM的类加载过程

当Java源文件经过javac编译完成,生成类文件之后,首先会被类加载器即ClassLoader加载。ClassLoader的主要职责是加载编译好的类文件,在对应的内存区域中生成该类的各个数据结构。类的加载分为加载、连接初始化三个阶段,如图:

1. 加载:加载类的class文件

2. 连接

2.1 验证:确保class文件的正确性,如版本、魔术因子等

2.2 准备:为类的静态变量分配内存,并且初始化默认值

2.3 解析:把类中的符号引用转为直接引用

3. 初始化:为类的静态变量赋代码编写阶段锁赋的值

需要注意的是:类的加载实施的是懒加载,即用的时候才加载,并且在同一个运行时包下,一个类只会被初始化一次。

类的完整的生命周期,除了类加载,还包括使用和卸载。

关于使用,JVM定义了6种主动使用类的场景,会导致类的加载和初始化

new对象;访问类的静态变量(静态常量不会!);访问类的静态方法;使用反射;初始化子类会初始化父类;启动类

注意初始化一个类为元素的数组不会加载类。

类加载的最终产物,是堆内存中的Class对象。而对于同一个ClassLoader,不管类被加载多少次,指向的都是同一个Class对象

类被加载后在栈内存中的分布情况如图

Java内存模型

通过CPU缓存和JVM工作模式的介绍,是为了引入Java内存模型的概念。Java内存模型(Java Memory Mode, JMM)定义了JVM如何与计算机的主存进行工作,理解JMM对正确理解Java多线程开发是十分重要的。JMM模型如下图所示:

Java内存模型的工作逻辑,与上面介绍到的CPU缓存一致性工作逻辑十分相似,其关于多线程的工作要点如下:

1. 共享变量存储于主内存中,每个线程都可以访问。

2. 每个线程都有私有的工作内存,或称本地内存。这只是个逻辑概念,其实质是涵盖了寄存器、缓存、编译器优化和硬件等。

3. 共享变量只以副本的形式,存储在本地内存中。

4. 线程不能直接操作主内存,只有操作了本地内存中的副本,才能刷新到主内存中。

5. 每个线程也不能操作其它线程的私有的本地内存

Java线程安全的实现

Java并发编程安全需要具备的三大特性:原子性、可见性和有序性。下面将介绍,基于JMM模型和Java线程安全的实现方式,是如何确保三大特性的。

原子性

  • 在Java并发编程中,简单的读取和赋值操作是原子性的,但是多个原子操作并在一起就不是了,比如将一个变量赋值给另外一个变量的操作。
  • JMM只保证了简单读取和赋值的原子性。因此,并发编程中需要用到synchronized实现同步,或者使用Lock接口的实现类加锁;对于基本数据类型如int的自增操作,也可以使用JUC包下的java.util.concurrent.atomic.*包下的原子类型。而volatile修饰的变量,不具备原子性。

可见性

基于JMM模型,对于线程读取共享变量:首次只要从主内存读取到工作内存,以后都在工作内存中读取即可;对于修改共享变量,新值先更新在工作内存中,再刷新到主存中。但什么时候刷新是不确定的。因此,Java并发编程中,要确保共享变量在多线程中同步更新,可以采取如下方式:

  • 通过synchronized关键字同步,可以确保在锁释放之前,对变量的修改刷新到主内存中;
  • 通过Lock接口实现类实现同步,同样可以在锁unlock之前,把修改刷新到主内存中;
  • 使用volatile关键字,当某线程修改了工作内存中的共享变量副本,会直接刷新主存中的值,并且其它线程会立刻收到本地内存中共享变量副本失效的信息,从而及时从主内存中更新值。

有序性

在JMM模型中,为了充分利用硬件性能,编译器和指令器有可能会对程序指令进行重排序。单线程下,这不会有什么问题,但多线程下则可能带来意想不到的状况。

关于并发编程的有序性,JMM基于一套原生Happens-before原则,来确保了多线程下一定程度的有序性。具体说来:

  • 程序次序规则:即便发生了重排序,在一个线程内最终的运行结果会与程序编写顺序的结果一致。
  • 锁定规则:先unlock再lock。即一个锁是锁定状态,需要先解锁才能再加锁。
  • volatile规则:如果一个线程对volatile变量读,另一个线程对该变量写,那么写操作一定发生在读操作之前。
  • 传递规则:如果操作A先于B,B先于C,那么A肯定先于C。
  • 线程启动规则:线程的start方法先于其它操作。
  • 线程中断规则:必须是先有interrupt()方法调用,才有中断信号的捕获。
  • 线程终结规则:线程的所有操作都必须先于线程死亡。
  • 对象终结规则:一个对象的初始化先于对象GC之前。

此外,在并发编程中,比较常用的是使用synchronized关键字和Lock接口同步,或者volatile关键字,来确保多线程下的有序性。

基于JVM原理、JMM模型和CPU缓存模型深入理解Java并发编程的更多相关文章

  1. 【Java并发编程】从CPU缓存模型到JMM来理解volatile关键字

    目录 并发编程三大特性 原子性 可见性 有序性 CPU缓存模型是什么 高速缓存为何出现? 缓存一致性问题 如何解决缓存不一致 JMM内存模型是什么 JMM的规定 Java对三大特性的保证 原子性 可见 ...

  2. Java并发编程系列-(8) JMM和底层实现原理

    8. JMM和底层实现原理 8.1 线程间的通信与同步 线程之间的通信 线程的通信是指线程之间以何种机制来交换信息.在编程中,线程之间的通信机制有两种,共享内存和消息传递. 在共享内存的并发模型里,线 ...

  3. Java并发编程:JMM(Java内存模型)和volatile

    1. 并发编程的3个概念 并发编程时,要想并发程序正确地执行,必须要保证原子性.可见性和有序性.只要有一个没有被保证,就有可能会导致程序运行不正确. 1.1. 原子性 原子性:即一个或多个操作要么全部 ...

  4. java并发编程系列七:volatile和sinchronized底层实现原理

    一.线程安全 1.  怎样让多线程下的类安全起来 无状态.加锁.让类不可变.栈封闭.安全的发布对象 2. 死锁 2.1 死锁概念及解决死锁的原则 一定发生在多个线程争夺多个资源里的情况下,发生的原因是 ...

  5. Java并发编程(五)JVM指令重排

    我是不是学了一门假的java...... 引言:在Java中看似顺序的代码在JVM中,可能会出现编译器或者CPU对这些操作指令进行了重新排序:在特定情况下,指令重排将会给我们的程序带来不确定的结果.. ...

  6. Java并发编程基础-线程安全问题及JMM(volatile)

    什么情况下应该使用多线程 : 线程出现的目的是什么?解决进程中多任务的实时性问题?其实简单来说,也就是解决“阻塞”的问题,阻塞的意思就是程序运行到某个函数或过程后等待某些事件发生而暂时停止 CPU 占 ...

  7. Java并发编程原理与实战四十二:锁与volatile的内存语义

    锁与volatile的内存语义 1.锁的内存语义 2.volatile内存语义 3.synchronized内存语义 4.Lock与synchronized的区别 5.ReentrantLock源码实 ...

  8. Java并发编程(您不知道的线程池操作), 最受欢迎的 8 位 Java 大师,Java并发包中的同步队列SynchronousQueue实现原理

    Java_并发编程培训 java并发程序设计教程 JUC Exchanger 一.概述 Exchanger 可以在对中对元素进行配对和交换的线程的同步点.每个线程将条目上的某个方法呈现给 exchan ...

  9. [Java并发编程(五)] Java volatile 的实现原理

    [Java并发编程(五)] Java volatile 的实现原理 简介 在多线程并发编程中 synchronized 和 volatile 都扮演着重要的角色,volatile 是轻量级的 sync ...

随机推荐

  1. c#编程基础之字符串函数

    c#常用的字符串函数 例一: 获取字符串的大小写函数 ToLower():得到字符串的小写形式 ToUpper():得到字符串的大写形式 注意: 字符串时不可变的,所以这些函数都不会直接改变字符串的内 ...

  2. Hibernate POJO在序列化(JSON)时遇到的若干问题

    假设某 POJO 有属性如下: private Set<User> users = new HashSet<>(0); @OneToMany(fetch = FetchType ...

  3. MyBatis(3.2.3) - hello world

    1. 创建数据表(MySQL): DROP TABLE IF EXISTS `department`; CREATE TABLE `department` ( `did` ) unsigned NOT ...

  4. Hadoop权威指南学习笔记二

    MapReduce简单介绍 声明:本文是本人基于Hadoop权威指南学习的一些个人理解和笔记,仅供学习參考,有什么不到之处还望指出,一起学习一起进步. 转载请注明:http://blog.csdn.n ...

  5. 进程间通信——FIFO(多个客户进程,一个服务进程)

    FIFO简介 FIFO就是Unix的一种复合POSIX标准的进程间通信机制.他又称为命名管道,跟管道的不同点是,每个FIFO都有一个路径名与之关联. FIFO虽然有路径名,但是他这中文件是在内核态(管 ...

  6. 2.sparkSQL--DataFrames与RDDs的相互转换

    Spark SQL支持两种RDDs转换为DataFrames的方式 使用反射获取RDD内的Schema     当已知类的Schema的时候,使用这种基于反射的方法会让代码更加简洁而且效果也很好. 通 ...

  7. Activiti工作流几种驳回方式的实现与比较

    最近公司做的一个项目要实现工作流程的收回,驳回等操作,而采用的工作流引擎并不支持驳回功能,这个项目恰好就我和一个实习生一块做,所以这个问题就落到我的头上来解决了... 客户提出的要求是驳回时要记录日志 ...

  8. 【Wyn Enterprise BI知识库】 什么是商业智能 ZT

    商业智能(Business Intelligence,BI),又称商务智能,指用现代数据仓库技术.在线分析处理技术.数据挖掘和数据展现技术进行数据分析以实现商业价值. 图1:商业智能(BI)系统 商业 ...

  9. Matplotlib学习---用matplotlib画直方图/密度图(histogram, density plot)

    直方图用于展示数据的分布情况,x轴是一个连续变量,y轴是该变量的频次. 下面利用Nathan Yau所著的<鲜活的数据:数据可视化指南>一书中的数据,学习画图. 数据地址:http://d ...

  10. argparse - 命令行选项与参数解析

    argparse模块作为optparse的一个替代被添加到Python2.7.argparse的实现支持一些不易于添加到optparse以及要求向后不兼容API变化的特性,因此以一个新模块添加到标准库 ...