1、JVM(Java Virtual Machine)
1.1 组成部分
JVM由三个主要的子系统构成:
类加载子系统
运行时数据区(内存结构)
执行引擎
1.2 运行时数据区
下面主要说一下运行时数据区(内存结构)
堆区: 属于共享内存区域,在绝大多数情况下,Java堆算得上是JVM中空间最大的区域,因为这里的唯一作用就是存放程序中实例化的、对象、数组,几乎所有的对象实例都会在这里分配内存空间。它被所有线程共享,伴随着Java虚拟机的启动而创建。
OutOfMemoryError:如果堆中没有内存完成实例分配,并且堆也无法再扩展时,抛出该异常。
方法区: 属于共享内存区域,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
虚拟机栈: 线程私有,生命周期和线程一致。描述的是 Java 方法执行的内存模型:每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈(栈顶的栈帧是当前正在执行的活动栈,也就是当前正在执行的方法,PC 寄存器也会指向这个地址,只有这个活动的栈帧的本地变量可以被操作数栈使用)的过程。
StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度。
OutOfMemoryError:如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存。
局部变量表: 存放了编译期可知的各种基本类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型)和 returnAddress 类型(指向了一条字节码指令的地址)
栈帧结构图:
本地方法栈: 区别于 Java 虚拟机栈的是,Java 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。也会有 StackOverflowError 和 OutOfMemoryError 异常。
程序计数器: 内存空间小,线程私有。字节码解释器工作是就是通过改变这个计数器的值来选取下一条需要执行指令的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器完成如果线程正在执行一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器的值则为 (Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
1.3 代码展示
查看Java的运行流程,首先写一个测试类,代码如下:
public class TestDemo {
public int math() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
public static void main(String[] args) {
TestDemo testDemo = new TestDemo();
testDemo.math();
}
}
首先将java文件转化为class字节码文件,利用javac命令
javac TestDemo.java
1
编译得到TestDemo.class文件,查看class文件,利用命令javap
javap -p TestDemo.class
Compiled from "TestDemo.java"
public class cn.thislx.lambda.test.TestDemo {
public cn.thislx.lambda.test.TestDemo();
public int math();
public static void main(java.lang.String[]);
}
利用javap -c命令将class字节码文件进行反汇编转化为我们可以理解的指令。
$ javap
用法: javap <options> <classes>
其中, 可能的选项包括:
-help --help -? 输出此用法消息
-version 版本信息
-v -verbose 输出附加信息
-l 输出行号和本地变量表
-public 仅显示公共类和成员
-protected 显示受保护的/公共类和成员
-package 显示程序包/受保护的/公共类
和成员 (默认)
-p -private 显示所有类和成员
-c 对代码进行反汇编
-s 输出内部类型签名
-sysinfo 显示正在处理的类的
系统信息 (路径, 大小, 日期, MD5 散列)
-constants 显示最终常量
-classpath <path> 指定查找用户类文件的位置
-cp <path> 指定查找用户类文件的位置
-bootclasspath <path> 覆盖引导类文件的位置
对代码进行反汇编并导出成文本格式
javap -c TestDemo.class > TestDemo.txt
得到的文本内容如下,我们的代码全部变成了指令:
Compiled from "TestDemo.java"
public class cn.thislx.lambda.test.TestDemo {
public cn.thislx.lambda.test.TestDemo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public int math();
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
public static void main(java.lang.String[]);
Code:
0: new #2 // class cn/thislx/lambda/test/TestDemo
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method math:()I
12: pop
13: return
}
我们需要根据JVM指令集进行理解。
JVM指令集可参考:https://www.jianshu.com/p/bc91c6b46d7b
2、计算机内存模型
在介绍Java内存模型之前,先来看一下到底什么是计算机内存模型,然后再来看Java内存模型在计算机内存模型的基础上做了哪些事情。要说计算机的内存模型,就要说一下一段古老的历史,看一下为什么要有内存模型。
内存模型,英文名Memory Model,他是一个很老的老古董了。他是与计算机硬件有关的一个概念。那么我先给你介绍下他和硬件到底有啥关系。
2.1 CPU和缓存一致性
我们应该都知道,计算机在执行程序的时候,每条指令都是在CPU中执行的,而执行的时候,又免不了要和数据打交道。而计算机上面的数据,是存放在主存当中的,也就是计算机的物理内存啦。
刚开始,还相安无事的,但是随着CPU技术的发展,CPU的执行速度越来越快。而由于内存的技术并没有太大的变化,所以从内存中读取和写入数据的过程和CPU的执行速度比起来差距就会越来越大,这就导致CPU每次操作内存都要耗费很多等待时间。
这就像一家创业公司,刚开始,创始人和员工之间工作关系其乐融融,但是随着创始人的能力和野心越来越大,>逐渐和员工之间出现了差距,普通员工原来越跟不上CEO的脚步。老板的每一个命令,传到到基层员工之后,由>于基层员工的理解能力、执行能力的欠缺,就会耗费很多时间。这也就无形中拖慢了整家公司的工作效率。
可是,不能因为内存的读写速度慢,就不发展CPU技术了吧,总不能让内存成为计算机处理的瓶颈吧。
所以,人们想出来了一个好的办法,就是在CPU和内存之间增加高速缓存。缓存的概念大家都知道,就是保存一份数据拷贝。他的特点是速度快,内存小,并且昂贵。
那么,程序的执行过程就变成了:
当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。
之后,这家公司开始设立中层管理人员,管理人员直接归CEO领导,领导有什么指示,直接告诉管理人员,然后>就可以去做自己的事情了。管理人员负责去协调底层员工的工作。因为管理人员是了解手下的人员以及自己负责>的事情的。所以,大多数时候,公司的各种决策,通知等,CEO只要和管理人员之间沟通就够了。
而随着CPU能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。
按照数据读取顺序和与CPU结合的紧密程度,CPU缓存可以分为一级缓存(L1),二级缓存(L3),部分高端CPU还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。
这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。
那么,在有了多级缓存之后,程序的执行就变成了:
当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。
随着公司越来越大,老板要管的事情越来越多,公司的管理部门开始改革,开始出现高层,中层,底层等管理>>者。一级一级之间逐层管理。
单核CPU只含有一套L1,L2,L3缓存;如果CPU含有多个核心,即多核CPU,则每个核心都含有一套L1(甚至和L2)缓存,而共享L3(或者和L2)缓存。
公司也分很多种,有些公司只有一个大Boss,他一个人说了算。但是有些公司有比如联席总经理、合伙人等机>制。
单核CPU就像一家公司只有一个老板,所有命令都来自于他,那么就只需要一套管理班底就够了。
多核CPU就像一家公司是由多个合伙人共同创办的,那么,就需要给每个合伙人都设立一套供自己直接领导的高>层管理人员,多个合伙人共享使用的是公司的底层员工。
还有的公司,不断壮大,开始差分出各个子公司。各个子公司就是多个CPU了,互相之前没有共用的资源。互不>影响。
下图为一个单CPU双核的缓存结构:
随着计算机能力不断提升,开始支持多线程。那么问题就来了。我们分别来分析下单线程、多线程在单核CPU、多核CPU中的影响。
单线程: cpu核心的缓存只被一个线程访问。缓存独占,不会出现访问冲突等问题。
单核CPU,多线程: 进程中的多个线程会同时访问进程中的共享数据,CPU将某块内存加载到缓存后,不同线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。但由于任何时刻只能有一个线程在执行,因此不会出现缓存访问冲突。
多核CPU,多线程: 每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。
在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。
如果这家公司的命令都是串行下发的话,那么就没有任何问题。
如果这家公司的命令都是并行下发的话,并且这些命令都是由同一个CEO下发的,这种机制是也没有什么问题。>因为他的命令执行者只有一套管理体系。
如果这家公司的命令都是并行下发的话,并且这些命令是由多个合伙人下发的,这就有问题了。因为每个合伙人>只会把命令下达给自己直属的管理人员,而多个管理人员管理的底层员工可能是公用的。
比如,合伙人1要辞退员工a,合伙人2要给员工a升职,升职后的话他再被辞退需要多个合伙人开会决议。两个合>伙人分别把命令下发给了自己的管理人员。合伙人1命令下达后,管理人员a在辞退了员工后,他就知道这个员工>被开除了。而合伙人2的管理人员2这时候在没得到消息之前,还认为员工a是在职的,他就欣然的接收了合伙人给他的升职a的命令。
2.2 处理器优化和指令重排
上面提到在CPU和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。除了这种情况,还有一种硬件问题也比较重要。那就是为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理。这就是处理器优化。
除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排。
可想而知,如果任由处理器优化和编译器对指令重排的话,就可能导致各种各样的问题。
关于员工组织调整的情况,如果允许人事部在接到多个命令后进行随意拆分乱序执行或者重排的话,那么对于这个员工以及这家公司的影响是非常大的。
那么为什么要指令重排呢?
之所以这么做,完全是基于代码执行的性能考虑的。我们知道,一条指令的执行是分多个步骤的,简单的说,可以分为以下几步:
取指 IF
译码和取寄存器操作数 ID
执行或者有效地址计算 EX
存储器访问 MEM
写回 WB
我们的汇编指令也不是一步就执行完成的,在CPU中实际工作时,它还是需要分多个步骤依次执行的。当然,每个步骤所涉及的硬件也可能不同,比如取值时会用到PC寄存器和存储器,译码时会用到指令寄存器组,执行时会使用ALU(算术逻辑单元),写回时需要寄存器组。
由于每一个步骤都可能使用不同的硬件来完成,因此,聪明的工程师们发明了流水线技术来执行指令,如下图所示的工作原理:
可以看到,当第二条指令执行时,第一条指令其实并未执行完,确切地说是第一条指令还没有开始执行,只是完成了取指的操作而已。这样的好处就非常明显了,假如这里每一个步骤都需要花费1毫秒,那么指令2等待指令1完全执行后再执行,则需要等待5毫秒的时间,而是用这种流水线模式后,指令2就只需要等待1毫秒的时间就可以开始执行了,这样以来就带来了很大的性能提升,在商业环境中这种流水线级别甚至更高,性能提升就愈加的明显了。
有了流水线这种模式,我们的CPU才能真正更高效的运行,但是,流水线总是害怕被迫中断。流水线满载时性能是很高的,但是一旦中断,所有的硬件设备就会进入到停顿器,等到再次满载运行就又要等到几个周期,因此性能损失会很大,所以我们必须想办法不让流水线中断。
那么答案就来了,之所以需要做指令重排,就是为了尽量减少指令流水线执行时的中断。当然了,指令重排只是减少中断的一种技术,实际上在CPU涉及中,还有更多的软硬件技术来防止中断,这里就不做更多叙述了。
为了加深对指令重排序的认识,理解指令重排序对性能提升的意义,我们通过一些简单的例子来增加感性的认识。
下图展示了A=B+C这个操作的执行过程,写在左边的是汇编指令,其中
LW表示load加载
LW R1,B就是表示将B的值加载到R1寄存器当中
ADD是加法,ADD R3,R1,R2就是表示将R1R2的值相加并存放到R3中
SW表示存储,SW A,R3就是表示将R3寄存器的值保存到变量A中。
右边就是流水线的情况,其中在ADD指令上就有一个大X,这就表示一个中断,为什么这里会有中断(停顿)呢?原因很简单,R2中的数据还没有准备好,必须要等到它写回到存储器上才能继续使用,所以ADD操作在这里必须等待一次。由于ADD的延迟,导致其后面所有的指令都要慢一步。
我们可以再来看一个稍微更复杂一点的例子:
a = b + c;
d = e - f;
上述代码的执行应该会是这样的,如下图所示:
从上图我们可以看出,由于ADD和SUB操作都需要等待上一条指令的结果,所以插入了不少的停顿,那么对于这段代码,我们是否可以消除这些停顿呢,显然是可行的。我们只需要将LW Re,e和LW Rf,f的操作移动到前面去执行即可,思路很简单,就是先加载e和f对程序执行是没有影响的,因为既然ADD的时候要停顿一下,那么不如将停顿的时间去用来做点别的操作。
针对上面的指令流程,我们将第5条指令挪到第2条指令的后面执行,将第6条指令挪到上图的第3条指令后面去执行,于是我们重新画一下指令重排后的执行流程图,如下所示:
上面这块代码的运算流程,在指令重排后减少了2次停顿,对于提高CPU处理性能效果明显,由此可见,指令重排对于提高CPU处理器性能还是十分必要的,虽然确实带来了乱序的问题,但是这点牺牲完全是值得的。
2.3 Happen-Before规则
上面介绍了指令重排,虽然Java虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有原则的,并发所有的指令都可以随便更改执行位置,下面罗列了一些基本原则,这些原则是指令重排不可以违背的:
程序顺序原则:一个线程内保证语义的串行性
volatile规则:volatile变量的写操作,先发生于读操作,这保证了volatile变量的可见性
锁规则:解锁(unlock)必然发生在随后的加锁(lock)前
传递性:A先于B,B先于C,那么A必然先于C
线程的start()方法先于它的每一个动作
线程的所有操作先于线程的终结(Thread.join())
线程的中断(interrupt)先于被中断线程的代码
对象的构造函数执行、结束先于finalize()方法
以程序顺序原则为例,重排后的指令绝对不能改变原有的串行语义,比如:
a = 1
b = a + 1
由于第二条语句依赖第一条语句执行的结果,如果冒然交换两条代码的执行顺序,那么程序的语义就会被修改,因此这种情况是绝对不允许发生的,这也是指令重排必须遵循的第一条基本原则。
此外,锁规则强调,unlock操作必然发生在后续对同一把锁的lock之前。也就是说,如果对一个锁的解锁后再加锁,那么加锁的执行动作绝对不可能重排到解锁的动作之前,很显然如果这么做,加锁就没有意义了。
其他几条原则也类似,都是为了保证指令重排不会破坏原有的语义结构。
3、JMM(Java Memory Model)
前面介绍过了计算机内存模型,这是解决多线程场景下并发问题的一个重要规范。那么具体的实现是如何的呢,不同的编程语言,在实现上可能有所不同。
我们知道,Java程序是需要运行在Java虚拟机上面的,Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
提到Java内存模型,一般指的是JDK 5 开始使用的新的内存模型,主要由JSR-133: JavaTM Memory Model and Thread Specification 描述。感兴趣的可以参看下这份PDF文档(http://www.cs.umd.edu/~pugh/java/memoryModel/jsr133.pdf)
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。
这里面提到的主内存和工作内存,读者可以简单的类比成计算机内存模型中的主存和缓存的概念。特别需要注意的是,主内存和工作内存与JVM内存结构中的Java堆、栈、方法区等并不是同一个层次的内存划分,无法直接类比。《深入理解Java虚拟机》中认为,如果一定要勉强对应起来的话,从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分。工作内存则对应于虚拟机栈中的部分区域。
所以,再来总结下,JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
3.2 JMM的实现
了解Java多线程的朋友都知道,在Java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、final、concurren包等。其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。
在开发多线程的代码的时候,我们可以直接使用synchronized等关键字来控制并发,从来就不需要关心底层的编译器优化、缓存一致性等问题。所以,Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。
本文并不准备把所有的关键字逐一介绍其用法,因为关于各个关键字的用法,网上有很多资料。读者可以自行学习。本文还有一个重点要介绍的就是,我们前面提到,并发编程要解决原子性、有序性和一致性的问题,我们就再来看下,在Java中,分别使用什么方式来保证。
原子性
在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。在synchronized的实现原理文章中,介绍过,这两个字节码,在Java中对应的关键字就是synchronized。
因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。
可见性
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。
Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。
除了volatile,Java中的synchronized和final两个关键字也可以实现可见性。只不过实现方式不同,这里不再展开了。
有序性
在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:
volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。
好了,这里简单的介绍完了Java并发编程中解决原子性、可见性以及有序性可以使用的关键字。读者可能发现了,好像synchronized关键字是万能的,他可以同时满足以上三种特性,这其实也是很多人滥用synchronized的原因。
但是synchronized是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。