Java JVM
介绍关于Java JVM的相关概念和内容。
JDk(Java Development Kit)
- Java程序设计语言
- Java虚拟机
- Java API类库
JRE(Java Runtime Environment)
- Java API类库中的Java SE API子集
- Java虚拟机
Java技术的未来趋势
- 模块化,已在Java9中得到实现,让Java轻量级部署得到可能——之前即使运行一个简单的HelloWorld.java程序,都需要先通过Classpath配置大小为上百兆的JDK和JRE环境,而在模块化的Java中,不同的功能封装到不同的模块中,一个程序不一定需要用到所有的功能,也即不一定需要加载所有的模块,只需要添加所需模块的映射即可(
requires/exports)。 - 混合语言,即Java平台JVM上多语言混合编程,基本已实现,现在在Java IDE中可以通过加载不同的插件实现在Java平台JVM上的编程,比如Groovy/Scala/PHP/Jython等。
- 多核并行——即并发问题,JDK1.5已经引入了
java.util.concurrent包实现一个粗粒度的并发框架,在JDK1.7中加入的java.util.concurrent.forkjoin包则扩充了该框架。此外,Java8中通过lambda表达式实现了Java的函数式编程——天然适合并发,为Java在多核并发时代不被淘汰给了一剂定心剂。 - 语法的丰富——比如过去Java的发展中,注入了自动装箱、泛型等语法,Java8和Java9中又分别加入了lambda表达式和模块化,语法的丰富让Java的功能愈加丰富完整(但也可能让不同版本的使用者感到疑惑,且版本之间的兼容性需要更加小心地对待)。
- 64位虚拟机
Java内存
Java与C++之间有一堵“高墙”——内存动态分配与垃圾回收技术,墙内的人想出去,墙外的人想进来。
基于JVM自动内存管理机制,可以很大程度上避免内存泄露和内存溢出的问题,但是一旦出现了内存泄露或内存溢出问题,如果不了解JVM,那么排查问题的工作将很难开展。
JVM运行时的数据区
程序计数器
线程所执行的字节码的行号指示器——根据计数器的值选取下一条需要执行的字节码指令。
线程的“私有内存”——JVM的多线程是通过线程的轮流切换和分配处理器执行时间的方式(类似对CPU进行分时复用操作)实现的,所以每条线程都需要在切换之前记录当前执行的位置,即每条线程的程序计数器都是独立且互不影响的。
程序计数器,这个内存区域是唯一一个在JVM规范中没有规定任何OutOfMemoryError的区域。
JVM栈
线程“私有内存”,生命周期与线程相同。
描述Java方法执行的内存模型——每个方法在执行的时候都会创建一个栈帧(Stack Frame),一个方法从调用到执行直至完成的过程,对应的就是一个栈帧在JVM栈中从入栈到出栈的过程。
栈帧中存储局部变量表、操作数栈、动态链接、方法出口(返回地址)等信息。
局部变量表的存储内容——所需的内存空间在编译期将会完成分配:
- 各种基本数据类型 ;
- 对象引用(Reference类型) ,可能是指向对象起始位置的引用指针,或者是指向一个代表对象的句柄或其他与此对象相关的位置;
- returnAddress类型,指向一条字节码指令的地址。
由于局部变量表所需的内存空间在编译期间就会完成分配,因此在进入一个方法时,方法需要在栈帧中分配多大的局部变量空间是完全确定的,方法运行期间将不会改变局部变量表的大小。
异常情况:
- StackOverflowError——线程请求的栈深度大于JVM栈所允许的栈深度;
- OutOfMemoryError——线程请求的栈深度大于JVM栈可动态扩展的上限,JVM栈无法申请到足够的内存。
本地方法栈
线程“私有内存”。
作用于JVM栈相同,只不过其中的方法是JVM使用到的由native关键字修饰的本地已编译方法(一般都是C/C++)。
与native相关的内容:JNI(Java Native Interface),详见另一篇文章
不同JVM对于该部分的实现有所不同:有的将本地方法栈与JVM栈合二为一,有的则分开。
Java堆
线程共享内存——存放对象实例和数组(由于栈上分配和标量替换等优化技术的出现,并不是所有的对象都分配在堆上)。
GC堆(Garbage Collected Heap)——垃圾回收器管理的主要区域。
Java堆可以位于物理不连续的内存空间,只要逻辑连续即可。
异常情况:
- OutOfMemoryError——堆中没有多余内存可以分配给实例,且无法再进行扩展。
方法区
线程共享内存——存储已被JVM加载的类信息、常量、静态变量、JIT编译器编译后的代码等数据。(HotSpot JVM中,类对象——
java.lang.Class对象,也存放在方法区中)方法区也需要垃圾回收管理——主要针对常量池的回收和对类型的卸载。
类型的卸载,是JVM的一种优化技术,其中有两点内容:
- 一个已经被加载的类型,被卸载的概率很小或者被卸载的时间不确定;
- 一个被特定的类加载器实例加载的类型,在运行的过程中可以认为是无法被更新的。
“无用的类”判定条件:
- 类的所有实例都已经被回收——Java堆中不存在该类的任何实例;
- 加载该类的ClassLoader已经被回收;
- 类对应的
java.lang.Class对象没有在任何地方被调用——包括没有在任何地方通过反射访问该类的方法。
异常的情况:
- OutOfMemoryError——无法继续分配内存。
运行时常量池
方法区的一部分——用于存放编译期类加载后生成的字面量和符号引用。
字面量:由字母,数字等构成的字符串或者数值,它只能作为右值出现;
符号引用:一组符号,用于描述所引用的目标,可以是任何形式的字面量。
动态性——与Class文件常量池不同,运行时常量池除了存储编译期类加载后生成的常量之外,还可以存储运行时生成的新的常量——String类的
intern()方法。"str".intern()的功能:检查字符串池(常量池)中是否存在”str”这个字符串,若存在则返回该字符串;若不存在则将”str”添加到字符串池中,然后返回其引用。联想到了关于字符串的两个比较方法:
==——比较的是两个字符串的引用,即两个字符串的内存地址;equals()——比较的是两个字符串的内容本身。
关于字符串的创建方法:
String str1 = new String("A");JDK1.6以及之前:
每一个
new都将在堆中创建一个新的对象,然后检查字符串常量池,若常量池中已存在内容相同的字符串,那么将不会额外在常量池中生成对象,否则将在常量池中生成”A”。所以,
new将会创建一个或者两个字符串对象,引用将会指向堆中创建的对象。intern()方法需要在常量池中存储对象(如果发现常量池中没有相应对象)。JDK1.7以及之后:
常量池不一定需要存储一个对象,可以直接存储堆中的引用。
所以,在JDK1.7中,
intern()方法如果发现在常量池中没有相应的对象,可以直接将堆中的引用放入常量池中,而不会在常量池中存储一个新的对象——这也是JDK1.7的intern()节省内存的原理所在。String str2 = "A";首先在常量池中创建一个对String类的对象引用变量str2,然后检查常量池中是否有”A”,若有则将str2直接指向”A”即可,否则将”A”存放在常量池中,然后将str2指向”A”。
异常情况:
- OutOfMemoryError
直接内存
并不是JVM运行时数据区的一部分。
JDK1.4中新加入的NIO类,引入一种基于通道(Channel)与缓冲区(Buffer)的I/O方式——使用Native函数库直接分配堆外内存 ,然后通过一个存储在Java堆中的DirectByteBuffer对象作为该内存的引用对内存进行操作——可以避免在Java堆和Native堆之间来回复制数据,从而提升一些场景中的性能。
所以,Java堆与Native堆(也即直接内存)是互相独立的,二者都会收到机器物理内存的限制,如果忽略直接内存,那么可能会在Java堆动态扩展的过程中出现OutOfMemoryError的异常。
对象的创建
new关键字检查
new指令的参数是否能在常量池中定位到一个类的符号引用,并检查符号引用所代表的类是否已经被加载、解析和初始化过,若没有则需要先执行相应的类加载过程;JVM为新生成的对象分配内存:
两种分配内存的方式:指针碰撞(Bump the Pointer,连续内存分配),空闲列表(Free List,不连续内存分配)
两种内存分配的同步方式:
- 每一次内存分配的动作都进行同步——CAS+失败重试的方式保证内存分配操作的原子性;
- 将内存分配的操作以线程为单位划分在不同的空间中进行——每个线程在Java堆中预先分配一小块内存(本地线程分配缓冲TLAB),只有TLAB用完并需要分配新的TLAB时才需要进行同步。
内存空间初始化,初始化为各数据类型对应的零值(初始化不包括对象头);
对象头信息的设置;
对象初始化,赋予对象真正的数据,至此一个真正的对象才算完全生成。
对象的内存布局
以下内容针对HotSpot虚拟机进行介绍。
对象头(Header)
存储对象自身的运行时数据,比如HashCode、GC分代年龄(便于垃圾回收器有效地执行垃圾回收机制)、锁状态标志、线程持有的锁、偏向线程ID(偏向线程用于设置对象头信息)、偏向时间戳等;
存储类型指针,即对象指向它的类元数据的指针——从而确定该对象属于哪个类(并不是所有的JVM查找类元数据信息都要经过对象本身)。
如果对象是一个Java数组,那么在对象头还需要保存用于记录数组长度的数据。
实例数据(Instance Data)
定义的各种类型的字段内容,包括继承的与自己创建的。
存储的顺序不一定是定义的顺序,还需要考虑到JVM分配策略参数的影响(相同宽度的字段总是分配到一起)。
对齐填充(Padding)
占位符——因为HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,即对象的大小必须是8字节的整数倍 。
对象的访问定位
Java通过存储在JVM栈上的Reference(引用)操作对象。
通过Reference访问对象的方式有两种:
句柄
Java堆中将会开辟出一块内存区域作为句柄池。
Reference存储的是对象的句柄地址。
句柄中包括对象实例数据(位于堆)的地址和类型数据(位于方法区)的地址。
优点:由于Reference存储句柄池中稳定的句柄地址,所以在对象位置移动(比如被回收了)的时候,只需要改变句柄中对象实例数据的地址即可。
缺点:两次指针定位。
直接指针
Reference存储的是对象的地址。
对象的类型数据同样放置在方法区中,至于如何放置关于类型数据的相关信息,应该是放置在对象中,随着对象位置的变化而变化。
优点:一次指针定位,速度快,在对象访问频繁的情况下,可以节省大量的时间开销。
缺点:当对象移动时,Reference本身需要修改。
Java堆的异常
Java堆的OutOfMemoryError是常见的内存异常情况。主要有两类:
内存泄露(Memory Leak)——本该回收的对象由于与GC Roots相关联而不能被垃圾回收器回收,需要知道泄露对象的类型信息以及GC Roots引用链的信息,从而定位出导致泄露的代码位置。
GC Roots算法:即根搜索算法,用于判断对象是否存活——通过一系列名为“GC Roots”的对象作为搜索的起始点,从这些起始点向下搜索,搜索走过的路径成为引用链(Reference Chain),当一个对象与GC Roots之间没有任何引用链相连时,该对象是不可用的,也即应该被回收。
内存溢出(Memory Overflow)——内存中的对象必须存在,主要的原因在于程序设计过程的考虑不周,比如某些对象的生命周期过长、对象被引用持有的时间过长、所需内存超出机器自身内存等。
JVM栈和本地方法栈的异常
- 单线程场景下,一般只会出现StackOverflowError,即请求的栈深度超过了JVM的最大深度;
- 多线程场景下,在StackOverflowError的基础上,也有可能出现OutOfMemoryError——因为每一个线程都需要有一个私有的栈,定义的栈容量越大,可申请的线程数就会越少,在申请新线程的时候就越容易出现“无法请求到足够空间”即OutOfMemoryError的情况——在物理内存和线程数不能减少的情况下,只能通过减少堆内存或者减少栈容量的方式,增加栈的数量。
方法区和运行时常量池的异常
该区域主要存储Class的相关信息,所以一般通过在运行时产生大量的类进行测试——比如通过CGLib字节码技术动态生成类。
Spring、Hibernate等框架中,经常会用到反射或CGLib技术动态生成大量的类,从而实现切面增强等功能——需要给方法区分配较大的内存空间,以便生成的Class可以载入内存。
此外,可能让方法区内存溢出的情况还有:大量JSP或动态产生JSP文件、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。
直接内存的异常
一个明显的特征是在堆Dump的文件中不会看见明显的异常——如果出现OutOfMemoryError且Dump的文件很小,而程序中涉及NIO的使用,那么可以考虑是不是直接内存的异常。
Java垃圾回收
由于Java内存的程序计数器、JVM栈和本地方法栈都是线程私有的内存——与线程具有相同的生命周期,所以这几个内存区域可以不用考虑垃圾回收的问题。
所以,垃圾回收的主要关注点在于Java堆(存放生成的对象实例)和方法区(存放类信息),这两个内存区域的内容都会在程序运行时动态变化。
对象是否可回收
可达性分析算法,也称根搜索算法。
GC Roots算法:即根搜索算法,用于判断对象是否存活——通过一系列名为“GC Roots”的对象作为搜索的起始点,从这些起始点向下搜索,搜索走过的路径成为引用链(Reference Chain),当一个对象与GC Roots之间没有任何引用链相连时,该对象是不可用的,也即应该被回收。
Java中可以作为GC Roots的对象包括以下几类:
- JVM栈中引用的对象;
- 本地方法栈中JNI引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象。
作为GC Roots对象,在程序运行期间不能被回收。可以看到,前两种是线程私有内存中的对象,自然不会被回收;后两种属于随着类加载而被载入的对象,是在程序运行之前就完成创建的对象。
对象的生存与死亡
经过可达性分析,判定的只是对象是否可以被回收,“可以”并不代表“一定” 。
对象真正被回收,即判定为死亡,还需要经过两个标记过程:
- 判断该对象是否有必要执行
finalize()方法——若需要执行finalize()方法,那么该对象将会放置在一个F-Queue队列中,并在稍后由JVM自动建立的、低优先级的Finalizer线程执行,若在finalize()方法中,该对象重新与引用链上的任何一个对象建立关联,那么它将不会被回收; - 对第一次标记之后,还留在“即将回收”集合中的对象进行标记,并在之后回收这些对象。
任何一个对象的finalize() 方法只能够被调用一次,如果之后它又一次被判定为“可回收”,那么基本上就会被回收了。
PS. 由于finalize() 方法的各种历史原因,导致它的调用代价相对较大,尽量不使用,这里只是为了讲解方便,介绍了一下。
引用(Reference)
引用分为四种,按照由强到弱的顺序如下:
- 强引用(Strong Reference)——只要有,就不会回收相应对象;
- 软引用(Soft Reference)——在系统发生内存溢出异常之前,会将该引用对应的对象进行二次回收,如果还不能解决问题,才会抛出OOM异常;
- 弱引用(Weak Reference)——当垃圾回收器工作时,无论内存是否够用 ,都会将该引用对应的对象回收;
- 虚引用(Phantom Reference)——唯一的作用在于,垃圾回收对象的时候,能得到一个系统通知(估计用于测试的情况比较多)。
JVM通过引用应该实现的功能至少有两点:
- 从引用直接或间接地查找到对象在Java堆中的数据存放的起始地址索引;
- 从引用直接或间接地查找到对象所属数据类型在方法区中存储的类型信息。
垃圾回收算法
目前基本都采用分代回收算法(Generational Collection) :
- 新生代(Minor GC)——对象的更新换代频率高,一般选用复制算法;
- 老生代(Full GC/Major GC)——对象的存活率高、没有额外的空间对额外的分配做担保,一般选用“标记-整理”或者“标记-清理”算法。
复制算法:将垃圾回收之后剩余的对象统一、规整地复制到之前预留的内存区域,然后将之前的区域清理干净——可以在垃圾回收之后较好地获得连续的内存。
标记-清理算法:最基本最简单的垃圾回收算法——将需要回收的对象标记好,然后回收就可以了。
标记-整理算法:将存活对象标记,并全部移动到内存的一侧,然后直接清理剩余区域的内存空间——同样可以获得连续的内存。
垃圾回收器
CMS(Concurrent Mark Sweep)
基于“标记-清理”算法,并在其中加入了并发的标记与清理的过程——即,总体上CMS的内存回收过程是与用户线程一起并发执行的,这既是CMS的优点,也是其缺点所在——回收工作与正常工作并发执行,那么将不能回收当前正在产生的浮动垃圾(Floating Garbage),且由于“标记-清理”算法的局限性,会产生较多的空间碎片。
G1
G1将整个Java堆划分成多个大小相等的独立区域(Region),新生代和老生代不再是物理隔离的,它们都是某一部分Region。G1所需要做的,是跟踪各个Region中所存在垃圾的回收价值,维护一个优先级列表,在回收时优先回收价值最大的Region——G1,Garbage-First的由来。
那么,问题来了?如果Reference和所引用的对象不在一个Region中,如何快速找到回收价值最大的Region呢?最简朴的方式是全堆扫描——必然带来低效率。G1使用Remembered Set避免全堆扫描——每个Region都有一个与之对应的Rememcered Set,JVM发现程序在对Reference类型的数据进行写操作时(涉及到对象的引用修改),会检查Reference与所引用的对象是否处于不同的Region中,若是则将相关的引用信息记录到被引用对象所属的Region的Rememcered Set中,那么只需要关注Region的Remembered Set,就可以计算得到当前Region的回收价值,而不需要全堆扫描了。
内存分配策略与回收策略
我是一个普通的Java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就被迫去了Survivor区的“From”区,自从去了Survivor区,我就开始漂了,有时候在Survivor的“From”区,有时候在Survivor的“To”区,居无定所。直到我18岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在年老代里,我生活了20年(每次GC加一岁),然后被回收。
上述故事形象地形容了JVM针对对象的内存分配策略以及一个长期存在对象的一生。
对象优先在Eden中分配
大多数情况下,一个对象会首先在新生代Eden区中分配内存。
HotSpot JVM把新生代分为了三部分:1个Eden区和2个Survivor区(分别叫From和To)。默认的比例为,Eden : Survivor = 8 : 1,也就是每次新生代中可用内存空间为新生代总容量的90%(意味着有10%的内存空间会浪费)。
当Survivor区空间不够(可能是长期存在的对象过多,进入老年代的年龄阈值又过高),需要依赖其他内存(老年代的内存)进行分配担保(Handle Promotion)——超出内存的对象将会直接存入老年代。
大对象应该直接进入老年代
大对象,即需要大量连续内存的Java对象——长字符串和数组。
经常性的出现大对象容易导致内存还有不少空间(但不连续)的时候,提前触发垃圾回收机制,从而保证有足够大的连续内存空间存储大对象——所以,尽量少的创建大对象。
JVM提供相应的参数,让超过一定大小的大对象直接在老年代中分配内存——从而避免GC的时候频繁地在Eden和Survivor之间复制大对象。
长期存活的对象进入老年代
JVM给每个对象都定义了一个对象年龄计数器。对象在Eden区中经过第一次GC后仍存在,并且能被Survivor容纳时,将被移动到Survivor区,同时对象的年龄置为1。每当对象在Survivor区的GC后仍存活一次,其年龄就将增加1。当年龄增加到一定程度,将被移入老年代(相应的阈值可以通过参数设置)。
动态对象年龄判定
当然,不一定完全遵照静态阈值的设定,判定对象是否可以移入老年代——如果Survivor空间中相同年龄的对象所占空间总和超过Survivor区空间的一半,那么年龄大于或者等于该年龄的对象就可以直接进入老年代。
空间分配担保
如果老年代的连续空间大于新生代对象总大小或者历次移入老生代的平均大小(参考过去的概率),就会对新生代执行GC,否则将会对老生代执行GC——保证新生代在GC时具有可靠的分配担保,所以空间分配担保应该是先于新生代GC发生的。
JVM性能监控与故障处理工具
JDK命令行工具
jps虚拟机进程状况工具列出正在运行的虚拟机进程,显示虚拟机执行主类(
main()所在的类)名,以及进程的本地虚拟机唯一ID——LVMID (对于本地虚拟机进程来说,LVMID与操作系统的进程ID——PID是一一对应的)jstat虚拟机统计信息监视工具显示本地或者远程JVM进程中的类装载、内存、垃圾回收、JIT编译等运行数据。
jstat -gc [LVMID]可以监视Java堆的状况,包括新生代和老生代。jinfoJava配置信息工具实时地(包括在程序运行时)查看和调整JVM各项参数。
jmapJava内存映像工具生成堆转储快照(heapdump或dump文件),可以在不产生异常的前提下拿到转储快照文件。
jhat虚拟机堆转储快照分析工具与
jmap一起使用,但是分析过程耗时耗资源。jstackJava堆栈跟踪工具生成JVM当前时刻的线程快照(threaddump或javacore文件)——即JVM每一条线程正在执行的方法堆栈的集合。
可以用于定位线程出现长时间停顿的原因,比如死锁、死循环、请求长时间等待外部资源导致线程停顿等。
HSDISJIT生成代码反汇编
JDK可视化工具
可视化工具也就是上述命令行工具的可视化表达,可以完全取代命令行工具,前提是有可视化界面的支持。
JConsole
VisualVM
BTrace,是VisualVM的一个插件,可以在程序运行的过程中,通过HotSpot VM的HotSwap技术动态地加入原本并不存在的调试代码 。
###JVM调优
编译时间与类加载时间的优化
编译时间,即JVM的JIT编译器编辑热点代码(Hot Spot Code)的耗时——这也就是HotSpot JVM名字的由来。
Java为了实现跨平台的特性,Java代码编译出来后形成的Class文件中存储的是字节码(ByteCode),JVM通过解释方式执行字节码命令——C/C++则是采用静态编译方式,直接将代码编译成本地二进制代码,运行速度更快。
为了解决Java程序执行的速度问题,JDK1.2之后JVM内置了两个运行时编译器,一个编译器用于将Java代码编译成字节码,另一个编译器则用于将热点代码(一段Java方法被调用的次数到达一定程度,将会判定为热点代码)编译成本地代码,从而提高执行速度。
二段式编译的方法,优点在于:运行期编译可以在基于运行期所收集数据的基础上,对代码的编译进行优化,从而在理论上达到更快的执行速度;缺点在于:运行期编译会消耗程序正常的运行时间——因为在判定为热点代码的时候,会通过第二次编译,成为本地二进制代码,这个过程是在程序运行期间完成的。
内存调整与垃圾回收频率调整
合理增加新生代内存容量,减少新生代回收频率;调整Java堆、老年代和永久代的容量与最大容量,使二者相等,避免内存扩展——每次的内存扩展将会带来一次老年代/永久代的GC(耗时)。
合理选择垃圾回收器
根据GC的情况,找出可优化的区域——是新生代,还是老年代,然后针对性地在配置文件中选择指定合适的垃圾回收器。
类文件结构
Class文件格式所具备的平台中立(不依赖特定硬件及操作系统)、紧凑、稳定和可扩展的特点,是Java技术体系实现两项特性——平台无关、语言无关的重要支柱。
平台无关特性
JVM都可以载入和执行同一种平台无关的字节码,从而实现程序“一次编写,到处运行”。
而“字节码”,就是类文件——Class文件。
语言无关特性
JVM不与包括Java在内的任何语言绑定,JVM只与特定的二进制文件格式——Class文件所关联,Class文件中包含JVM指令集和符号表以及其他所需信息。
Class文件中使用强制性语法和结构化约束。
任意一门功能性语言都可以经过特定编译器的编译,表示为一个能被JVM所接受的有效Class文件(字节码文件)。 ——也即,JVM上能够运行的语言不仅仅是Java,还可以是C++/Python/Scala等等。
Class文件是一组以8位字节为基础单元的二进制流,其中没有任何分隔符,所有数据项紧密排列。当遇到大小超过8字节的数据项时,将会按照高位在前 (最高位字节存放在地址最低位,最低位字节存放在地址最高位)的方式分割为若干个8位字节进行存储。
Class文件中只有两种数据类型:
无符号数
基本数据类型,用于描述数字、索引引用、数量值或UTF-8编码构成的字符串值。
大小有1字节、2字节、4字节和8字节。
表
由多个无符号数或者其他表作为数据项构成的复合数据类型,以“_info”结尾。
魔数
位于Class文件的头4字节,唯一作用是确定该Class文件是否为一个能被JVM接受的Class文件——值为:0xCAFEBABE(咖啡宝贝) 。其他的值的Class文件将不能被JVM所接受。
版本号
位于魔数之后的4字节,其中前2字节是次版本号(Minor Version),后两字节是主版本号(Major Version)——这样的次序正好符合Class文件“高位在前”的数据分割方式。
同样,高版本JDK能够向下兼容,但是JVM不能接受高于其版本的Class文件。
常量池
常量池可以理解为Class文件中的资源仓库,是占用Class文件空间最大的数据项目之一。池中的每一个常量都是一张表”_info”。
常量池的容量是从1开始计数的——满足某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义。
Class文件结构中只有常量池的容量计数是从1开始的。
常量池中主要存放两大类常量:
字面量(Literal)
字符串或声明为
final的各个基本类型的常量值等。符号引用(Symbolic Reference)
- 类和接口的全限定名(即绝对路径)
- 字段的名称和描述符
- 方法的名称和描述符
常量池的作用:
Java代码在进行编译的时候,并不像C/C++有“连接”的步骤,仅仅是编译成相应的Class文件,JVM加载Class文件的时候才会进行动态链接。
即Class文件不会保存各个方法、字段的最终内存布局信息,不经过运行期转换和动态链接操作,无法获得真正的内存入口地址,也就无法被JVM所使用。
常量池的存在,可以让JVM在程序运行期从池中获取对应的符号引用,然后在类创建时或运行时解析、翻译到具体的内存地址中。
Class文件中的方法、字段等的名称,都需要引用CONSTANT_Utf8_info型常量描述——CONSTANT_Utf8_info常量的最大长度为65535Byte=64KB,超过该长度的名称将无法通过编译。
访问标志
位于常量池之后的2字节,用于识别一些类或者接口层次的访问信息——类OR接口、public ORprotected ORprivate 、abstract ?、final ?等等 。
类索引、父类索引、接口索引集合
- 类索引(this_class),用于确定该类的全限定名;
- 父类索引(super_class),用于确定该类的父类的全限定名——只有一个,因为Java不允许多重继承,其中
java.lang.Object的父类索引值为0; - 接口索引集合(interfaces),实现的接口按顺序排列在集合中。
字段表集合
字段表用于描述接口或类中声明的变量——称为字段:
- 类级变量
- 实例级变量
- 不包括方法内部声明的局部变量
- 不包括父类或者父接口中继承而来的字段
字段所包含的信息:
- 作用域(区别于访问标志中的
publicORprotectedORprivate); static——区分实例变量OR类变量;final——可变性;volatile——并发可见性;transient——可否序列化;- 字段数据类型——基本类型、数组、对象;
- 字段名称。
Java语言中字段是无法重载的——两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称。
但是,在Class文件中,只要两个字段的描述符(有相应的简单符号和描述规则)不一致,即使字段重名,那也是OK的。
方法表集合
方法表的结构与字段表相同——方法的定义可以通过访问标志、名称索引、描述符索引清楚地描述,但是方法内的Java代码则不在这里——Java代码经过编译形成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性中。
Java语言中,方法的重载,除了要与原方法具有相同的方法名之外,还必须拥有与原方法不同的特征签名——一个方法中各个参数在常量池中的字段符号引用的集合,但是方法的返回值不包含在特征签名中,所以Java语言无法仅仅依靠方法返回值的不同来对一个方法进行重载。
Class文件中,同样,只要两个方法的描述符不同,就可以共存——即,方法的返回值不同,那么两个方法也算互相重载,从而共存在同一个Class文件中。
属性表集合
- Code属性——存放方法体内的代码的属性集合
- Exceptions属性——列举方法中可能抛出的Checked Exceptions,即方法参数后
throws关键字列举的异常。 - 等等属性。
字节码指令
JVM的指令是由一个字节长度的、代表某种特定操作含义的数字(操作码,Opcode)+ 紧随其后的0~多个代表此操作所需参数(操作数,Operands)构成。
JVM指令集的特点:追求尽可能小的数据量、追求高传输效率,也因为过于精简——指令集并不是完全独立的,有的操作并没有一对一的指令支持,所以有时候需要将原本的操作转换成指令支持的操作。
####JVM解释器执行模型
|
|
加载和存储指令
将数据在栈帧中的局部变量表和操作数栈之间来回传输。
运算指令
将两个操作数栈上的值进行特定的运算,并将结果重新存入操作数栈顶。
JVM中没有直接支持byte/char/short/boolean类型的运算指令,这类数据运算需要先转换为int类型,然后使用int类型的运算指令完成运算。
类型转换指令
用于完成显式的类型转换操作。
宽化类型转换,JVM直接支持(隐式)。
窄化类型转换,必须显式使用转换指令完成:
- int/long类型转换成整数类型T时,只需简单丢弃除了最低位N个字节 (假设类型T的数据长度为N)的以外的高位内容即可——所以会出现数据符号在转换前后不同的情况;
- 浮点值转换成整型类型T时(int/long类型),有以下规则:
- 浮点数为NaN,转换后为0;
- 浮点数为无穷大,转换为相同符号的T所能代表的最大值/最小值;
- 否则,采用向零舍入模式(取不大于该浮点数的最近的整数)取整,获得相应的整数值。
控制转移指令
有条件或无条件地修改PC寄存器的值。
方法调用和返回指令
调用指令与数据类型无关,返回指令则是根据返回值的类型区分的。
异常处理指令
显示抛出异常的操作,对应于athrow指令实现。
同步指令
方法级的同步,以及方法内部一段指令序列的同步——由管程(Monitor)支持。
- 方法级的同步是隐式的,即无须通过字节码指令来控制。
- 一段指令序列的同步则需要通过Java语言的synchronized语句块表示。
JVM类加载机制
JVM将描述类的数据从Class文件(二进制流)中加载到内存中,在这个过程中对数据进行校验、解析和初始化,最终形成可以被JVM直接使用的Java类型。
Java的类加载过程在程序运行期间完成——动态加载&动态连接——Java动态扩展的语言特性。
Java的类加载过程涉及到一个类的生命周期——从加载到JVM内存中开始,直到从JVM内存中卸载为止,整个过程包括以下阶段:
加载(Loading)——在JVM外部实现
- 通过Class文件中,类的全限定名,获取定义此类的二进制流——关于二进制流的获取,有很多种方式:比如ZIP/JAR包、网络、运行时计算生成(动态代理技术)、其他文件生成等;
- 将二进制流所代表的静态存储结构,转化为方法区的运行时数据结构;
- 在内存中生成一个代表该类的
java.lang.Object对象——作为方法区该类的各种数据的访问入口(从内存的入口进入到方法区中访问)。
对于非数组类的加载,可以通过自定义类加载器(ClassLoader),控制Class文件二进制流的获取方式——即,重写一个类加载器的
loadClass()方法。对于数组类的加载,数组本身的加载不通过类加载器,而是由JVM直接创建。不过,数组内的元素类型(不论是对象的引用,还是基本数据类型),还是需要通过类加载器创建。
验证
虽然Java语言本身是较为安全的,如果完全遵照Java语言规范编程,基本不会有危险的行为,但是Class文件并不只能通过Java语言编译而成,所以二进制流中有可能存在Java语言规范不可控的危险因素——验证的原因。
- 文件格式验证——判断二进制流是否符合Class文件规范;
- 元数据验证——信息语义分析,判断二进制流描述的信息是否符合Java语言规范;
- 字节码验证——针对数据流和控制流分析,判断程序语义是符合逻辑的,确保方法在运行时不会做出危害JVM安全的事情。
- 符号引用验证——对类自身以外的信息(常量池内的各种符号引用)进行匹配性校验。
验证阶段重要但不一定需要,当对程序运行多次之后,确定程序是完全安全的,那么就可以使用参数关闭大部分验证过程,缩短JVM加载时间。
准备
正式为类变量分配内存并设置类变量初始值——当然是在方法区中分配,当然是类变量(static修饰),而不包括实例变量。
准备阶段设置的类变量初始值是相应数据类型对应的“零值”——如果类字段的字段属性表中存在ConstantValue属性,那么准备阶段将会将变量初始化为程序代码中指定的值。
1public static final int val = 10; // 准备阶段直接初始化为10解析
将Class文件中的符号引用,根据具体使用的JVM,解析为特定的直接引用——Class文件的普适性 VS JVM的特殊性。
符号引用,可以是任意形式的字面量,与JVM实现的内存布局无关,所引用的目标也不一定加载到内存中。
直接引用,是可以直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄 。与JVM实现的内存布局相关,所引用的目标也一定要存在于内存中。
涉及到类或接口的方法解析,通常会沿着继承关系递归查找并解析。
初始化
执行类中定义的Java程序代码,为类变量初始化,同时将会执行类中的静态初始化块。
初始化阶段是执行类构造器
<clint>()方法的过程——<clint>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并生成的。类构造器
<clint>()与类的构造函数(实例构造器<init>())不同,因为前者不需要显式地调用父类的类构造器(实例构造器需要先调用父类构造函数super())——JVM会保证在此之前完成父类构造器的调用和执行。接口中不能使用静态初始化块。
接口与类不同的是,执行接口的
<clint>()方法不需要先执行父接口的<clint>()方法——只有当父类接口中定义的变量使用了,才会对父接口进行初始化,不过对于该接口来说,那已经是程序执行阶段了。<clint>()在多线程中的加锁和同步,受到JVM的保护和支持。使用
卸载
类加载器
将类加载机制中的“加载”过程放到JVM外部去实现——实现这个过程的模块就称为类加载器。这么做的原因,是为了让应用程序自己选择合适自己的方式获取所需要的类。
任意一个类,都需要确立其在JVM中的唯一性——由其自身与加载它的类加载器一起确立。因为每一个类加载器都有一个独立的类名称空间。
即,如果问两个类是否“相等”?那么,这个问题只有在两个类的类加载器是同一个的前提下才有意义。
双亲委派模型
自上而下依次为:
- 启动类加载器
- 扩展类加载器
- 应用程序类加载器
- 自定义类加载器
模型上下类加载器的关系不是继承,而是子类通过组合的方式复用父类加载器的代码。
双亲委派模型的工作过程:
一个类加载器收到了类加载的请求,首先不会自己去尝试加载该类,而是将请求委派给父类加载器完成。每一个层次都是这个委派的过程——这么一来所有的类加载请求都将上传到顶层的启动类加载器。只有当上层类加载器反馈无法完成相应的类加载请求(搜索范围中没有找到相应的类信息)时,子类加载器才会尝试自己加载。
实现双亲委派模型的代码都集中在java.lang.ClassLoader 的loadClass() 中,自定义的子类加载器的类加载方法可以放置在findClass() 方法中——当父类加载失败,抛出ClassNotFoundException异常后,将会执行findClass() 方法。
OSGi
规则就是用来打破的。
OSGi破坏了双亲委派模型——通过自定义的类加载器机制的实现,实现了模块化热部署机制,为程序提供了高动态、高灵活的模块化热部署方式。
每个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,只需要将Bundle与类加载器一同换掉即可。
这样一来,就会出现模块之间平级的类加载请求。
JVM执行引擎
栈帧
JVM方法调用和方法执行的数据结构,JVM栈的组成单元。
对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧——当前栈帧(Current Stack Frame)才是有效的,与当前栈帧相关联的方法即为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
一个栈帧中,包含以下五个部分:
局部变量表
用于存放方法参数和方法内部定义的局部变量 ——局部变量表的内存分配基于Class文件中属性表集合的Code属性。
局部变量表的容量以变量槽(Slot)为基本单位,Slot的长度可以随着CPU、操作系统或JVM的不同而发生变化,只要让Slot在外观上看起来与32位JVM中的Slot一致即可(对齐或补白)。
一个Slot可以存放一个32位以内的数据类型(boolean/byte/char/short/int/float/Reference/returnAddress)。对于64位的数据类型,JVM会以高位对齐的方式为其分配两个连续的Slot空间,包括double/long——由于局部变量表位于线程私有的JVM栈上,所以对于两个连续Slot空间的读写不论是否是原子操作,都不会出现数据安全问题。
为了节省栈帧空间,Slot是可以重用的——当局部变量超出了其定义的作用域,那么其所占用的空间就可以被重用。
操作数栈
JVM解释执行引擎称为“基于栈的执行引擎”。
操作数栈(操作栈)的每一个元素可以是任意的Java数据类型(32位数据类型所占容量为1,64位所占容量为2)。
方法刚开始的时候,方法的操作栈为空,随着方法的执行,会有各种字节码指令往操作栈中写入和提取内容——即指令的入栈和出栈。
在概念定义中,栈帧是线程私有的内存空间,即相互独立。但是具体的JVM实现中,会在栈帧中开辟出线程之间能够共享的内存区域——操作栈共享区域,用于在线程之间共享一部分数据,从而减少方法调用过程中额外的参数复制传递。
动态链接
每个栈帧都包含一个引用——指向运行时常量池中该栈帧的所属方法,该引用是为了支持方法调用过程中的动态链接。
方法返回地址
方法退出的本质,就是当前栈帧的出栈。
方法退出的方式有两种:
- 正常完成出口:执行引擎遇到任意一个方法返回的字节码指令;
- 异常完成出口:执行过程中遇到异常,且异常没有在方法体内得到处理。
正常完成的方法可能会给其调用者产生返回值,但是异常完成的方法不会给其调用者产生任何返回值。
不论任何一种退出方式,都需要回到方法被调用的位置。
附加信息
基于栈的指令集架构
Java编译器输出的指令流,基本上是基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令 ,依赖操作数栈进行工作。
相对的是,基于寄存器的指令集,比如X86。
ISA的优点在于可移植性,缺点在于执行速度相对会慢一点(入栈出栈指令数量较多,对内存的访问频繁)。
方法调用
方法调用不等同于方法执行——方法调用的唯一任务是,确定被调用方法的版本,即调用哪一个方法。
所有方法调用的目标方法在Class文件中都是一个常量池中的符号引用。
Java方法调用又分为解析调用和分派调用两种方式。
解析调用
解析调用一定是静态的过程。
发生在类加载的解析阶段,解析调用会将其中一部分符号引用转化为直接引用——解析调用成立的前提是:符号引用对应的目标方法,在程序代码写好、编译器进行编译时,就必须确定——即“编译期可知,运行期不可变” ,这样的方法包括:
- 静态方法
- 实例构造器
<init>方法 - 私有方法
- 父类方法
其中,静态方法对应指令invokestatic,后三种方法对应指令invokespecial。
上述四种方法,再加上final 方法,组成了Java中的非虚方法 ——无法被覆盖,没有其他版本,多态选择的结果肯定是唯一的。
#####分派调用
分派调用可能是静态的,也可能是动态的,同时根据宗量数(方法的接受者与方法的参数的统称),又可以分成单分派和多分派。因此,两两组合,就有四种分派类型:静态单分派、静态多分派、动态单分派、动态多分派。
Java语言是一门静态多分派、动态单分派的语言:
- 编译期,会确定静态类型,以及方法参数的类型;
- 运行期,会确定实际类型。
静态分派
即依赖静态类型来定位方法执行版本的分派动作。
什么是静态类型?
12345static abstract class Human {}static class Man extends Human {}...Human man1 = new Man(); // Human是静态类型,Man是实际类型(这里涉及向上转型)Man man2 = new Man(); // 同理,Man既是静态类型,又是实际类型静态类型可以在编译期确定,而实际类型在编译期是不可知的,只有在运行期才能确定。
所以,静态分派在编译期完成 (所以实际上不由JVM执行,而是由编译器完成)。
静态分派的典型应用就是方法的重载 ——同一个类中,对多个具有相同名称的方法采用不同的方法参数和方法块定义。
动态分派
即依赖实际类型,定位方法执行版本的分派动作。
动态分派的典型应用就是方法的重写——在是静态类型,Man是动态类型中,子类采用了父类的方法(相同的方法名、方法参数、返回参数),但是对方法块进行了重新定义。
静态/动态类型语言
判断静态/动态类型语言的依据是:类型检查的主体过程,是在编译期还是运行期。
动态类型语言的又一个特征是:变量无类型而变量值才有类型。
静态VS动态:
静态类型语言可以在编译期确定类型,即编译器可以提供严谨的类型检查机制,有利于稳定性和大规模。
动态类型语言在运行期才能确定类型,提高了灵活性和代码的简洁性,可以带来更高的开发效率。
Java与动态类型
JDK1.7提供了invokedynamic指令,以及java.lang.invoke 包——提供了一种新的动态确定目标方法的机制,即MethodHandle。
MethodHandle与Reflection
二者的作用都是在程序运行期,动态获取一个特定类型的对象。
- 本质上,Reflection与MethodHandle机制都是在模拟方法的调用,但是二者的模拟方法调用的层次不同:Reflection是在Java代码层次,而MethodHandle则是在字节码层次;
- Reflection属于重量级,MethodHandle属于轻量级——Reflection中的
java.lang.reflect.Method对象所包含的信息远比MethodHandle机制中的java.lang.invoke.MethodHandle对象包含的信息多; - 由于MethodHandle是在字节码层次模拟对方法的调用,所以MethodHandle中的方法对应于相应的JVM方法调用指令,也因此可以享受JVM的各种优化措施,相比之下,Reflection则无法享受;
- 此外,MethodHandle的设计,目的是服务于所有运行在JVM之上的语言,不仅仅局限于Java。
Java与C/C++编译器的对比
即,即时编译器与静态编译器的对比:
- 即时编译器运行占用的是用户程序的运行时间;
- 由于Java语言是动态的类型安全语言,需要由JVM确保程序不会违反语言语义或访问非结构化内存,即需要频繁地进行动态检查;
- Java语言中没有virtual关键字,但是Java中使用虚方法和多态选择的频率远大于C/C++;
- Java语言可以动态扩展,意味着运行时可能加载的新类,可能改变程序类型的继承关系;
- Java语言中对象的内存分配都是在堆上进行的,而C/C++的对象可以分配在堆上和栈上,从而减轻内存回收的压力;
- Java语言的动态安全、动态扩展和垃圾回收机制,都为Java提供了较大的灵活性,从而带来了开发上效率的提升;
- Java可以进行运行期的程序优化。
Java内存模型(JMM)
Java内存模型(Java Memory Model),其设计目的是为了屏蔽各种硬件和操作系统的内存访问差异,实现Java程序在各种平台下都能达到一致的内存访问效果。
JMM定义了程序中各个变量的访问规则——JVM中将变量存储到内存和从内存中取出变量的底层细节——这里的“变量”指的是线程共享的变量(实例字段、静态字段、构成数组对象的元素),而不是线程的私有变量(局部变量和方法参数),因为线程私有变量不存在争用问题,自然不需要定义访问的规则。
针对JMM所谈及的“变量”,规定这些变量存储在主内存(可以类比物理机中的主内存,但只是JVM内存的一部分,线程共享的那部分)中。
此外,每条线程拥有自己私有的工作内存 ,工作内存中存储了被该线程使用的变量的主内存副本拷贝 ——线程对变量的所有操作都必须在自己的工作内存中进行,而不能直接读写主内存中的变量。
当然,由于工作内存是线程私有的,所以不同线程之间无法直接访问其他线程的工作内存,线程之间变量值的传递需要通过主内存完成。
由此,可以可以大致得到Java内存与JMM之间的对应关系:
- Java堆中对象实例数据部分——主内存;
- JVM栈——线程的工作内存。
内存间交互操作
可以先看一张图:
|
|
针对内存间的交互操作,Java内存模型定义了8种原子操作:
- lock/unlock:锁定与解锁,作用于主内存的变量,将主内存的变量标记为线程独占的状态,以及将变量从线程独占的状态中解锁出来;
- read:读取,将主内存变量的值传输到线程工作内存中;
- load:载入,将read读取的主内存变量值载入线程工作内存的变量副本中;
- use:使用,将线程工作内存中变量副本的值传递给执行引擎(每当JVM遇到一个需要使用变量副本值的字节码指令时,都会执行use操作);
- assign:赋值,接收执行引擎处理后的值,并将该值赋值给线程工作内存中的变量;
- store:存储,将线程工作内存中的变量值传输到主内存中;
- write:写入,将主内存中的变量值(从线程工作内存中得到的变量值)写入到主内存中的变量。
此外,JMM还为8种原子操作制定了规则:
- read与load必须顺序出现,store和write必须顺序出现——即不允许出现变量从A内存读取到B内存,但是B内存不接受的情况;
- 经过修改的变量,必须将变化同步回主内存;
- 没有经过修改的变量,不能同步回主内存;
- 一个新的变量必须在主内存中产生,即线程工作内存中使用的变量都是从主内存传过去的;
- 一个变量在同一时刻只允许一条线程对其lock,但lock操作可以由同一线程反复执行多次;
- 一个变量unlock之前,必须先将此变量同步回主内存中。
volatile变量
volatile变量只能保证变量的可见性,不能保证变量的并发安全性。
所以,使用volatile变量的时候,如果要保证其原子性,只有以下场景适合:
- 运算结果不依赖变量的当前值;
- 能够确保只有单一的线程对变量进行修改操作——单修改多读取场景;
- 变量不需要与其他的状态变量共同参与不变约束——状态无关。
否则,仍需要加锁保证并发的安全性。
volatile变量可以禁止指令重排序优化 ——机器级别上的优化,Java代码中顺序的命令,在指令流中可能会重新排序,即所谓的“线程内表现为串行的语义”,保证可以拿到正确结果,但是不保证完全按照Java代码的执行顺序。
非原子性协定
针对64位的数据类型long和double,允许JVM实现选择可以不保证read/load/store/write这四个操作的原子性。不过可以通过volatile修饰,保证操作的原子性。
目前基本上各大JVM都将64位数据的读写操作作为原子操作,所以也不用担心。
原子性/可见性/有序性
原子性:
基本数据读写的原子性和64位数据的非原子性协定,以及同步块Synchronized。
可见性
JMM依赖主内存作为传递媒介实现可见性。
可以使用volatile修饰变量,在变量修改时立即刷新主内存。
此外,还可以使用synchronized和final修饰,以实现可见性。
有序性
如果在本线程内观察,所有操作都是有序的——线程内表现为串行的语义。
如果在一个线程中观察另一个线程,所有的操作都是无序的——指令重排序&工作内存与主内存之间的同步延迟。
volatile(禁止指令重排序)和synchronized(线程串行访问共享的区域)都可以实现线程之间操作的有序性。
先行发生原则
判断数据是够存在竞争、线程是否安全的主要依据。
JMM中存在“天然的”先行发生关系(无须任何同步即可存在),如果两个操作之间的关系不属于先行发生关系,那么无法保证执行的顺序性,也即需要一定的同步措施:
- 程序次序规则:一个线程内,按照程序代码控制流的顺序执行;
- 管程锁定规则:同一个锁但不同线程,unlock操作先行发生于之后的lock操作;
- volatile变量规则:对volatile变量的写操作先行发生于读操作;
- 线程启动规则:Thread对象的
start()方法先于线程的每一个动作; - 线程终止规则:线程的所有操作都先于线程的终止检测操作(
Thread.join()、Thread.isAlive()); - 线程中断规则:对线程
interrupt()方法的调用先于被中断线程的代码检测到中断事件的发生(可以使用Thread.isInterrupted()方法检测是否有中断发生); - 对象终结规则:对象的初始化完成(实例构造器的完成)先于对象的
finalize()方法(也即对象的回收); - 传递性:A先于B,B先于C,那么A先于C。
时间先后顺序与先行发生原则之间基本没有关系,所以在衡量并发安全问题时尽量不要受到时间顺序的干扰,一切必须以先行发生原则为准。
Java线程调度
线程调度,即系统为线程分配处理器使用权的过程。
协作式线程调度
线程的执行时间由线程本身控制,线程切换动作是线程可知的,所以不需要考虑线程同步问题,但是线程执行时间不可控制。
抢占式线程调度
由系统为线程分配执行时间,线程间的切换是线程不可知的,需要考虑线程间的同步问题。
Java线程状态
不同文章或书籍对于线程状态的分类各不相同,大体可以分为4类:
- 创建(New):线程创建至调用
Thread.start()或其他方法将线程启动这段时间; - 就绪(Runnable):调用
Thread.start()方法后,线程进入就绪状态,线程不一定立刻就运行,可能是等待,也可能是阻塞,也可能是从运行状态转化过来(可能等待CPU时间片,也可能等待所需的锁); - 运行(Running):运行条件满足,线程开始运行;
- 终止(Terminated):任务完成,或者遇到异常,线程终止。