JVM学习笔记
根据Java虚拟机规范,在运行时,JVM管理的内存分为以下区域:
其中:方法区和堆被所有线程共享,其它三个区域则是线程私有,这些区域的功能如下:
区域 | 说明 |
程序计数器 |
当前线程所执行的字节码的行号指示器,在虚拟机的槪念模型里,字节码解释器工作时就是通过改变此计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都依赖于此计数器。 如果当前正在执行的是一个java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行本地方法则为空 |
JVM栈 |
此栈的生命周期与线程相同。每个方法执行的时候都会同时创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈过程。这一点和C语言是类似的,栈帧对应着一个未执行完毕的函数。 局部变量表:存放了编译期可知的基本类型、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或 者其他与此对象相关的位置)和retumAddress类型(指向了一条字节码指令的地址)。其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。 |
本地方法栈 |
类似于JVM栈,但是其是用来执行本地方法的。Hotspot虚拟机把JVM栈和本地方法栈合二为一实现。 |
Java堆 |
在虚拟机启动时创建,不需要连续的内存区域。此内存区域用于存放对象实例。根据JVM规范的描述,所有对象、数组都要在堆上分配。 Java堆是垃圾回收的主要管理区域。根据GC算法的不同,可能分为:新生代(Eden、From Surivor、To Surivor)和老年代。 Java堆可能划分出线程私有的分配缓冲区(TLAB) |
TLAB |
线程本地分配缓冲(Thread-local allocation buffer)是在Eden Space中开辟了一小块线程私有的区域,默认占用Eden的1%空间 在Java程序中很多对象都是小对象且用过即丢,它们不存在线程共享也适合被快速GC,对于小对象通常JVM会优先分配在TLAB上,并且TLAB上的分配由于是线程私有所以没有锁开销。因此在实践中分配多个小对象的效率通常比分配一个大对象的效率要高 在TLAB上分配对象时不需要锁住整个堆 对象分配过程:
|
方法区 |
存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。尽管虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap (非堆 ) 对于Hotspot来说,常常把方法区称为永久代 运行时常量池(Runtime Constant Pool):存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。注意Java语言不强制常量在编译时产生,所以此池具有动态性 |
除了上述的运行时区域以外,还有一块内存区域叫直接内存(Direct Memory)。 NIO类库引入了一种基于通道(Channel) 与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作,避免在Java堆和Native堆中来回复制数据以提高性能。
除了程序计数器以外,其它区域均可能发生OOM异常。
考虑最简单的代码:Object obj = new Object();
- Object obj 将映射到Java栈的本地变量表中,作为Reference类型出现
- new Object()将反映到Java堆中,形成一块存储Object类型实例数据值(对象各字段数据)的结构化内存
- 关于Object类的类型信息(Class、父类、接口、方法等)存放于方法区
引用类型的两种实现方式:
- 使用句柄:堆中划分单独区域作为句柄池,Reference中存放的为对象句柄地址,而句柄中包含对象实例数据、类型数据各自的具体地址
- 直接指针:Reference直接存放对象的地址,需要考虑类型数据的布局。Hostspot使用的是这种方式
内存溢出类型 | 说明 |
java.lang.OutOfMemoryError: java heap space |
这是最常见的堆内存溢出,即堆对内存空间不足,无法完成对象分配请求。 出现此问题时,有必要分清内存泄漏(Memory Leak)、内存溢出(Memory overflow),对于前者,要检查泄漏对象到GC Root的路径,对于后者,要考虑增加内存、减少对象生命周期 |
java.lang.OutOfMemoryError:GC over head limit exceeded | 垃圾回收器频繁工作,但是GC效果不理想的情况下发生。说明有无法回收的对象 |
java.lang.OutOfMemoryError: PermGen space | 永久区溢出,通常在系统加载的类非常多、定义了非常多的常量、大量使用动态字节码生成技术导致 |
java.lang.OutOfMemoryError: Direct buffer memory | 直接或间接使用了ByteBuffer中的allocateDirect方法的时候,而不做clear的时候就会出现类似的问题,设置-XX:MaxDirectMemorySize解决 |
java.lang.StackOverflowError | 如果线程请求的栈深度大于虚拟机所允许的最大深度则抛出,通常在错误的递归调用中出现。 |
java.lang.OutOfMemoryError: unable to create new native thread |
创建了超过系统限制的内存数时会导致 |
java.lang.OutOfMemoryError: requested n bytes for . Out of swap space | 一般是由于地址空间不够而造成 |
OS对于单个进程的内存是有限制的,对于32位Windows,进程内存的上限为2GB。
JVM进程总内存 = Xmx + MaxPermSize+ 程序计数器 + JVM进程本身内存消耗 + JVM栈 + 本地栈
JVM能创建的线程数,一方面受到OS对进程最大线程数的限制,一方面受到内存的限制:总可用栈内存/每个栈的大小。
给对象中添加一个引用计数器,每 当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1;任何时刻 计数器都为0的对象就是不可能再被使用的。
缺点:很难解决对象之间的相互循环引用的问题。
Java语言中具体垃圾回收算法均是根搜索算法的变体。通过一系列的名为“GC Roots”的对象作为起始点,从这些点开始向下搜索,搜索所 走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用 的。在Java语言里,GC Roots对象可以是:
- 虚拟机栈(栈帧中的本地变量表)中的引用的对象
- 方法区中的类静态属性引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中的引用的对象
GC算法 | 说明 |
标记-清除算法 |
Mark-Sweep算法是最基础的算法,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象 缺点有两个:
|
复制算法 |
复制算法主要解决效率问题,将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。该算法避免了内存碎片,但是具有严重缺点:两倍的内存消耗 由于巨大的内存浪费,实际都采用复制算法的变体来回收新生代。JVM通常将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的 一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。HotSpot默认Eden和Survivor的大小比例是8 : 1,也就是Eden中可用内存空间为整个新生代容量的90%。当Survivor空间不够用时,需要依赖年老代进行Promotion Eden和Survivor默认8:1的比例是基于绝大部分对象都是朝生暮死型的假设,某些垃圾收集器能够自适应的调整该比例,该比值越小,内存浪费越大 |
标记-整理算法 |
Mark-Compact算法针对年老代特点进行设计, 标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存 该算法的效率低于复制算法 |
- Minor GC:只针对年轻代的GC,也称为Young GC
- Full GC:针对年老代的GC,偶尔会伴随年轻代、永久代的GC,也称为Major GC
Jdk 6u22以后,可用的垃圾收集器有(年轻代年老代之间的连线表示可以组合使用):
垃圾收集器 | 说明 |
Serial |
JDK1.3.1之前的新生代收集唯一选择,STW的基于复制算法的单GC线程收集器 使用-XX:+UseSerialGC打开,该选项会同时打开Serial Old。在JMC中,该收集器显示为Copy |
ParNew |
Serial收集器的多GC线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数(例如:-XXSurvivorRatio、 -XX:PretenureSizeThreshold、-XX:HandlePromotionFailure 等)、收集算法、STW、对象分配规则、回收策略与Serial完全一样 ParNew是许多server模式下JVM的默认新生代收集器,因为它可以与CMS一起工作 在单CPU场景下该收集器不会比Serial表现更好。 -XX:ParallelGCThreads用来指定线程数,默认与CPU数相同,在CPU特别多的情况下,可以减少数量 使用-XX:+UseParNewGC打开,默认与Serial Old配合。在JMC中,该收集器显示为ParNew |
Parallel Scavenge |
STW的基于复制算法的多GC线程收集器。它的特点是:吞吐量优先(Throughput),而其它垃圾回收器都是以响应时间为目标,吞吐量=用户代码时间 / (用户代码时间 + GC时间) -XX:MaxGCPauseMillis用于控制响应时间,-XX:GCTimeRatio用于控制吞吐量。 自适应调节也是此回收器与ParNew的重要区别:-XX:+UseAdaptiveSizePolicy用于自动调整新生代大小、E/S比例、晋升阈值(-XX:PretenureSizeThreshold)来达到最合适的响应时间和吞吐量要求 使用-XX:+UseParallelGC打开,默认与Serial Old配合。在JMC中,该收集器显示为PS Scavenge 并行收集器(Parallel Scavenge / Parallel Old)的:
|
Serial Old |
STW的基于标记-清除-整理(Mark Sweep Compact)算法的单GC线程收集器,专用于年老代(Tenured) 使用-XX:+UseSerialGC打开,该选项会同时打开Serial。在JMC中,该收集器显示为MarkSweepCompact |
Parallel Old |
基于标记-清除算法的多GC线程年老代收集器,具有压缩功能,JDK1.6以后出现。主要用于配合Parallel Scavenge 使用-XX:+UseParallelOldGC打开,该选项会同时打开Parallel Scavenge。在JMC中,该收集器显示为PS MarkSweep |
CMS |
CMS (Concurrent Mark Sweep),低停顿的并发的标记-清除算法收集器,以获取最短回收停顿时间为目标。B/S系统服务器适合使用CMS,工作流程分为以下4步骤:
缺点:
使用-XX:+UseConcMarkSweepGC打开,该选项会自动打开ParNew。在JMC中,该收集器显示为MarkSweepCompact 并发模式失败:YGC要求提升对象到年老代,而后者没有足够的空间(没有足够的时间为其清理出空间) |
G1 |
JDK1.6u14后出现,G1将整个Java堆(包括新生代、老年代)划分为多个大小固定的独立区域 (Region),并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域(这就是Garbage First名称的来由) G1用于更好的支持大内存(4G+) G1随时整理堆,而CMS只有在STW时才整理 从JDK 8u20开始G1支持字符串(包括底层char[])去重,使用-XX:+UseStringDeduplicationJVM打开 |
- 对象优先在Eden分配,如果Eden空间不足,发生Minor GC
- 大对象直接进入年老代:所谓大对象就是指,需要大量连续内存空间的Java对象(长字符串、数组)
- 长期存活对象进入年老代:所谓长期,是指经历多次Minor GC仍然存活
- 空间分配担保
- 强引用:普通方式new的对象,均为强引用,这类对象不会被回收
- 软引用:系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中并进行第二 次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常
- 弱引用:一旦垃圾回收器运作,即被回收,最多活到下一次垃圾回收之前
- 幻引用:一个对象是否 有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收 集器回收时收到一个系统通知。
Class文件是一组以字节为基础单位的、格式紧凑的二进制流,如果某数据项需要超过8位,则按高位在前方式分割为若干字节存储。
- 无符号数:ul、u2、u4、u8分别代表1个字节、2个字节、 4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值、Unicode编码的字符
- 表是以多个无符号数、其他表作为数据项构成的复合类型,一般以 _info结尾。整个Class文件就是一张表:
类型 名称 说明 u4 magic 1个。用于确认是否为合法Class文件,为0xCAFEBABE u2 minor_version 1个。次版本号 u2 major_version 1个。主版本号 u2 constant_pool_count 1个。记录常量池条目的数量 cpinfo constant_pool constant_pool_count - 1个。此容器的计数从1开始。主要包括两类常量:字面量(Literal)和符号引用(Symbolic Reference),字面量包括字符串、声明为final的常量值。符号引用包括:
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
- 方法的名称和描述符
Java与C不同,不存在"链接"的编译步骤,而是在JVM加载class文件时动态链接——class文件中不包含方法、字段 的内存布局信息——通过符号引用进行翻译,得到具体内存地址
u2 access_flags 1个。识别一些类或接口层次的访问信息,包括:这个Class是类还是接口 、是否定义为 public类型、是否定义为abstract类型、如果是类的话,是否被声明为final,等等 u2 this_class 1个。类索引(指向常量池) u2 superclass 1个。父类索引(指向常量池) u2 interfaces_count 1个。接口个数 u2 interfaces interfaccs_count个。接口索引集合(指向常量池) u2 fields_count 1个。字段个数 field_info fields fields_count个。字段表包含字段的各种限定符、描述符、名字索引等信息。字段表集合中不会列出从超类或父接口中继承而来的字段。
结构:
u2 access_flags 1,字段访问标记,参考下面的表格
u2 name_index 1,简单名称的索引
u2 descriptor_index,描述符的索引
u2 attributes_count,属性的个数
attribute_info attributes attributes_count 属性用于描述一些额外的信息u2 methods_count 1个。方法个数 method_info methods methods_count个。方法表包含字段的各种限定符、描述符、名字索引等信息
结构:
u2 access_flags 1,方法访问标记,参考下面的表格
u2 name_index 1,简单名称的索引
u2 descriptor_index,描述符的索引
u2 attributes_count,属性的个数
attribute_info attributes attributes_count 属性用于描述一些额外的信息,包括表示方法的代码的Code属性。u2 attributes_count 1个。 attribute_info attributes attributes_count个。属性表
结构:
u2 attribute_name_index 1 属性名称在常量池的索引
u2 attribute_length 1 属性长度
u1 info attribute_length 属性内容
JDK 编译器版本 |
target 参数 |
十六进制minor.major |
十进制 minor.major |
jdk1.1.8 |
不能带 target 参数 |
00 03 00 2D |
45.3 |
jdk1.2.2 |
不带(默认为 -target 1.1) |
00 03 00 2D |
45.3 |
jdk1.2.2 |
-target 1.2 |
00 00 00 2E |
46.0 |
jdk1.3.1_19 |
不带(默认为 -target 1.1) |
00 03 00 2D |
45.3 |
jdk1.3.1_19 |
-target 1.3 |
00 00 00 2F |
47.0 |
j2sdk1.4.2_10 |
不带(默认为 -target 1.2) |
00 00 00 2E |
46.0 |
j2sdk1.4.2_10 |
-target 1.4 |
00 00 00 30 |
48.0 |
jdk1.5.0_11 |
不带(默认为 -target 1.5) |
00 00 00 31 |
49.0 |
jdk1.5.0_11 |
-target 1.4 -source 1.4 |
00 00 00 30 |
48.0 |
jdk1.6.0_01 |
不带(默认为 -target 1.6) |
00 00 00 32 |
50.0 |
jdk1.6.0_01 |
-target 1.5 |
00 00 00 31 |
49.0 |
jdk1.6.0_01 |
-target 1.4 -source 1.4 |
00 00 00 30 |
48.0 |
jdk1.6.0_01 |
不带(默认为 -target 1.6) |
00 00 00 32 |
50.0 |
jdk1.7.0 |
不带(默认为 -target 1.6) |
00 00 00 32 |
50.0 |
jdk1.7.0 |
-target 1.7 |
00 00 00 33 |
51.0 |
jdk1.7.0 |
-target 1.4 -source 1.4 |
00 00 00 30 |
48.0 |
常量池是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一。
常量池中的每一项常量都是一个表,共有11种结构各不相同的表结构数据,这11 种表都有一个共同的特点,就是表开始的第一位是一个u1类型的标志位,代表当前这个常量属于哪种常量类型:
类型 | 描述 | |
CONSTANT_Utf8_info | 1 |
UTF-8编码的字符串 u1 tag 1 |
CONSTANT_Integer_info | 3 | 整型字面量,高位在前 |
CONSTANT_Float_info | 4 | 浮点型字面量,高位在前 |
CONSTANT_Long_info | 5 | 长整型字面量,高位在前 |
CONSTANT_Doublc_info | 6 | 双精度浮点型字面量,高位在前 |
CONSTANT_Class_info | 7 |
类或接口的符号引用 u1 tag 1 |
CONSTANT_String_info | 8 | 字符串类型字面量,指向字符串字面量的索引 |
CONSTANT_Fieldref_info | 9 |
字段的符号引用 tag u1 |
CONSTANT_Methodref_info | 10 |
类中方法的符号引用 tag u1 |
CONSTANT_InterfaceMethodref_info | 11 |
接口中方法的符号引用 tag u1 |
CONSTANT_NameAndType_info | 12 |
字段或方法的部分符号引用 tag u1 |
在常量池结束之后,紧接着的2个字节代表访问标志(access_flags):
标志名称 | 标志值 | 说明 |
ACC_PUBLIC | 0x0001 | 是否为public类型 |
ACC_FINAL | 0x0010 | 是否被声明为final,只有类可设置 |
ACC_SUPER | 0x0020 | 是否允许使用invokespecial字节码指令 |
ACC_INTERFACE | 0x0200 | 标识这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为abstract类型,对于接口或抽象类来说值为真 |
ACC_SYNTHETIC | 0x1000 | 标识这个类并非由用户代码产生的 |
ACC_ANNOTATION | 0x2000 | 标识这是一个注解 |
ACC_ENUM | 0x4000 | 标识这是一个枚举 |
字段表的前两个字节:
标志名称 | 标志值 | 说明 |
ACC_PUBLIC | 0x0001 | 字段是否public |
ACC_PRIVATE | 0x0002 | 字段是否pravate |
ACC_PROTECTED | 0x0004 | 字段是否protected |
ACC_STATIC | 0x0008 | 字段是否static |
ACC_FINAL | 0x0010 | 字段是否final |
ACC_VOLATILE | 0x0040 | 字段是否volatile |
ACC_TRANSIENT | 0x0080 | 字段是否transient |
ACC_SYNTHETIC | 0x1000 | 字段是否由编译器自动产生的 |
ACC_ENUM | 0x4000 | 字段是否enum |
方法表的前两个字节:
标志名称 | 标志值 | 说明 |
ACC_PUBLIC | 0x0001 | 方法是否public |
ACC_PRIVATE | 0x0002 | 方法是否pravate |
ACC_PROTECTED | 0x0004 | 方法是否protected |
ACC_STATIC | 0x0008 | 方法是否static |
ACC_FINAL | 0x0010 | 方法是否final |
ACC_SYNCHRONIZED | 0x0020 | 方法是否synchronized |
ACC_BRIDGE | 0x0040 | 方法是否为编译器产生的桥接方法 |
ACC_VARARGS | 0x0080 | 方法是否接受不定参数 |
ACC_NATIVE | 0x0100 | 方法是否为native |
ACC_ABSTRACT | 0x0400 | 方法是否为abstract |
ACC_STRICT | 0x0800 | 方法是否为strict |
ACC_SYNTHETIC | 0x1000 | 方法是否是由编译器自动产生的 |
描述符标识符列表:
标识符 | 含义 |
B | 基本类型byte |
C | 基本类型char |
D | 基本类型double |
F | 基本类型float |
I | 基本类型int |
J | 基本类型long |
S | 基本类型short |
Z | 基本类型boolean |
V | 特殊类型void |
L | 对象类型,如Ljava/lang/Object; |
[ |
表示数组的前缀。例如java.lang.String[][] 表示为 [[Ljava/lang/String; |
描述方法时,按如下规则:
- 先参数列表,后返回值。例如:void ific()描述为()V
- 参数按声明顺序放置于()内。例如:int compare(Object o1,Object o2)描述为(Ljava/lang/ObjectLjava/lang/Object)I
在Class文件、字段 表、方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。JVM规范中预定义了9项虚拟机实现应当能识別的属性:
属性名称 | 使用位置 | 说明 |
Code | 方法表 |
Java代码编译成的字节码指令 结构: u2 attribute_name_index 1,固定为"Code" |
ConstantValue | 字段表 | final关键字定义的常量值 |
Deprecated | 类、方法表、字段表 | 被声明为deprecated的方法和字段 |
Exceptions | 方法表 | 方法抛出的异常 |
InnerClasses | 类文件 | 内部类列表 |
LineNumberTable | Code属性 | Java源码的行号与字节码指令的对应关系 |
LocalVariableTable | Code属性 | 方法的局部变量描述 |
SourceFile | 类文件 | 源文件的名称 |
Synthetic | 类、方法表、宇段表 | 标记方法或字段为编译器自动生成的 |
- 加载:JVM规范没有规定何时加载类,实行根据需要决定。加载阶段需要完成3件事:
a) 通过一个类的全限定名来获取定义此类的二进制字节流
b) 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
c) 在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口 - 验证:确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。包括文件格式验证、元数据验证、字节码验证、符号引用验证等步骤
- 准备:为类变量分配内存并设置类变量初始值,这些内存都在方法区中分配。
- 解析:是将常量池内的符号引用替换为直接引用的过程。包括接口和类解析、字段解析、类方法解析、接口方法解析
- 初始化:是执行类构造方法()的过程(此方法收集静态变量赋值、static代码块自动生成),虚拟机保证多线程环境下()方法被正确的加锁与同步。以下情况,如果类尚未初始化,则需要触发初始化。遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时;当通过反射调用时;父类未初始化时,首先初始化父类;虚拟机启动时,初始化main函数所在类
比较两个类是否“相 等”,只有在这两个类是由同一个类加载器加载的前提之下才有意义。
从JVM的角度,类加载器只分为两类:一种是启动类加载器 (Bootstrap ClassLoader),这个类加栽器使用C++语言实现,是虚拟机自身的一部分; 另外一种就是所有其他的类加载器,这些类加栽器都由Java语言实现且全都继承自抽象类java.lang.ClassLoader。
从开发人员角度,类加载器可以细分为:
类加载器 | 说明 |
启动类加载器 Bootstrap ClassLoader |
负责将$JAVA_HOME\lib目录中的、或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar)类库加载到虚拟机内存中 |
扩展类加栽器 |
由sun.misc.Launcher$ExtClassLoader实现,它负责加载$JAVA_HOME\lib\ext目录中的、或者被 java.ext.dirs系统变最所指定的路径中的所有类库 |
应用程序类加栽器 |
由sun.misc. Launcher$AppClassLoader来实现,ClassLoader中.getSystemClassLoader()的返回值即为此加载器,一般也称为系统类加载器。负责加载CLASSPATH上所指定的类库,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中 默认的类加栽器。 |
所谓双亲委派模型,指出了启动类加载器外,所有ClassLoader都可通过getParent()得到父加载器。通常,如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加 载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当 父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己加载。
线程上下文 类加载器(Thread Context ClassLoader)可以通过java.lang.Thread类的 setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承。
栈帧(Stack Frame)是用于支持JVM进行方法调用和方法执行的数据结构,是JVM运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。对于执行引擎来讲,只有栈顶的栈帧是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作,栈帧结构示意图如下:
局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,每个Slot 都应该能存放一个 boolean、byte、char、short、int、float、reference 或 retumAddress 类型的数据(即一般的说Slot大小为32bits)
虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始到局部变量 表最大的Slot数量。对于Long、Double占用2个Slot,高位在前
对于块级作用域的变量,其Slot可以被重用
也称为操作栈,每个栈元素可以是任意Java类型,Long、Double占用两个栈容量。当方法开始执行时,操作数栈是空的,其后会有各种字节码指令向操作数栈中写入和提取内容,也就是入栈出栈操作。例如, 在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用 是为了支持方法调用过程中的动态连接
方法退出有两种方式:
- 执行引擎遇到方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,这种退出方法的方式称为正常完成出口
- 方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令 产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口
方法退出后需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定。方法退出时可能执行的操作有:
- 恢复上层方法的局部变量表和操作数栈
- 把返回值(如果有的话)压入调用者栈帧的操作数栈中
- 调整PC计数器的值以指向方法调用指令后面的一条指令
为了获得较好的执行效能,JMM不限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器调整代码执行顺序。
JMM规定了所有的变量都存储在主内存(Main Memory,相当于物理内存)中。每条线程还有自己的工作内存(Working Memory,相当于处理器高速缓存、寄存器),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝, 线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
JMM定义了8种原子性操作来确定工作内存与主内存如何交互:
- lock (锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态
- unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来, 释放后的变量才可以被其他线程锁定
- read (读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的 工作内存中,以便随后的load动作使用
- load (载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
- use (使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执 行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作
- assign (赋值作用于工作内存的变量,它把一个从执行引擎接收到的值陚值 给工作内存的变量,每当虚拟机遇到一个给变量陚值的字节码指令时执行这个操作
- store (存储):作用于工作内存的变董,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用
- write (写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中
JMM还规定了在执行上述八种基本操作时必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现
- 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中
- 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了 assign和load操作
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作, 变量才会被解锁
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行sotre 和write操作)
关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制。
当一个变量被定义成volatile之后,它将具备两种特性:
- 第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,变量值在线程间传递均需要通过主内存来完成,如:线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量的值才会对线程B可见。
- 第二是禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变 量赋值操作的顺序与程序代码中的执行顺序一致
但是volatile并不保证操作的原子性,在不符合以下两条规则的运算场景中,仍要通过加锁来保证原子性:
- 运算结果并不依赖变量的当前值(例如简单setter),或者能够确保只有单一的线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束
对于64位的数据类型(long和double),JMM允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次进行。但是实际上,商业JVM基本上都把64位数据的读写操作作为原子操作实现。
- 原子性(Atomicity):由Java内存模型来直接保证的原子性变量操作包括read、 load、assign、use、store和write这六个,我们大致可以认为基本数据类型的访问读写是具备原子性的。如果需要在大范围实现原子性,lock、unlock操作可以完成(对应JVM指令monitorenter 、monitorexit,在代码上反映为Synchronized代码块)
- 可见性(Visibility):当一个线程修改了共享变量的值,其他线程能够 立即得知这个修改。volatile保证了多线程 操作时变置的可见性,而普通变量则不能保证这一点。synchronized、final也可以实现可见性
- 有序性(Ordering):天然的有序性:如果在本线程内观察,所有的操作都是有序的。在另一个线程观察,所有的操作都是无序的——指令重排、工作内存和主内存同步延迟的影响导致无序。volatile、synchronized提供有序性保证
“先行发生”(happens-before)的原则非常重 要,它是判断数据是否存在竞争,线程是否安全的主要依据。下面是Java内存模型下一些“天然的”先行发生关系(无需任何同步保证手段):
- 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 监视器锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序
- volatile变量规则 (Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序
- 线程启动规则(Thread Start Rule) :Thread对象的start()方法先行发生于此线程的每一个动作
- 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行
- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生
- 对象终结规则(Hnalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始
- 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作 C,那么操作A先行发生于操作C
Java中线程的实现具有以下几种方式:
- 使用内核线程实现。内核线程(Kernel Thread, KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。
程序一般不会直接去使用内核线程,而是去使用内核线程的一种髙级接口——轻量级进程(Light Weight Process, LWP)。轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型 - 使用用户线程实现。狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持规模更大的线程数量
- 混合实现:既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了进程被阻塞的风 险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是M:N的关系
Java语言定义了 5种进程状态:
- 新建(New):创建后尚未启动的线程处于这种状态
- 运行(Runable):Runable包括了操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间
- 无限期等待(Waiting):处于这种状态的进程不会被分配CPU执行时间,它们要等待被其他线程显式地唤醒。以下方法会让线程陷入无限期的等待状态:
a) 没有设置Timeout参数的Object.wait()方法
b) 没有设置Timeout参数的Thread.join()方法
c) LockSupport.park()方法 - 限期等待(Timed Waitting):处于这种状态的进程也不会被分配CPU执行时间, 不过无须等待被其他线程显式地唤醒,在一定时间之后它们会由系统自动唤醒。 以下方法会让线程进入限期等待状态:
a) Thread. sleep()方法
b) 设置了 Timeout 参数的 Object.wait()方法
c) 设置了 Timeout 参数的 Thread.join()方法
d) LockSupport.parkNanos() 方法
e) LockSupport.parkUntil()方法 - 阻塞(Blocked):进程被阻塞了,“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
- 结束(Terminated):已终止线程的线程状态,线程已经结束执行
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。
按照线程安全的级别,可以按线程安全强度把共享数据分为5类:
- 不可变对象:例如字符串、数字的部分子类、枚举
- 绝对线程安全:完全满足上述关于线程安全的定义。即使多个线程同时对这个对象进行一系列顺序的操作,也不会出现意外的结果
- 相对线程安全:通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施。在Java语言中,大部分的线程安全类都属于这种类型,例如Vector、HashTable、 Collections.synchronizedCollection()方法包装的集合等
- 线程兼容:是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中安全地使用,我们平常说一个类不是线程安全的,绝大多数指的都是这种情况。Java API中大部分的类都是线程兼容
- 线程对立:是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于Java语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现。一个线程对立的例子是Thread类的suspend()和resume()方法
- 互斥同步(Mutual Exclusion & Synchronization):是最常见的一种并发正确性保障手段,同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条 (或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区 (Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。在Java里面,最基本的互斥同步手段就是synchronized关键字。synchronized是Java语言中一个重量级的操作,虚拟机会进行一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到内核态中。除了 synchronized之外,还可以使用java.util.concurrent的重入锁(ReentrantLock)来实现同步,在基本用法上,ReentrantLock与synchronized 很相似,他们都具备一样的线程重入特性,只是代码写法上有点区别,一个表现为API 层面的互斥锁(lock和unloek方法配合try/fmally语句块来完成),一个表现为原生语法层面的互斥锁。不过ReentrantLock比synchronized增加了一些髙级功能,主要有以下三项:
a) 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择 放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助
b) 公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁
c) 锁绑定多个条件:一个ReentrantLock对象可以同时绑定多个Condition对象, 而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个 锁,而ReentrantLock则无须这样做,只需要多次调用newCondition()方法即可 - 非阻塞同步:随着硬件指令集的发展,可以使用基于冲突检测的乐观并发策略。Atomiclnteger就是这样的例子
- 无同步方案:
a) 可重入代码:也叫纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。可重入代码有一些共同的特征:例如不依赖存储在堆上的数据和公用的系统资源、 用到的状态量都由参数中传入、不调用非可重入的方法等。
b) 线程本地存储(Thread Local Storage)
互斥同步对性能最大的影响是阻塞的实现, 挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来 了很大的压力。在许多应用上,共享数据的锁定状态 只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有 一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会儿”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是 所谓的自旋锁。
自旋锁在JDK1.6以后默认开启。使用-XX:+UseSpinning可以强制开启。自旋默认值是10次,可以使用参数-XX:PreBlockSpin来更改。
在JDK 1.6中引入了自适应的自旋锁。自旋的时间由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象 上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。
大部分情况下,倾向于将同步块的作用范围限制得尽量小,但是如果一系列的连续操作都对同一个对反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地 进行互斥同步操作也会导致不必要的性能损耗。
轻量级锁是JDK 1.6中加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。首先 需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了 使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了 CAS操 作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
偏向锁也是JDK 1.6中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉, 连CAS操作都不做了。偏向锁可以提高带有同步但无竞争的程序性能。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。有时候使用参数-XX:-UseBiasedLocking来禁止偏向锁优化反而可以提升性能。
逃逸分析是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
在计算机语言编译器优化原理中,逃逸分析是指分析指针动态范围的方法,它同编译器优化原理的指针分析和外形分析相关联。当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他过程或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。
Java在Java SE 6u23以及以后的版本中支持并默认开启了逃逸分析的选项。Java的 HotSpot JIT编译器,能够在方法重载或者动态加载代码的时候对代码进行逃逸分析,同时Java对象在堆上分配和内置线程的特点使得逃逸分析成Java的重要功能。
经过逃逸分析之后,可以得到三种对象的逃逸状态:
- GlobalEscape(全局逃逸), 即一个对象的引用逃出了方法或者线程
- ArgEscape(参数级逃逸),即在方法调用过程当中传递对象的引用给一个方法
- NoEscape(没有逃逸),一个可以进行标量替换的对象。可以不将这种对象分配在传统的堆上
编译器可以使用逃逸分析的结果,对程序进行以下优化:
- 堆分配对象变成栈分配对象。一个方法当中的对象,对象的引用没有发生逃逸,那么这个方法可能会被分配在栈内存上而非常见的堆内存上
- 消除同步。线程同步的代价是相当高的,同步的后果是降低并发性和性能。逃逸分析可以判断出某个对象是否始终只被一个线程访问,如果只被一个线程访问,那么对该对象的同步操作就可以转化成没有同步保护的操作,这样就能大大提高并发程度和性能
- 矢量替代。逃逸分析方法如果发现对象的内存存储结构不需要连续进行的话,就可以将对象的部分甚至全部都保存在CPU寄存器内,这样能大大提高访问速度
逃逸分析对应的JVM参数是 -XX:+DoEscapeAnalysis
Leave a Reply