2.3.1 对象创建过程:
其中3.分配内存,有两种方式:
判断对象是“死”是“活”
方法 | 方法描述 | 优点 | 缺点 | 总结 |
---|---|---|---|---|
引用计数法 | 记录每个对象的引用数量,引用为0的对象可以被回收 | 无法解决循环引用的问题 | java不采用这种方法,python采用 | |
可达性分析法 | 回收通过GC Roots不可达的对象(GC root不可达的对象还有一次听过finalize拯救自己的机会) | Java采用的方法 |
再谈对象引用
引用类型 | 描述 | 使用场景 |
---|---|---|
强引用 | new 出来的 | 地球人都知道 |
软引用 | SoftReference,OOM之前会回收掉这些引用 | 缓存对象 |
弱引用 | WeakReference,下一次GC就会被回收掉 | WeakHashMap中的key就是弱引用 |
虚引用 | PhantomReference,不对对象的生存时间构成影响,存在的唯一目的就是在对象被系统回收时收到一个系统通知 |
算法 | 优点 | 缺点 | 总结 |
---|---|---|---|
标记-清除 | 存在碎片问题 | ||
复制 | 在对象存活率较高时进行比较多的复制,效率变低 | 现在的商业虚拟机都采用这种算法处理新生代,Eden:s1:s2=8:1:1 | |
标记-整理 | 应用老年代 |
3.4.1 枚举根节点
可作为GC Roots的节点主要在全局性的引用(常量或类静态属性)与执行上下文(栈帧中的本地变量表)中。
如果要逐个检查这些引用一定会耗费很多时间。JVM通过OopMap来避免遍历。
OopMap中记录着全局性的引用与执行上下文中的对象引用。
3.4.2 安全点
JVM在安全点产生OopMap
3.4.3 安全区域
安全区域是指在一段代码片段中引用关系不会发生变化。
四个阶段:
CMS缺点:
G1特点:
在G1收集器中,Region之间的对象引用以及其他收集器的新生代与老年代之间的对象引用,虚拟机都是使用rememberedSet来避免全堆扫描的,
G1中没个region都对应一个rememberedSet。虚拟机发现程序对reference类型的数据进行写操作的时候,会产生一个中断,检查reference指向的对象是否在不同的region中,然后记录rememberedSet。rememberedSet是GC Roots的一部分,
G1四个步骤:
分配原则:
长期存活的对象将进入老年代:-XX:MaxTenuringThreshold=15表示到达15岁的对象将进入老年代。在特殊情况下对象不到15岁也进入老年代:Survivor空间中相同年龄的对象的大小综合大于Survivor空间的一半,则大于等于该年龄的对象直接进入老年代。
空间分配担保
jps -v 查看虚拟机启动时的JVM参数
jps -v
175 MyApplication -Xmx4g -Xms4g -Xmn1g -Xss256k -XX:+CMSClassUnloadingEnabled -XX:PermSize=256M -XX:MaxPermSize=512M -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+UseG1GC
jstat -class 171
Loaded : 已经装载的类的数量
Bytes : 装载类所占用的字节数
Unloaded:已经卸载类的数量
Bytes:卸载类的字节数
Time:装载和卸载类所花费的时间
jstat -gc 171
S0C:年轻代中第一个survivor(幸存区)的容量 (kb)
S1C:年轻代中第二个survivor(幸存区)的容量 (kb)
S0U :年轻代中第一个survivor(幸存区)目前已使用空间 (kb)
S1U :年轻代中第二个survivor(幸存区)目前已使用空间 (kb)
EC :年轻代中Eden(伊甸园)的容量 (kb)
EU :年轻代中Eden(伊甸园)目前已使用空间 (kb)
OC :Old代的容量 (kb)
OU :Old代目前已使用空间 (kb)
MC:metaspace(元空间)的容量 (kb)
MU:metaspace(元空间)目前已使用空间 (kb)
YGC :从应用程序启动到采样时年轻代中gc次数
YGCT :从应用程序启动到采样时年轻代中gc所用时间(s)
FGC :从应用程序启动到采样时old代(全gc)gc次数
FGCT :从应用程序启动到采样时old代(全gc)gc所用时间(s)
GCT:从应用程序启动到采样时gc用的总时间(s)
option
no option 输出全部的参数和系统属性
-flag name 输出对应名称的参数
-flag [+|-]name 开启或者关闭对应名称的参数
-flag name=value 设定对应名称的参数
-flags 输出全部的参数
-sysprops 输出系统属性
jinfo -flags 171 //输出全部JVM参数
分析OOM的通常做法:
-1.首先配置JVM启动参数,让JVM在遇到OutOfMemoryError时自动生成Dump文件
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path
-2.如果没有上面的配置,则jmap 生成堆文件。注意这个操作是stop the world的。
jmap -dump:format=b,file=/path/heap.bin
-3.用MAT分析工具分析堆文件
将JVM堆栈信息dump到/tmp/jstack20190316这个文件
jstack >/tmp/jstack20190316
jstack20190316中的内容如下图
参考:cpu持续高的案例分析.
案例描述:
一个15万PV/天左右的在线文档网站,硬件是32位系统1.5G堆内存。
某天升级了硬件系统,新的硬件为4个cpu,16GB物理内存,64为CentOS 5.4,Resin为Web服务器。整个服务器没有部署其他应用。管理员为了尽量利用硬件资源使用了64位的JDK1.5,并通过-Xmx和-Xms将堆固定为12GB。是使用情况是,网站总是不定期的出现长时间失去响应的情况。
分析:
网站长时间失去响应是由于GC导致的,回收12G的堆,一次Full GC停顿高达12s。并且由于程序设计关系,文档从磁盘加载到内存,反序列化文档产生的大对象很对进入老年代,这导致12G很快被用完。导致频繁FULL GC。
暂且不说代码的问题。程序部署上的问题是,过大堆内存回收带来长时间停顿。
科普:
高性能硬件上部署程序主要有两种方式:
部署方式 | 部署实践 | 面对的问题 |
---|---|---|
64位JDK | 有把握控制FULL GC的频率,比如可以通过深夜定时任务触发FullGC或者定时重启来保持内存可用空间在一个稳定水平。 | 1.内存回收长时间停顿。2.64位性能低于32位。3.这种程序几乎违法在oom的时候生成堆快照,即便生成也很难分析。4.内存消耗比32位快。 |
32位虚拟机集群 | 在一个物理机上启动多个应用进程,每个进程对应一个端口号,再搭建一个前端的负载均衡器,以反向代理的方式分配请求 | 1.尽量避免节点竞争全局资源,典型的就是磁盘竞争。2.很难高效的利用某些资源,例如连接池。3.32位windows平台内存受限最多2GB,Linux系统中受限最多4GB。4.大量使用本地缓存的应用比较浪费内存,可以考虑使用集中式缓存。 |
案例描述:
一个MIS系统,硬件为两台2个cpu、8GB内存的小型机,每台机器启动3个实例构成了一个6个节点的集群。
有一些共享数据需要在各个节点直接共享,开始这些数据放在mysql中,但是读写竞争激烈影响性能,后来构建了一个全局缓存,需要共享的数据线放在缓存中,共享完成才清除缓存。共享的过程是把数据用tcp发送给各个节点。
分析:
由于tcp存在失败的可能,需要重发,所以在没有确认所有节点都收到信息前,发送的数据必须在内存中保留。当个节点直接的网络交互非常频繁时,如果网络情况不能满足要求,重发数据在内存中不断堆积,很快就oom了。
案例描述:
一小学电子考试系统,32位系统,1.6GB内存,服务器不定时OOM,加入-XX:+HeapDumpOnOutOfMemoryError,但是在OOM的时候并没有生成堆文件,挂着jstat一直紧盯着屏幕发现GC并不频繁,各区都表示“压力不大”,但就是不停的oom。
分析:
32位windows平台最多利用2GB内存,其中1.6GB给了堆,Direct Memory只有0.4GB可用。Direct Memory这块内存只有等老年代满了Full GC的时候顺便把他回收一把,否则他得不到回收的机会就只能抛出oom。
案例描述:
一个数字校园系统,在系统压测时发现请求时间比较长,通过操作系统的mpstat工具发现cpu使用率很高,并且占用大部分cpu资源的程序不是应用系统本身,这是不正常的。
通过工具查看哪些系统调用话费了最多的cpu资源,发现竟然是“fork”系统调用,众所周知,”fork“是你linux用来产生新进程的。
系统开发人员最终找到可答案:每个用户请求处都需要执行一个外部shell脚本活的系统的一些信息,执行这个shell脚本是通过java的Runtime.getRuntime().exec()来调用的。
JVM执行这个命令的过程是:首先clone一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后退出这个进程。
如果频繁执行这个操作,系统的消耗会很大。
JVM可以运行在各种平台的原因就是他可以执行字节码(.class),JVM支持任何语言编译成的字节码。
class文件是以8位字节为单位的二进制流,个数据项之间没有分隔符。文件中用类似c语言结构体的伪结构来存储数据,数据类型只有两种:无符号数和表。
无符号数属于基本数据类型,用u1、u2、u4、u8来表示1、2、4、8字节的无符号数。无符号数可以用以描述数字、索引引用、数量之或者按照utf-8编码构成的字符串值。
表是由无符号数和其他表构成的复合类型。表习惯以"_info"结尾。整个class文件本质上就是一张表。
无论是无符号数还是表,当需要描述多个数据时,会使用前置计数器+若干数据项的形式,称这种描述形式为集合。
6.3.1 魔数与class文件版本
每个class文件的头4个字节称为魔数,它的唯一作用是确定这个文件是否是虚拟机可接受的class文件。
class文件的魔数值固定为:0xCAFEBABY
紧接着魔数的是class文件的版本号:5、6字节是次版本号(minor version)。7、8字节是主版本号(major version),该版本号和jdk版本有对应关系,低版本的class不能运行在高版本的jdk。class版本号从45.0开始,对应JDK1.1。jdk1.7对应class版本51.0。
6.3.2 常量池
紧挨着版本号之后是常量池,是占用class空间最大的项目之一。
常量池的数据项目数量不固定,所以采用集合描述,所以在常量池的入口放一个计数器记录常量池常量的数量。
常量池中放两类常量:
public class Person {
private String name;
private String sex;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSex() {
return sex;
}
public void setSex(String sex) {
this.sex = sex;
}
}
~ javap -verbose Person.class
Classfile Person.class
Last modified 2019-4-5; size 523 bytes
MD5 checksum b91fd6abd4007d7cb376e5a44c455a4b
Compiled from "Person.java"
public class Person
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#21 // java/lang/Object."":()V
#2 = Fieldref #4.#22 // Person.name:Ljava/lang/String;
#3 = Fieldref #4.#23 // Person.sex:Ljava/lang/String;
#4 = Class #24 // Person
#5 = Class #25 // java/lang/Object
#6 = Utf8 name
#7 = Utf8 Ljava/lang/String;
#8 = Utf8 sex
#9 = Utf8
#10 = Utf8 ()V
#11 = Utf8 Code
#12 = Utf8 LineNumberTable
#13 = Utf8 getName
#14 = Utf8 ()Ljava/lang/String;
#15 = Utf8 setName
#16 = Utf8 (Ljava/lang/String;)V
#17 = Utf8 getSex
#18 = Utf8 setSex
#19 = Utf8 SourceFile
#20 = Utf8 Person.java
#21 = NameAndType #9:#10 // "":()V
#22 = NameAndType #6:#7 // name:Ljava/lang/String;
#23 = NameAndType #8:#7 // sex:Ljava/lang/String;
#24 = Utf8 Person
#25 = Utf8 java/lang/Object
{
public Person();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 5: 0
public java.lang.String getName();
descriptor: ()Ljava/lang/String;
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field name:Ljava/lang/String;
4: areturn
LineNumberTable:
line 11: 0
public void setName(java.lang.String);
descriptor: (Ljava/lang/String;)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: putfield #2 // Field name:Ljava/lang/String;
5: return
LineNumberTable:
line 15: 0
line 16: 5
public java.lang.String getSex();
descriptor: ()Ljava/lang/String;
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #3 // Field sex:Ljava/lang/String;
4: areturn
LineNumberTable:
line 19: 0
public void setSex(java.lang.String);
descriptor: (Ljava/lang/String;)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: putfield #3 // Field sex:Ljava/lang/String;
5: return
LineNumberTable:
line 23: 0
line 24: 5
}
SourceFile: "Person.java"
6.3.3 访问标志位
在常量池之后紧接着的两个字节是访问标志位(access_flag),用于标识一些类或接口层次的访问信息,是否是public,是否是abstract等。
共有16个字节可以使用,当前只使用了8个,没有使用到的一律设置为0。
6.3.4 类索引、父类索引、与接口索引集合
类索引、父类索引分别是一个u2类型的数据,接口索引集合是一组u2类型数据的集合,class文件由这三项确定类的继承关系。
类索引用于确定这个类的全限定名,父类索引用于确定父类的全限定名。
6.3.5 字段表集合
字段表用于描述接口或类中声名的变量。字段包含类级别变量以及实例级别变量,但不包含方法内的局部变量。
6.3.6 属性表集合
字段表和方法表都可以携带自己的属性表集合,
java虚拟机指令是由一个字节长度、代表着某种特定操作含义的数字(称为操作码),以及跟随其后的多个参数(称为操作数)构成。
由于java虚拟机采用面向操作数栈而不是面向寄存器的架构,所以大多数指令都不包含操作数,只有一个操作码。
6.4.1 字节码与数据类型
在java虚拟机的指令集中,大多数的指令都包含了起操作的数据类型信息,例如,iload是加载int类型的数据,fload是加载float类型的数据。 这两条指令在class文件中必须拥有独立的操作码。
对于大部分数据类型的指令,他们的操作码中包含特殊的字符来表示到底为哪种数据类型服务。其中i代表int、l代表long、s代表short、b代表byte、c带表char、f代表float、d代表double、a代表refrence。
由于java虚拟机操作码只有一个字节长度(8位),也就是说操作码最多256个,这就导致,操作码不能支持所有数据类型,其实,大部分指令都不支持byte、char、short,甚至没有任何指令支持 boolean,编译器会在编译期或者运行期将其扩展为相应的int类型,使用int指令。
6.4.2 加载和存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。
6.4.3 运算指令
大体上算术指令可以分为两种:堆整形数据进行运算,堆对浮点类型数据运算。
6.4.4 类型转换指令
java虚拟机直接支持宽化类型转换,类型转换指令一般用于处理窄化类型转换和处理字节码指令集没有直接支持相关类型的问题。
6.4.5 对象创建和访问指令
6.4.6 操作数栈指令
6.4.7 控制转移指令
6.4.8 方法调用和返回指令
6.4.9 异常处理指令
6.4.10 同步指令
在java语言中,类型的加载、连接和初始化过程都是在程序运行期完成的。这种策略会有一定的性能开销,但是多了更多的灵活性。例如一个面向接口的应用程序,在运行期才指定具体的实现类。
类从被加载到虚拟机内存到卸载出内存为止,他的整个生命周期包含以下七个部分:
虚拟机规定有且只有5种情况必须立即对类进行初始化:
类加载的全过程包括:加载、验证、准备、解析、初始化
7.3.1 加载
“加载”是类加载过程的一个阶段。在加载阶段JVM需要完成三件事情:
7.3.2 验证
这个阶段大致上会完成4个阶段的验证动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
文件格式验证:
元数据验证:
字节码验证:
这个阶段是最复杂的阶段,通过分析数据流和控制流,确定程序语义合法,符合逻辑。
符号引用验证:
这个验证发生在将符号引用转化为直接引用的时候、发生在解析阶段。需要验证的内容:
7.3.4 解析
将虚拟机常量池中的符号引用替换为直接引用的过程。
7.4.1 类与类加载器
同一个类,被不同的类加载器加载也是不相等的。
7.4.2 双亲委派模型
从JVM角度看,类加载器大体上可以分为两类:启动类加载器(c++实现的,是JVM的一部分) && 其他类加载器(java实现的,独立于JVM外部)。
从程序员角度看,可分为三类:
-双亲委派是在jdk1.2加入的,为了兼容老版本,提供了findClass()方法。jdk1.2之前都是直接重写loadClass(),jdk1.2之后是将双亲委派实现在loadClass()中的。
本章从概念模型的角度来讲解虚拟机的方法调用和字节码执行。
每一个方法的调用都对应一个栈帧在JVM里出入栈。
栈帧的内容包含:局部变量表、操作数栈、动态链接、方法返回地址等。
8.2.1 局部变量表
用于存储方法参数和方法内部的局部变量。
在编译期就能确定局部变量表的最大容量。
局部变量表以变量槽为最小单位。
8.2.2 操作数栈
栈的最大深度在编译期确定。
方法刚开始的时候操作数栈是空的,在运行时,通过操作数栈传递参数或者进行算数运算。
理论上两个栈帧是完全独立的,但是有一些优化情况会让两个栈帧出现重叠。
8.2.3 动态链接
每个栈帧都包含 一个执行运行时常量池的引用,该引用是为了支持动态链接。
class文件的常量池中有大量的符号引用,这些符号引用有些在静态解析中转化为直接引用,有些在运行时转化为直接引用,称为动态链接。
8.2.4 方法返回地址
方法退出的过程:
1.把当前栈帧出栈
2.恢复上层方法的局部变量表和操作数
3.把返回值压入调用者栈的操作数栈
4.调整pc计数器指向下一条指令
所有的方法调用在class文件中都是常量池中的符号引用,在类加载的解析阶段会把一些符号变为直接引用,还有一些要在执行器才能确定具体的方法。
8.3.1 解析
有些方法调用在编译期就可以确定调用入口,比如静态方法、私有方法。
jvm中提供了5条方法调用的指令:
8.4.1 解释执行
本节探讨JVM如何执行方法中字节码指令的。JVM执行java代码分为解释执行和编译执行。本章探讨解释执行。
8.4.2 基于栈的指令集和基于寄存器的指令集
Java编译期输出的指令流,是基于栈的指令集架构。
pc机中直接支持的指令集架构是寄存器指令集。
栈指令集的优势是可移植,缺点是执行速度会稍慢。
8.4.3 基于栈的解释器执行过程
在class文件格式与执行引擎这部分中,用户的程序能直接影响的内容并不多。
能通过程序进行操作的,主要是字节码生成与类加载器这两部分功能。
本章几个例子,是字节码生成和类加载器的经典活用。
9.2.1 Tomcat:正统的类加载器架构
主流的web服务器都实现了自己的类加载器,因为一个健全的web服务器要解决以下问题:
10.2.1
javac的编译过程大概可以分为3个过程
语法分析根据token序列构造抽象语法树(AST)的过程,是描述程序语法结构的树形表达方式。每个节点都代表一个语法结构,如:包、类型、修饰符、运算符、接口、返回值、甚至是注释。
完成了词法分析和语法分析之后,接下来就是填充符号表。
符号表是由符号地址和符号信息构成的表格。其内容在编译的不同阶段都会用到,在语义分析中,可用于语义检查和产生中间代码、在目标代码生成阶段,当堆符号进行地址分配时,可作为地址分配的依据。
10.2.3 注解处理器
解析注解处理器就是不断修改语法树。
10.2.4 语义分析与字节码生成
语法分析之后,编译器获得了程序的语法树,这保证程序结构的正确。
而语义分析用于保证程序逻辑正确,其主要任务是对语法树进行上下文有关性质的检查。
语义分析过程包含标注检查、数据和控制流分析两部分。
10.3.1 泛型与类型擦除
泛型是类型擦除的,也就是说编译成class文件后,泛型会被擦除。
但是元数据中还是有泛型信息的,这也是我们可以通过反射得到参数化类型的根本依据。
拆箱、装箱在编译之后被转化成对应的包装盒还原方法、如Integer.valueof()、Integer.initValue()。
遍历循环则是把代码还原成了迭代器的实现,这也是为什么遍历循环需要被遍历的类实现iterable接口。
即时编译器(JIT)的作用:提高热点代码的执行效率,在运行期,将这些代码编译成与本地平台相关的机器代码,并进行个种层次的优化。
热点代码是:摸个方法或者代码块,频繁使用。
JIT并不是JVM必须的部分,但却是最核心最能体现JVM技术水平的部分。
本章以hotSpot虚拟机为例讲JIT.围绕以下几个问题展开:
为何hotSpot要使用解释器和编译器并存的架构
当程序运行环境中内存限制较大时(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用编译器提高效率。
为何hotSpot要使用两个不同的即时编译器
hotSpot内置两个编译器,Client Compiler和Server Compiler(C1、C2),默认解释器与其中一个编译器配合使用,使用哪个编译器取决于JVM运行的模式。
解释器与编译器搭配的方式成为”混合模式“
使用参数”-Xint“强制JVM运行在”解释模式“,这是编译器完全不介入工作
使用参数”-Xcomp“强制JVM运行在”编译模式“ ,这是将优先使用编译器执行程序
java -version命令查看当前JVM运行的模式
~ java -version
java version "1.8.0_191"
Java(TM) SE Runtime Environment (build 1.8.0_191-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)
➜ ~
程序何时使用解释器,何时使用编译器
JIT需要占用程序运行时间,要编译出优化程度高的代码则需要的时间也更多。而且也需要解释器手机性能监控信息,这导致解释器运行时间长。
为了在程序启动速度和运行效率之间达到平衡,hotSpot采用分层编译的策略。
第0层 程序解释执行 不开启性能监控
第1层 C1编译,进行简单可靠的优化
第2层 C2编译,开启一些耗时较长的优化
这种分层策略C1、C2同时运行,C1可以获得更好的编译速度。C2可以获得更好的编译质量。
哪些程序会被编译成本地代码?如何编译成本地代码
热点代码有两类:
被多次调用的方法
被多次执行的循环体
那么这里的”多次“怎么定义和衡量呢?JVM有两种热点探测的方式:
基于采样的热点探测:方法是定期检查各个线程的栈顶方法,经常出现的栈顶的方法就是热点方法。优点是简单,高效而且可以取得方法的调用关系。缺点是不精确。
基于计数器的热点探测:每个方法一个计数器统计执行次数,超过一定阈值就是热点方法。这种方式得到的结果准确但是操作麻烦而且不能得到调用关系。
hotSpot采用第二种-基于计数器的热点探测方法,它为每个方法准备两个计数器,方法调用计数器和回边计数器。两个计数器都有阈值,
方法调用计数器
client模式下阈值是1500、server模式下是10000次,可通过虚拟机参数-XX:CompileThreshold设置阈值。方一个方法被调用时,先检查该方法是不是被JIT优化过的方法,如果不是则计数器+1,然后判断调用计数器+回边计数器之和是否超过阈值。
如果不做任何设置,方法计数器统计的并不是绝对次数,超过一段时间还没到达阈值则计数器值减半,称为热度衰减,这段时间称为半衰周期。热度衰减实在GC时顺便进行的,可通过参数-XX:UseCounterDecay来关闭热度衰减,通过-XX:CounterHalfLifeTime设置半衰周期时间。
回边计数器
用于统计一个方法中循环体方法的执行次数。
当字节码中遇到指令向后跳转称为“回边”。
没有热度衰减。
计算机的存储设备与处理器的运算速度存在几个数量级的差距,所以加入告诉缓存作为内存与处理器之间的缓冲。
在多处理器中每个处理器都有自己的高速缓冲,如何保证缓存一致性?这就需要各处理器都遵循一些协议。
除了高速缓存之外,代码可能会被乱序执行,JVM中也有指令重排序。
JVM试图用内存模型来屏蔽各种硬件和操作系统的内存访问差异,让Java程序在各个平台达到一致的内存访问效果。C、C++直接使用硬件和操作系统的内存模型,有可能在一个平台上运行正常的系统在另一套系统上却运行出错。
主内存与工作内存
JVM内存模型主要目标是定义程序中各个变量的访问规则,即存储虚拟机中的变量和读取内存中变量。
JVM内存模型规定所有变量都存储在主存,但是每个线程还有自己的工作内存。
线程、主存、工作内存三者之间的交互关系如图:
内存交互操作
工作内存与主存之间的交互细节,JVM内存模型定义了8个操作,每个操作都是原子的。
实现线程的三种方式
使用内核线程实现。由内核完成线程调度和切换,缺点是需要占用内核资源,这导致能创建的线程数量有限。而且切换线程需要用户态和内核态切换,耗费大。
使用用户线程实现
进程和用户线程1:N。
优点是不需要内核的支援、缺点也是没有内核支援。
这就使得其实现 比较复杂,java中已经放弃使用这种实现。
使用用户线程加轻量级进程实现
用户线程和轻量级进程比例不定,N:M。
用户线程的创建、切换 还是在用户空间进行。
线程调度与系统调用通过轻量级内核线程来完成,降低了整个进程被阻塞风险。
java线程调度’
有两种方式:
协同式线程调度。A线程执行完主动通知系统切换到另一个线程上。优点是实现简单缺点是执行时间不可控。
抢占式线程调度。每个线程由系统分配时间片。java使用这种方式。
可以通过设置线程优先级来给一些线程更多的执行时间。
状态转换