不安分的黑娃
踏踏实实,坚持学习,慢慢就懂了~

目录

参考资料

JDK Vs JRE

image

JVM 结构

JVM 运行时数据区

image

  1. 程序计数器
每个 JVM 线程都有自己的程序计数器.
如果方法不是 native ,程序计数器记录了当前正在执行的 JVM 指令的地址.
唯一1个不会抛出 OOM 异常的内存区域。
  1. Java 虚拟机栈(OOM,StackOverflowError)
创建线程后生成 Java 虚拟机栈 . 
Java 虚拟机栈存储的都是**栈帧**,
Java 虚拟机栈 包含本地变量,部分结果,方法调用和返回的一部分.

Java 虚拟机栈的内存可以不连续.
  1. 堆(OOM)
存储了所有 class 实例和分配的数组都在 堆中.
所有JVM线程共享.
JVM启动时创建.
垃圾收集器进行对象的回收.会抛出 OOM
  1. 方法区(OOM)
存储了每个类的结构,比如运行时常量池,成员变量和方法,方法和构造器的代码,以及类和接口初始化的特定方法.

它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
JDK 1.8 以前,使用永久代实现方法区的功能,所以有人就称方法区为永久代(永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小)。
JDK 1.8 完全废弃永久代,将类型信息移动到元空间。

运行时常量池: 用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
class 文件的constant_pool 表示运行时常量池.它包含了各种常量,JVM 生成类或者结构后,这个类或接口的常量池就会构造起来.

运行时常量池会抛出 OOM(Out Of Memory).虚拟机启动时创建.

String类的 intern()方法可以在运行期间,将常量放到常量池中。
  1. 本地方法栈(OOM,StackOverflowError)
其他语言写的方法,一般是 C 语言的方法.
创建线程后生成 本地方法栈 . 

直接内存

直接内存不是运行时数据区的一部分。
在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer对象作为这块内存的引用进行操作。

直接内存的分配不受Java 堆大小限制,但是会影响本机总内存,当总内存不够时,会导致动态扩展时出现 OutOfMemoryError异常。

栈帧

Java 虚拟机栈存储栈帧.
栈帧用于存储数据和部分结果,以及执行动态链接、方法返回值和分派异常。

每次方法调用,产生1个新的栈帧,方法调用完成后栈帧就会销毁.

栈帧从线程的 Java 虚拟机中分配.
image

下边会详细说明栈帧内容。

局部变量数组

数组大小由编译后决定.

boolean, byte, char, short, int, float, reference, or returnAddress 类型的值占用1个本地变量.
long or double 类型占用2个连续的本地变量.
本地变量由下标定位.

操作数栈(先进后出)

操作数栈的大小由编译后决定.

当栈帧创建后,操作数栈是空的.

动态连接

每个栈帧都包含当前方法对运行时常量池的一个引用.
方法的类文件代码通过符号引用要调用的方法和访问的变量。

动态连接就是将 符号引用转为真实的调用.

普通方法调用完成

在这种情况下,当前帧用于恢复调用者的状态,包括其局部变量和操作数堆栈,并适当增加调用者的程序计数器,以跳过方法调用指令。然后在调用方法的帧中继续正常执行,并将返回值(如果有的话)推入该帧的操作数堆栈。

方法调用突然完成(抛出异常)

不会返回值给调用者.

Class 文件格式

ClassFile {
    u4             magic;
    u2             minor_version;
    u2             major_version;
    u2             constant_pool_count;
    cp_info        constant_pool[constant_pool_count-1];
    u2             access_flags;
    u2             this_class;
    u2             super_class;
    u2             interfaces_count;
    u2             interfaces[interfaces_count];
    u2             fields_count;
    field_info     fields[fields_count];
    u2             methods_count;
    method_info    methods[methods_count];
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

magic: 魔数,固定值 0xCAFEBABE
minor_version : JDK 主版本
major_version: JDK 副版本
constant_pool_count: 常量数量
constant_pool[] 包含字符串常量,类和接口的名字,成员变量名字和其他常量;下标从1开始到 constant_pool_count-1
access_flags: 访问权限标志
this_class:常量池的一个下标,指向类或结构的定义.
super_class: 父类
interfaces_count:接口数量
interfaces[interfaces_count] :接口数组
fields_count: 成员数量
fields[fields_count]:成员数组
methods_count:方法数量
methods[methods_count]: 方法数组
attributes_count: 类的属性表数量
attributes[attributes_count]: 类属性数组

Java对象内存模型

一个Java对象在内存中包括3个部分:对象头、实例数据和对齐填充

JVM 类加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。

类加载过程

image

1. 加载阶段

(1)通过一个类的全限定名获取定义此类的二进制字节流(此动作通过类加载器完成)
(2)将这个二进制字节流代表的静态存储结构转化为方法区运行时数据结构
(3)在内存中生成代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
注意:二进制字节流并没有规定从哪里获取,可以从 class 文件,ZIP 包,网络,运行时计算生成,由其他文件生成(比如JSP)。

2. 连接阶段——验证

加载阶段与链接阶段的部分动作是交叉进行的,但是 加载阶段的开始时间一定在连接阶段开始之前。

验证阶段包含4个动作:文件格式验证,元数据验证,字节码验证,符号引用验证。

文件格式验证
验证class文件中的魔数,主次版本号,常量池里的常量是否有不被支持的类型,指向常量的各种索引值中是否指向不存在的常量...等非常多验证。

这个阶段是基于二进制流进行的,当验证完成后, 该二进制流才被允许进入 Java 虚拟机的方法区中存储。后边三个在方法区中验证。

元数据验证
对字节码描述的信息进行语义分析,保证不存在与《java 虚拟机规范》定义相悖的元数据。
验证点可能包括:

  • 这个类是否有父类(除了 java.lang.Object 外,其他都有父类)
  • 这个父类是否继承了不允许被继承的类(比如, final 修饰的类)
  • 如果这个类不是抽象的,它是否实现了接口中的所有方法
  • 类中的字段,方法和父类是否矛盾。

字节码验证
主要通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
这阶段就要对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。
比如:

  • 任何跳转指令都不能跳转到方法体以外的字节码指令上。
  • 保证方法中任何类型转换都是有效的。

符号引用验证
发生在虚拟机将符号引用转为直接引用的时候,这个动作发生在连接-解析阶段发生。

符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验。
通常需要校验以下内容:

  • 通过字符描述的全限定名是否可以找到这个类。
  • 指定类中是否存在方法或者字段的描述。
  • 符号引用中的类,字段,方法的可访问性是否可以被当前类访问。

3. 连接阶段——准备

准备阶段是正式为类变量(静态变量)分配内存并设置初始0值阶段。

从 JDK 8 开始,类变量跟随 Class 对象存储在堆中。JDK8以前,方法区是由永久代实现的,所以静态变量实在方法区内。

比如:

public static int a = 10;

那么准备阶段完成后,类变量 a 的值就是 0;

4. 连接阶段——解析

解析阶段就是将 Java 虚拟机常量池中的符号引用转化为直接引用。

  • 符号引用:一组符号来描述所引用的目标,符号描述。
  • 直接引用:以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行,分别对应于常量池的
CONSTANT_Class_info、CON-STANT_Fieldref_info、
CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、
CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和
CONSTANT_InvokeDynamic_info 8种常量类型

5. 初始化

初始化阶段就是执行类构造器<clinit>()方法的过程。这个方法是编译器生成的。
初始化,依旧是对类变量的初始化赋值。连接-准备阶段已经给类变量设置了0值,初始化就是要给类变量设置初始值。

类加载器

类加载阶段的“通过一个类的全限定名称获取描述该类的二进制字节流”的动作交给外部的类加载器完成。

每个类加载器都有自己的类命名空间。即使两个类加载器都加载了同1个class文件,实例化出来的Class对象并不是同1个。

比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

双亲委派模型

JDK8 及 JDK 8以前,三层类加载器:
image
上图中的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型”。

双亲委派模型的工作过程是:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

java.lang.ClassLoader#loadClass 方法源码:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 检查类是否已经被加载
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    // 交给父加载器加载(除了 Bootstrap ClassLoader外,其他所有类加载器都有父加载器)
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        // 此加载器是Bootstarp ClassLoader,没有父类加载器
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }
                // 如果父类加载器,都没找到这个类,那么由本加载器自行加载
                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

破坏双亲委派模型

第一次破坏: JDK 1.2以前没有双亲委派模型,用户自定义类加载器。

第二次破坏:JDK1.3 将 JNDI 服务加入到rt.jar, 但JNDI存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口 。Java 团队提供了线程上下文类加载器,JNDI使用此类加载器去加载所需的代码。JDK 6 以前的 SPI加载。

第三次破坏:由用户追求动态性,比如代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。
OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现。

JDK 1.9 引入了Java模块化系统

JDK 9的模块不仅仅像之前的JAR包那样只是简单地充当代码的容器,除了代码外,Java的模块定义还包含以下内容:

  • 依赖其他模块的列表。
  • 导出的包列表,即其他模块可以使用的列表。
  • 开放的包列表,即其他模块可反射访问模块的列表。
  • 使用的服务列表。
  • 提供服务的实现列表。

类加载器关系

image

当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,也许这可以算是对双亲委派的第四次破坏。

各个类加载器负责加载的模块
启动类加载器(Bootstrap ClassLoader)加载的模块:

java.base            java.security.sasl
java.datatransfer    java.xml
java.desktop         jdk.httpserver
java.instrument      jdk.internal.vm.ci
java.logging         jdk.management
java.management      jdk.management.agent
java.management.rmi  jdk.naming.rmi
java.naming          jdk.net
java.prefs           jdk.sctp
java.rmi             jdk.unsupported

平台类加载器(Platform ClassLoader)加载的模块:

java.activation*     jdk.accessibility
java.compiler*       jdk.charsets
java.corba*          jdk.crypto.cryptoki
java.scripting       jdk.crypto.ec
java.se              jdk.dynalink
java.se.ee           jdk.incubator.httpclient
java.security.jgss   jdk.internal.vm.compiler*
java.smartcardio     jdk.jsobject
java.sql             jdk.localedata
java.sql.rowset      jdk.naming.dns
java.transaction*    jdk.scripting.nashorn
java.xml.bind*       jdk.security.auth
java.xml.crypto       
jdk.security.jgss
java.xml.ws*           
jdk.xml.dom
java.xml.ws.annotation* 
jdk.zipfs

应用程序类加载器(Application ClassLoader)加载的模块:

jdk.aot              jdk.jdeps
jdk.attach           jdk.jdi
jdk.compiler         jdk.jdwp.agent
jdk.editpad          jdk.jlink
jdk.hotspot.agent    jdk.jshell
jdk.internal.ed      jdk.jstatd
jdk.internal.jvmstat jdk.pack
jdk.internal.le      jdk.policytool
jdk.internal.opt     jdk.rmic
jdk.jartool          jdk.scripting.nashorn.shell
jdk.javadoc          jdk.xml.bind*
jdk.jcmd             jdk.xml.ws*
jdk.jconsole

虚拟机字节码执行引擎

执行引擎是Java虚拟机核心的组成部分之一。

image

栈帧

JVM 以方法的调用作为基本单元,栈帧用于支持JVM方法调用和执行的数据结构。
栈帧包括:本地变量表,操作数栈, 动态链接和返回地址等。

栈帧概念结构
image
当前栈帧 : 位于栈顶的栈帧。
当前方法 : 当前栈帧关联的方法。

局部变量表

局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部的局部变量。
局部变量表以变量槽为最小单位。一个变量槽能存储 32位的数据类型。变量槽允许跟随处理器变化。
对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。

Java虚拟机通过索引定位的方式使用局部变量表。

操作数栈

操作数栈是先入后出的。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种
字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。

两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在
大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。如下图:
image

动态链接

每个栈帧都有一个指向运行时常量池中该栈帧所属方法的引用,这个引用是为了支持方法引用中的动态链接。
动态连接就是每一次运行期间都将符号引用转为直接引用。

方法返回地址

当一个方法调用后,有两种方式退出这个方法:正常调用完成和异常调用完成。
正常调用
第一种方式是执行引擎遇到任意一个方法
返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定。

异常调用
在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。

一般来说,方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。

其他附加信息

《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、
性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在讨论概念时,一
般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

方法调用

方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。

方法调用分为解析和分派步骤。

Java 堆

Java 堆是存储数组和对象的。
Java 堆分代模型:
image

Java 堆分为:新生代和老年代,大小比例为 1:2。
新生代又分为:Eden区 和 一个是Survivor区,Survivor区又分为大小相等的 S0和 s1 区;大小比例:Eden:s0:s1 = 8:1:1

新生代:刚创建的对象,会放在 Eden区。

Java 对象创建过程

一般情况下,新创建的对象都会被分配到Eden区,一些特殊的大的对象会直接分配到Old区。

image

常见问题答疑

如何理解 Minor/Major/Full GC?

Minor GC : 新生代垃圾回收
Major GC : 老年代垃圾回收
Full GC : 新生代+老年代垃圾回收

Survivor的存在意义

Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。

为什么需要两个Survivor区?

最大的好处就是解决了碎片化。
新生代采用的是“复制-清除”垃圾回收算法。
复制-清除算法:将s0中活跃对象拷贝到 s1 后,将 s0 整个清除掉。

新生代中Eden:S1:S2为什么是8:1:1?

新生代中的可用内存:复制算法用来担保的内存为9:1
可用内存中Eden:S1区为8:1
即新生代中Eden:S1:S2 = 8:1:1
现代的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代中的对象大概98%是“朝生夕死”的

堆内存中都是线程共享的区域吗?

JVM默认为每个线程在Eden上开辟一个buffer区域,用来加速对象的分配,称之为TLAB,全称:Thread Local Allocation Buffer。
对象优先会在TLAB上分配,但是TLAB空间通常会比较小,如果对象比较大,那么还是在共享区域分配。

垃圾回收

如何确定Java对象是个垃圾?

两种方法:

  1. 引用计数法(无法解决循环引用)
  2. 可达性分析(通过GC Root的对象,开始向下寻找,看某个对象是否可达)

引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

可达性分析
image

能作为GC Root:类加载器、Thread、虚拟机栈的本地变量表、static成员、常量引用、本地方法栈的变量等。

什么时候进行垃圾回收?

GC 是由JVM自动完成的,根据JVM系统环境而定,所以时机是不确定的。

一般以下情况就开始进行回收:

(1)当Eden区或者S区不够用了
(2)老年代空间不够用了
(3)方法区空间不够用了
(4)System.gc()

垃圾回收算法

标记-清除

标记出来需要回收的对象,然后进行清除。
缺点:


标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程
序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
(1)标记和清除两个过程都比较耗时,效率不高
(2)会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制

将内存划分为两块相等的区域,每次只使用其中一块,如下图所示:
image

当其中一块内存使用完了,就将还存活的对象复制到另外一块上面,然后把已经使用过的内存空间一次清除掉:
image

缺点:空间利用率低。

标记-整理

标记过程仍然与"标记-清除"算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集

针对新生代和老年代采取不同的垃圾回收算法:
新生代:复制算法(对象在被分配之后,可能生命周期比较短,Young区复制效率比较高)
老年代:标记清除或标记整理(Old区对象存活时间比较长,复制来复制去没必要,不如做个标记再清理)

垃圾回收器

image

上图中,七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。

Serial(串行收集器)

它是一种单线程收集器,不仅仅意味着它只会使用一个CPU或者一条收集线程去完成垃圾收集工作,更重要的是其在进行垃圾收集的时候需要暂停其他线程。

优点:简单高效,拥有很高的单线程收集效率
缺点:收集过程需要暂停所有线程
算法:复制算法
适用范围:新生代
应用:Client模式下的默认新生代收集器

image

暂停所有应用程序线程被称为“stop the world”,这个时候所有用户线程将会保存到安全点,等垃圾收集线程结束后,再从安全点恢复出来。

Serial Old(串行收集器)

优点:简单高效,拥有很高的单线程收集效率
缺点:收集过程需要暂停所有线程
算法:标记-整理算法
适用范围:老年代

image

ParNew

ParNew 是 Serial 垃圾收集器的多线程版本。

优点:在多CPU时,比Serial效率高。
缺点:收集过程暂停所有应用程序线程,单CPU时比Serial效率差。
算法:复制算法
适用范围:新生代
应用:运行在Server模式下的虚拟机中首选的新生代收集器

image

Parallel Scavenge

优点:在多CPU时,比Serial效率高。
算法:复制算法
适用范围:新生代

Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。

吞吐量=处理器用于运行用户代码的时间 / 处理器总消耗时间

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是:

  1. -XX:MaxGCPauseMillis 控制最大垃圾收集停顿时间
  2. -XX:GCTimeRatio 直接设置吞吐量大小的

Parallel Old

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实
现。这个收集器是直到JDK 6时才开始提供的。
在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。
image

CMS(并发标记收集器)

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

采用的是"标记-清除算法",整个过程分为4步:
(1)初始标记 CMS initial mark     标记GC Roots直接关联对象,不用Tracing,速度很快
(2)并发标记 CMS concurrent mark  进行GC Roots Tracing
(3)重新标记 CMS remark           修改并发标记因用户程序变动的内容
(4)并发清除 CMS concurrent sweep 清除不可达对象回收空间,同时有新垃圾产生,留着下次清理称为浮动垃圾

image

优点:并发收集、低停顿
缺点:产生大量空间碎片、并发阶段会降低吞吐量

G1 垃圾收集器

官网: https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc.html#garbage_first_garbage_collection

G1是一款主要面向服务端应用的垃圾收集器。可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的 Mixed GC模式。

G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数
-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。
而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的 Humongous Region之中,
G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待

image

G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。
更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),
优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。
这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。

在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
CMS收集器采用增量更新算法实现,而G1收集器则是通过原始快照(SATB)算法来实现的。

-XX:G1HeapRegionSize 设置 Region 大小
-XX:MaxGCPauseMillis 指定的停顿时间

image

G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现。

就内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且
堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和
其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间。

目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,

垃圾收集器分类

  • 串行收集器->Serial和Serial Old

只能有一个垃圾回收线程执行,用户线程暂停。

适用于内存比较小的嵌入式设备

  • 并行收集器[吞吐量优先]->Parallel Scanvenge、Parallel Old

多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。

适用于科学计算、后台处理等若交互场景

  • 并发收集器[停顿时间优先]->CMS、G1

用户线程和垃圾收集线程同时执行(但并不一定是并行的,可能是交替执行的),垃圾收集线程在执行的时候不会停顿用户线程的运行。

适用于相对时间有要求的场景,比如Web

image

查看 GC 参数

  1. 查看GC基本信息
JDK 9 以前 -XX:+PrintGC  
JDK 9 开始  -Xlog:gc
  1. 查看GC详细信息
JDK 9 以前 -XX:+PrintGCDetails  
JDK 9 开始  -Xlog:gc*
  1. 查看GC前后堆,方法区容量变化
JDK 9 以前 -XX:+PrintHeapAtGC
JDK 9 开始  -Xlog:gc+heap=debug
  1. 查看GC过程中用户线程并发时间以及停顿的时间
JDK 9 以前 -XX:+PrintGCApplicationConcurrentTime  -XX:+PrintGCApplicationStoppedTime
JDK 9 开始  -Xlog:safepoint
  1. 查看收集器Ergonomics机制
JDK 9 以前 -XX:+PrintAdaptive-SizePolicy
JDK 9 开始  -Xlog:gc+ergo*=trace
  1. 查看熬过收集后剩余对象的年龄分布信息
JDK 9 以前 -XX:+PrintTenuring-Distribution
JDK 9 开始  -Xlog:gc+age=trace

垃圾收集器参数

常用参数含义

参数 含义 说明
-XX:CICompilerCount=3 最大并行编译数 如果设置大于1,虽然编译速度会提高,但是同样影响系统稳定性,会增加JVM崩溃的可能
-XX:InitialHeapSize=100M 初始化堆大小 简写-Xms100M
-XX:MaxHeapSize=100M 最大堆大小 简写-Xms100M
-XX:NewSize=20M 设置年轻代的大小
-XX:MaxNewSize=50M 年轻代最大大小
-XX:OldSize=50M 设置老年代大小
-XX:MetaspaceSize=50M 设置方法区大小
-XX:MaxMetaspaceSize=50M 方法区最大大小
-XX:+UseParallelGC 使用UseParallelGC 新生代,吞吐量优先
-XX:+UseParallelOldGC 使用UseParallelOldGC 老年代,吞吐量优先
-XX:+UseConcMarkSweepGC 使用CMS 老年代,停顿时间优先
-XX:+UseG1GC 使用G1GC 新生代,老年代,停顿时间优先
-XX:NewRatio 新老生代的比值 比如-XX:Ratio=4,则表示新生代:老年代=1:4,也就是新生代占整个堆内存的1/5
-XX:SurvivorRatio 两个S区和Eden区的比值 比如-XX:SurvivorRatio=8,也就是(S0+S1):Eden=2:8,也就是一个S占整个新生代的1/10
-XX:+HeapDumpOnOutOfMemoryError 启动堆内存溢出打印 当JVM堆内存发生溢出时,也就是OOM,自动生成dump文件
-XX:HeapDumpPath=heap.hprof 指定堆内存溢出打印目录 表示在当前目录生成一个heap.hprof文件
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -Xloggc:g1-gc.log 打印出GC日志 可以使用不同的垃圾收集器,对比查看GC情况
-Xss128k 设置每个线程的堆栈大小 经验值是3000-5000最佳
-XX:MaxTenuringThreshold=6 提升年老代的最大临界值 默认值为 15
-XX:InitiatingHeapOccupancyPercent 启动并发GC周期时堆内存使用占比 G1之类的垃圾收集器用它来触发并发GC周期,基于整个堆的使用率,而不只是某一代内存的使用比. 值为 0 则表示”一直执行GC循环”. 默认值为 45.
-XX:G1HeapWastePercent 允许的浪费堆空间的占比 默认是10%,如果并发标记可回收的空间小于10%,则不会触发MixedGC。
-XX:MaxGCPauseMillis=200ms G1最大停顿时间 暂停时间不能太小,太小的话就会导致出现G1跟不上垃圾产生的速度。最终退化成Full GC。所以对这个参数的调优是一个持续的过程,逐步调整到最佳状态。
-XX:ConcGCThreads=n 并发垃圾收集器使用的线程数量 默认值随JVM运行的平台不同而不同
-XX:G1MixedGCLiveThresholdPercent=65 混合垃圾回收周期中要包括的旧区域设置占用率阈值 默认占用率为 65%
-XX:G1MixedGCCountTarget=8 设置标记周期完成后,对存活数据上限为 G1MixedGCLIveThresholdPercent 的旧区域执行混合垃圾回收的目标次数 默认8次混合垃圾回收,混合回收的目标是要控制在此目标次数以内
-XX:G1OldCSetRegionThresholdPercent=1 描述Mixed GC时,Old Region被加入到CSet中 默认情况下,G1只把10%的Old Region加入到CSet中

JVM 性能监控工具

  • jps:虚拟机进程状况工具
# 列举JVM进程启动时JVM参数
jps -v
# 列举JVM进程,并打印出主类全名或jar包路径
jps -l *.jar
  • jstat:虚拟机统计信息监视工具
    是用于监视虚拟机各种运行状态信息的命令行工具。
    它可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据。
#查看类装载信息
jstat -class PID 1000 10   查看某个java进程的类装载信息,每1000毫秒输出一次,共输出10次
#查看垃圾收集信息
jstat -gc PID 1000 10

#查看垃圾收集信息,已使用空间与总空间的百分比
jstat -gcutil PID 1000 10
  • jinfo:Java配置信息工具
    jinfo(Configuration Info for Java)的作用是实时查看和调整虚拟机各项参数(参数只有被标记为manageable的flags可以被实时修改)。
    jinfo格式:
jinfo [ option ] pid
# 查看pid 某个参数的值
jinfo  -flag <name> pid 
# 开启或关闭某个参数
jinfo -flag <+|-><name> pid 
# 设置参数值
jinfo  -flag <name>=<value> pid
  • jmap:Java内存映像工具
    jmap(Memory Map for Java)命令用于生成堆转储快照(一般称为heapdump或dump文件)。
    jmap的作用并不仅仅是为了获取堆转储快照,它还可以查询finalize执行队列、Java堆和方法区的
    详细信息,如空间使用率、当前用的是哪种收集器等。
# 打印堆信息
jmap -heap PID
# dump出堆内存相关信息
jmap -dump:format=b,file=heap.hprof PID 
# 启动时添加 JVM 参数,当发生内存溢出自动生成dump 文件
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap.hprof
  • jstack:Java堆栈跟踪工具
    jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者
    javacore文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的
    目的通常是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂
    起等,都是导致线程长时间停顿的常见原因。
# 查看堆栈信息
jstack PID

# 除堆栈信息外,显示锁的附加信息
jstack -l PID
  • 可视化工具

GC Viewer 可以查看分析 GC日志。
Eclipse Memory Analyzer是一个快速且功能丰富的Java堆分析器,可帮助您查找内存泄漏并减少内存消耗。下载地址:https://www.eclipse.org/mat/downloads.php

JVM 性能调优

内存设置

# 调整老年代和新生代比例
-XX:NewRatio
# 设置最大堆大小
-Xmx1024m 
# 设置最小堆大小
-Xms1024m
# 设置线程大小
-Xss256k
# 启动时添加 JVM 参数,当发生内存溢出自动生成dump 文件
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap.hprof

内存溢出

  1. 高并发场景
浏览器缓存、本地缓存、验证码
CDN静态资源服务器
集群+负载均衡
动静态资源分离、限流[基于令牌桶、漏桶算法]
应用级别缓存、接口防刷限流、队列、Tomcat性能优化
异步消息中间件
Redis热点数据对象缓存
分布式锁、数据库锁
5分钟之内没有支付,取消订单、恢复库存等
  1. 内存泄露

ThreadLocal引起的内存泄露,最终导致内存溢出

public class TLController {
 @RequestMapping(value = "/tl")
 public String tl(HttpServletRequest request) {
     ThreadLocal<Byte[]> tl = new ThreadLocal<Byte[]>();
     // 1MB
     tl.set(new Byte[1024*1024]);
     return "ok";
 }
}

(1)top命令查看

top
top -Hp PID

(2)jstack查看线程情况,发现没有死锁或者IO阻塞的情况

jstack PID
java -jar arthas.jar   --->   thread

(3)查看堆内存的使用,发现堆内存的使用率已经高达88.95%

jmap -heap PID
java -jar arthas.jar   --->   dashboard

(4)此时可以大体判断出来,发生了内存泄露从而导致的内存溢出,那怎么排查呢?

jmap -histo:live PID | more
获取到jvm.hprof文件,上传到指定的工具分析,比如heaphero.io

G1调优最佳实战

官网https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc_tuning.html#recommendations)

(1)不要手动设置新生代和老年代的大小,只要设置整个堆的大小

whyhttps://blogs.oracle.com/poonam/increased-heap-usage-with-g1-gc

G1收集器在运行过程中,会自己调整新生代和老年代的大小
其实是通过adapt代的大小来调整对象晋升的速度和年龄,从而达到为收集器设置的暂停时间目标
如果手动设置了大小就意味着放弃了G1的自动调优

(2)不断调优暂停时间目标

一般情况下这个值设置到100ms或者200ms都是可以的(不同情况下会不一样),但如果设置成50ms就不太合理。暂停时间设置的太短,就会导致出现G1跟不上垃圾产生的速度。最终退化成Full GC。所以对这个参数的调优是一个持续的过程,逐步调整到最佳状态。暂停时间只是一个目标,并不能总是得到满足。

(3)使用-XX:ConcGCThreads=n来增加标记线程的数量

IHOP如果阀值设置过高,可能会遇到转移失败的风险,比如对象进行转移时空间不足。如果阀值设置过低,就会使标记周期运行过于频繁,并且有可能混合收集期回收不到空间。 
IHOP值如果设置合理,但是在并发周期时间过长时,可以尝试增加并发线程数,调高ConcGCThreads。

(4)MixedGC调优

-XX:InitiatingHeapOccupancyPercent
-XX:G1MixedGCLiveThresholdPercent
-XX:G1MixedGCCountTarger
-XX:G1OldCSetRegionThresholdPercent

(5)适当增加堆内存大小

(6)不正常的Full GC

有时候会发现系统刚刚启动的时候,就会发生一次Full GC,但是老年代空间比较充足,一般是由Metaspace区域引起的。可以通过MetaspaceSize适当增加其大家,比如256M。

JVM性能优化指南

image

常见问题

(1)内存泄漏与内存溢出的区别

内存泄漏是指不再使用的对象无法得到及时的回收,持续占用内存空间,从而造成内存空间的浪费。
内存泄漏很容易导致内存溢出,但内存溢出不一定是内存泄漏导致的。

(2)young gc会有stw吗?

不管什么 GC,都会发送 stop-the-world,区别是发生的时间长短。而这个时间跟垃圾收集器又有关
系,Serial、PartNew、Parallel Scavenge 收集器无论是串行还是并行,都会挂起用户线程,而 CMS
和 G1 在并发标记时,是不会挂起用户线程的,但其它时候一样会挂起用户线程,stop the world 的时
间相对来说就小很多了。

(3)major gc和full gc的区别

Major GC在很多参考资料中是等价于 Full GC 的,我们也可以发现很多性能监测工具中只有 Minor GC
和 Full GC。一般情况下,一次 Full GC 将会对年轻代、老年代、元空间以及堆外内存进行垃圾回收。触
发 Full GC 的原因有很多:当年轻代晋升到老年代的对象大小,并比目前老年代剩余的空间大小还要大
时,会触发 Full GC;当老年代的空间使用率超过某阈值时,会触发 Full GC;当元空间不足时(JDK1.7
永久代不足),也会触发 Full GC;当调用 System.gc() 也会安排一次 Full GC。

(4)什么是直接内存

Java的NIO库允许Java程序使用直接内存。直接内存是在java堆外的、直接向系统申请的内存空间。通
常访问直接内存的速度会优于Java堆。因此出于性能的考虑,读写频繁的场合可能会考虑使用直接内
存。由于直接内存在java堆外,因此它的大小不会直接受限于Xmx指定的最大堆大小,但是系统内存是
有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。

(5)垃圾判断的方式

引用计数法:指的是如果某个地方引用了这个对象就+1,如果失效了就-1,当为0就会回收但是JVM没
有用这种方式,因为无法判定相互循环引用(A引用B,B引用A)的情况。
引用链法: 通过一种GC ROOT的对象(方法区中静态变量引用的对象等-static变量)来判断,如果有
一条链能够到达GC ROOT就说明,不能到达GC ROOT就说明可以回收。

(6)不可达的对象一定要被回收吗?

即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真
正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行
一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或
finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个
对象建立关联,否则就会被真的回收。

(7)为什么要区分新生代和老年代?

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不
同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合
适的垃圾收集算法。
比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制
成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分
配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

(8)G1与CMS的区别是什么

CMS 主要集中在老年代的回收,而 G1 集中在分代回收,包括了年轻代的 Young GC 以及老年代的 Mix
GC;G1 使用了 Region 方式对堆内存进行了划分,且基于标记整理算法实现,整体减少了垃圾碎片的
产生;在初始化标记阶段,搜索可达对象使用到的 Card Table,其实现方式不一样。

(9)方法区中的无用类回收

方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?
判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。
类需要同时满足下面 3 个条件才能算是 “无用的类” :
a-该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
b-加载该类的 ClassLoader 已经被回收。
c-该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java 内存模型

内存模型:它可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

image

从JDK 5开始,Java 内存模型成熟起来。

Java 内存模型的主要目的是定义程序中各个变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。
注:此处的变量包括:实例字段,静态字段和构成数组的元素。

Java 内存模型规定:

  • 所有变量存储在主内存中。
  • 每条线程都有自己的工作内存。
  • 线程的工作内存保存了该线程使用的变量的主内存副本。
  • 线程对变量的所有操作都在工作内存进行,不能直接操作主内存。
  • 不同线程之间无法访问对方的工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
    image

注意:这里的主内存和工作内存与的Java内存区域中的Java堆、栈、方法区等并不是同一个层次的对内存的划分,两者基本是没有关系的。

内存间的交互操作

即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成。

·lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态
·unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
·load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
·use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
·assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
·store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
·write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:

·不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
·不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
·不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
·一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。
·一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
·如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。
·如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
·对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

volatile

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制.

关键字volatile作用
当一个变量被 volatile 修饰,那么这个变量包含2个特性:

  1. 保持此变量对所有线程的可见性。(可见性,当一个线程修改了此变量,那么其他线程立即可以知道这个变量的新值。)
  2. 是禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

volatile关键字只能保证可见性,以下两个场景需要加锁保证原子性:

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  • ·变量不需要与其他的状态变量共同参与不变约束。

内存屏障保证禁止指令重排序。

针对 long 和 double 的特殊规则

Java内存模型要求lock、unlock、read、load、assign、use、store、write这八种操作都具有原子性,
但是对于64位的数据类型(long和double),
在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,
即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,
这就是所谓的“long和double的非原子性协定”(Non-Atomic Treatment of double and long Variables)。

原子性,可见性和有序性

Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的。

原子性
基本数据类型的访问、读写都是具备原子性的(例外就是long和double的非原子性协定,读者只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)。
可见性
可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。

除了 volatile 关键字外,synchronized 和 final 关键字也能保证可见性。

同步块的可见性:当执行unlock操作时,必须先把此变量同步回主内存中。
final 可见性:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去,那么在其他线程中就能看见final字段的值。

有序性

Java 提供了 synchronized 和 volatile 关键字保证有序性。

volatile :本身包含了禁止指令重排序。
synchronized : 一个变量在同一个时刻只允许一条线程对其进行lock操作

happen-before 原则

happen-before 原则是判断数据是否存在竞争,线程是否安全的非常有用的手段。
happen-before 原则是Java 内存模型中定义的两项操作间的偏序关系。比如说,操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B
观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等

Java 内存模型下的 happen-before 原则有:

  • 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  • 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后。
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行。
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

Java 线程

线程的实现

实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)。
image

image

image

Java 的线程实现

但从JDK 1.3起,“主流”平台上的“主流”商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型。

Java 线程调度

线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式(Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度。

Java使用的线程调度方式就是抢占式调度。

因为主流虚拟机上的Java线程是被映射到系统的原生线程上来实现的,所以线程调度最终还是由操作系统说了算。

Java 线程的状态切换

image

  • 新建
  • 运行
  • 无限期等待(Object::wait() Thread::join() LockSupport::park())
  • 有限期等待(Thread::sleep() Thread::join() Object::wait() LockSupport::parkNanos()LockSupport::parkUntil())
  • 阻塞
  • 结束

等待状态:处于这种状态的线程不会被分配处理器执行时间。
阻塞:线程被阻塞了,“阻塞状态”与“等待状态”的区别是“阻塞状态”在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。

线程安全与优化

Java 语言中的线程安全性

线程安全,将以多个线程之间存在共享数据访问为前提;如果不存在共享数据访问,那么就是线程安全的。

不可变对象一定是线程安全的。

Java语言中,如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用final关键字修饰
它就可以保证它是不可变的。如果共享数据是一个对象,由于Java语言目前暂时还没有提供值类型的
支持,那就需要对象自行保证其行为不会对其状态产生任何影响才行。

线程安全的实现

  1. 互斥同步

同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些,
当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量
(Mutex)和信号量(Semaphore)都是常见的互斥实现方式。因此在“互斥同步”这四个字里面,互斥
是因,同步是果;互斥是方法,同步是目的。

synchronized 关键字是最常见的互斥同步手段。synchronized 是一个可重入锁。

ReentrantLock与synchronized相比增加了一些高级功能,主要有以下三项:等待可中断、可实现公平锁及锁可以绑定多个条件。

互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步(Blocking Synchronization)。

  1. 非阻塞同步

·测试并设置(Test-and-Set);
·获取并增加(Fetch-and-Increment);
·交换(Swap);
·比较并交换(Compare-and-Swap,下文称CAS);
·加载链接/条件储存(Load-Linked/Store-Conditional,下文称LL/SC)。

CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V
表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合
A时,处理器才会用B更新V的值,否则它就不执行更新。但是,不管是否更新了V的值,都会返回V的
旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。

CAS操作无法避免 ABA 问题。如果需要解决ABA问题,则采用互斥同步即可。

  1. 无同步方案
  • 可重入代码:不依赖全局变量的代码。
  • 线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。

锁优化

高效并发是从JDK 5升级到JDK 6后一项重要的改进项,HotSpot虚拟机开发团队在这个版本上花费了大量的资源去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率。

自旋锁和适应性自旋

为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁

使用参数-XX:PreBlockSpin 可以设置自旋次数,默认10次。

适应性自旋:自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。

锁粗化

同步块的范围由细粒度优化为粗粒度。

轻量级锁

轻量级锁是 JDK 6时加入的新型锁机制。设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系
统互斥量产生的性能消耗。

对象头里的 mark word 是实现轻量级锁

image

在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即 Displaced Mark Word),这时候线程堆栈与对象头的状态如图:

image

然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。

如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。

偏向锁

偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。偏向锁也是 JDK 1.6 引入的。

假设当前虚拟机启用了偏向锁(启用参数-XX:+UseBiased Locking,这是自JDK 6 起 HotSpot 虚拟机的默认值),那么当锁对象第一次被线程获取的时候,
虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。
同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。
如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。

一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就按照上面介绍的轻量级锁那样去执行。
偏向锁、轻量级锁的状态转化及对象Mark Word的关系如图:
image

posted on 2022-04-04 22:05  不安分的黑娃  阅读(37)  评论(0编辑  收藏  举报