Featured image of post 读《深入理解java虚拟机2019版》有感

读《深入理解java虚拟机2019版》有感

非常nice的java虚拟机书籍

类加载器子系统

  • 类加载子系统负责从文件系统或者网络中加载class文件,class文件在文件的开头有特定的文件标识。

  • CLassLoader只负责classs文件的加载,至于它是否可以运行,则由Execution Engine(执行引擎)决定。

  • 加载的类信息存放于一块称为方法区的内存空间,除了类的信息外, 方法区中还会存放运行时常量池信息,可能还包含字符串字面量和数字常量(这部分常量信息是lass文件中常量池部分的内存映射)

类加载的过程

加载

  1. 通过一个类的全限定名获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时结构
  3. 在内存中生成一个代表这个类的class对象,作为方法区这个类的各种数据的访问入口

链接

验证
  • 目的在于确保class文件的字节流中包含的信息符合当前虚拟机的要求,保证被加载类的正确性,不会危害虚拟机自身的安全。
  • 主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证。
准备
  • 为类变量分配内存并且设置该类变量的默认初始值,即零值。
  • 这里不包含用final修饰的static,因为final在编译的时候就分配了,准备阶段会显式初始化。
  • 这里不会实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到java堆中。

此环境只给变量分配内存,和默认初始值,没赋值。

final 修饰的static会赋值。

解析
  • 常量池内的符号引用转换为直接引用的过程。
  • 事实上,解析操作往往会伴随着jvm在执行完初始化之后再执行。
  • 符号引用就是一组符号来描述所引用的目标,符号引用的字面量形式明确定义在(java虚拟机规范)的class文件格式中,直接引用就是直接指向目标的指针,相对偏移量或一个间接定位到目标的句柄。
  • 解析动作主要针对类或接口、字段 、类方法、接口方法、方法类型等。

初始化

  • 初始化阶段就是执行类构造器方法()的过程。
  • 此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
  • 构造器方法中指令按语句在源文件中出现的顺序执行。
  • ()不同于类的构造器。(关联:构造器是虚拟机视角下的())
  • 若该类具有父类,jvm会保证子类的()方法执行前,父类的()方法已经执行完毕。
  • 虚拟机会保证一个类的()方法在多线程下被同步加锁。

类加载器

启动类加载器(引导类加载器、Bootstrap ClassLoader)

  • 这个类加载器使用c/c++实现的,嵌套在JVM内部。

  • 它用来加载java的核心库(JAVA_HOME/jre/lib/rt.jar,resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类

  • 并不继承自java.lang.ClassLoader,没有父加载器

  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器

  • 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类

双亲委派机制

  • 原理
  1. 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行。
  2. 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器
  3. 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

沙箱安全机制

由于双委派机制,用户直接私自篡改java核心包下的类,(如自定义一个String类),这种保护机制叫沙箱安全机制。

运行时数据区

程序计数器(pc寄存器)

JVM中的程序计数器,它的命名来源于cpu的寄存器,寄存器存储指令相关的现场信息,cpu只有把数据转载到寄存器才能运行。

程序计数器不是广义上的物理寄存器,JVM中的程序计数器是对cpu中的寄存器的一种抽象模拟。

  • 作用

pc寄存器用来储存指向下一条指令的地址,也就是即将要执行的下一条指令,由执行引擎来读取下一条指令。

  • 它是一块很小的内存空间,几乎可以忽略不计,也是运行速度最快的存储空间。

  • 在JVM规范中,每个线程都有自己的程序计数器,是线程私有的,它的生命周期和线程的生命周期保持一致。

  • 任何时间一个线程都只有一个方法在执行,也就是当前方法,程序计数器会存储当前正在执行的指令方法的jvm指令地址,如果执行的是navtive方法,它其中就是未指定值( undefined)

  • 它是唯一一个jvm没有规定任何OOM的区域。

栈是运行时单位,而堆是存储的单位

栈解决程序的运行问题,(程序如何执行)或者如何处理数据,堆解决的是数据存储的问题,数据怎么放,放在哪儿。

  • 栈中 存放的东西

主管java的运行,保存方法的局部变量(八种基本数据类型的值,对象的引用地址),部分结果,并参与方法的调用和返回。

栈的特点

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
  • JVM直接对java的操作有两种:
    • 每个方法按执行,伴随着进栈(入栈,压栈)
    • 执行结果后的出栈操作
  • 对于栈来说不存在垃圾回收的问题。

java默认的栈大小是动态的(不固定但是不是无限大),但是可以手动设置大小,如果超出后会报栈溢出

  • -Xss设置栈大小

栈的存储单位

栈的基本单位是栈帧,在一个线程中,一个时间节点只有一个活动的栈帧。

栈帧的内部结构

  • 局部变量表

定义为一个数组,主要用于存储方法参数和定义在方法体内部的局部变量。

局部变量表所需大小是在编译期就确定下来的,并保存在方法的Code属性的maximun local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。

    • Slot(槽)

槽是局部变量表中的计量单位,32位以内的数据(byte,short,char,int)只占一个Slot槽位,64位类型的数据(long和double)占两个。

Slot是放在栈里的数组中,数据的大小是不会动态改变的,如果前面的槽位被释放,后面有新的slot可重新去用之前的空间,这就是slot的重复利用。

  • 操作数栈(表达式栈)

如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新pc寄存器中下一条需要执行的字节码指令。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。

java虚拟机的解释引擎是基于栈的执行引擎,其中栈指的就是操作数栈。

用来临时存储操作过程的中间结果。

  • 动态链接

用来记录方法区常量池中对象的地址。

关于静态链接和动态链接

当一个字节码文件被装载进jvm内部时,如果被调用的方法在编译期可知,且运行期保持不变,这种情况下,被调用的方法的符号引用转换为直接引用的过程叫做静态链接。

被调用的方法在编译期无法被确定下来,只能在程序的运行期间,被调用的方法的符号引用被转化为直接引用,这种转化过程具有动态性,就叫动态链接。

  • 方法返回地址
  • 一些附加信息

关于操作数栈和局部变量表的举例和说明

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public static void main(String[] args) {
    int i = 0;
    int x = 0;
    while (i<10){
        x=x++;
        i++;
    }
    //结果是0
    System.out.println(x);
}
  • 局部变量表

一个类中的变量其实都是局部变量表中的一个值。

  • 操作数栈

用来临时存储操作过程的中间结果,就是一个值变化过程的临时存储。

操作数栈和局部变量表的联动

以x=x++为例:

  • 读取

首先从局部变量表中读取load对映hash槽里读取x的值,此时操作数栈中为0,局部变量表也为0

  • x++

x++操作是在局部变量表中进行的,所以局部变量表中的值加1,此时操作数栈中为0,局部变量表为0

  • 赋值

等于操作是把操作数栈中的值赋值到对应的局部变量表中,操作数栈的值为0,局部变量表中的为1,用操作数栈的0来覆盖局部变量表中的1,所以x重新变为0。

类加载过程

loading 加载

  • 父加载器

父加载器不是“类加载器的加载器”,也不是类加载器的父类加载器。

父加载器只当前类加载器的parent对象指向的加载器。

ClassLoader的源码

findInCache-> parentLoadClass -> findClass()

自定义类加载器

  1. extends ClassLoader
  2. overwrite findClass()-》defineClass(bytep[]->class clazz)

混合执行,编译执行,解释执行

linking

校验

  1. 验证文件是否符合jvm规定

赋值(默认值)

  1. 静态成员变量赋默认值。

解析

  1. 将类。方法,属性等符号引用解析为直接引用,指向内存的详细地址。

赋初始值

总结

load- 默认值- 初始值

new -申请内存- 默认值-初始值

一些常用的分析

静态绑定和动态绑定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public Demo3_9(){}

private void test1(){}

private final void test2(){}

public void test3(){}

public static void test4(){}

public static void main(String[] args) {
    Demo3_9 demo3_9 = new Demo3_9();
    demo3_9.test1();
    demo3_9.test2();
    demo3_9.test3();
    Demo3_9.test4();
}

test1,test2,test4都是可以直接确定要调用哪个方法,称为静态绑定。invokespecial(私有方法) invokestatic(静态方法)

test3是public的方法,可能会被重写,只有在运行的过程当中才能确定具体调用的哪个方法,被称为动态绑定。invokevirtual

静态绑定的运行效率要远远高于动态绑定的方法。

多态的原理

虚方法表是在链接阶段生成的

  • 虚方法表(vtable)

动态绑定的方法会存在虚方法表中。静态方法,私有方法,final修饰的方法都不在虚方法表中。虚方法表在一个类的二进制文件的最后一行。

虚方法中会记录此类中的方法具体是调用的哪个父类或自己的具体方法。

finally

finally会捕获try中的异常,catch中的异常,会普通代码里的异常 ,拷贝三份一样的代码,来确保finally中的代码一定会被执行。

如果finally中有return,finally中的return会在代码中的return之后执行。finally中的return不会抛异常。

对于反射的优化

  • sun.reflect.noInfloation可以用来禁用膨胀(直接生成GeneratedMethodAccessorl,但首次生成比较耗时,如果仅反射调用一次,不划算)
  • sun.reflect.inflationThreshold可以修改膨胀阀值

JMM

  • jvm分区

padding(缓存一次读取64个字节的数据)

三级缓存

  • 合并写(寄存器只能处理四个字节)

每四个字节同时修改。

零拷贝

jvm直接去访问os管理的内存。不需要不复制到jvm内存中,就是直接内存的使用。

程序计数器

  • 线程私有的

记录下一条指令的执行地址

程序计数器不会存在内存溢出问题。

  • 线程私有的

线程运行需要的内存空间

本地方法栈

  • 线程私有的

java代码调用native方法来调用c/c++的代码运行所用的空间。

方法区

线程共享的区域

  • 1.8之前

字符串常量存储在永久区(堆内存)

FGC不会清理

  • 1.8之后(元数据区)(系统本地内存)

字符串常量位于堆

会触发FGC清理

方法区内存结构图

方法区内存溢出问题

元空间默认使用的是系统内存,一般不会发生内存溢出问题,

当类的加载器创建的类过多时,就会导致方法区内存溢出。

运行时常量池

运行时常量池,常量池是*.class 文件中的,当该类被加载,它的常量池

  • 类基本信息
  • 常量池

给指令提一些常量符合,让执行器根据这些符号来找到要去执行哪些方法。

常量池,就是一张表,虚拟机指令根据这张表常量池表找到执行的类名,方法名,参数类型,字面量等信息

  • 类方法定义
  • 虚拟机指令

stringTable(字符串池)

StringTable的底层实现类似hashtable,是hash表。

常量池中的信息都会被加载到运行时常量池中,这时这些都是常量池中的符号,还没有变为java字符串对象。

所有字符串都是懒惰加载的,只有在使用时才会创建,字符串创建前先要去字符串池中判断当前字符串是否存在,如果不存在就创建。

  • 常量池中的字符串只是符号,第一次用到时才变为对象。
  • 利用串池的机制,来避免重复创建字符串对象。
  • 字符串变量拼接的原理是StringBuilder。(1.8)
  • 字符串常量拼接的原理是编译器优化。
  • 可以使用intern方法,主动将串池中还没有的字符串对象放串池中。
StringTable的垃圾回收

当内存紧张时,且放入stringTable的字符串没有引用时,也会发生GC现象。

stringTable的性能调优

–XX StringTableSize=20000 修改stringTable单个桶大小,当stringtable中的字符串常量非常多时,可以调整桶大小减少hash冲突,来提升性能。

让字符串入池可以极大的减少堆内存的占用。

直接内存

直接内存属于操作系统内存。

  • 常见于NIO操作时,用于数据缓冲区
  • 分配回收成本较高,但读写性能高
  • 不受JVM内存回收管理

direct Memory 大文件拷贝

byteBuffer可以使用直接内存来完成文件NIO操作,它的大文件拷贝效率要比传统的FileInputStream(IO流)高效很多。

  • 传统IO拷贝操作

文件要先被读取到系统内存中,后被拷贝到JVM堆内存中。

cpu从java用户态先切换到内核态,再切换回用户态来完成拷贝。

  • 使用直接内存来完成大文件拷贝

会在系统内存中生成一块名为direct memory的内存空间,这块空间java可以直接访问。

cpu从用户态切换到内核态,将文件读取到此内存中,然后切换回用户态直接在这块内存进行操作。

比传统方式少了一次缓冲区复制操作。

直接内存释放原理

  • 通过unsafe分配直接内存和释放内存
1
2
3
4
5
6
7
long base = unsafe.allocateMemory(_1Gb);
//分配内存
unsafe.setMemory(base,_1Gb,(byte) 0);
System.in.read();
//释放内存
unsafe.freeMemory(base);
System.in.read()

gc

这些不会被垃圾回收

四种引用

软引用和虚引用在被 gc时,要进入引用队列然后被gc回收所占用的空间。

只有所有GC Roots对象不通过(强引用)引用该对象,该对象才能被 垃圾回收。

只要能通过gc root找到,就不会被垃圾回收。

只要没有被强引用引用到,在gc时就可能会被回收

普通gc后,如果内存当内存不足时gc

只要没有被强引用引用到,在gc时就可能会被回收

只要发生gc就会被回收。

必须配合引用对象使用,主要配合ByteBuffer使用,被引用对象回收时,会将虚引用入队,由Reference Handler线程调用虚引用相关方法释放直接内存。

虚引用被回收时,就会被加入到引用队列中,当此引用不再被强引用引用时,会调用unsafe中的freeMemory方法回收。

  • 终结器引用

终结方法被重写后,重写的终结方法就可以被gc回收。

无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize方法,第二次GC时才能回收被引用对象。

一些常用参数

  • 堆初始大小

-Xms

  • 堆最大大小

-Xmx或-XX:MaxHeapSize=size

  • 新生代大小

-Xnm或(-XX:NewSize=size + -XX:MaxNewSize=size)

  • 幸存区比例(动态)

-XX:InitialSurvivorRatio=ratio和-XX:-UseAdptiveSizePolicy

  • 幸存区比例

-XX:SurvivorRatio=ratio

  • 晋升阀值

-XX:MaxTenuringThreshold=threshold

  • 晋升详情

-XX:+PrintTemuringDistributtion

  • GC详情

-XX:+PrintGCDetails -verbose:gc

  • FullGC 前MinorGC

-XX:+ScavengeBeforeFullGC

UseGCOverheadLimit

当打开此开关后,如果gc花费98%的时间,也只能回收不到2%的堆空间时,就不再发生gc而是报出此错。

-xx: +DisableExplicitGC

禁用显示的垃圾回收,让代码中的System.gc()无效。

system.gc() 是一种Full GC

gc的常用算法

  • 标记清除

存活对象比较多的话效率高。

需要两遍扫描,效率低。容易 产生碎片。

  • 拷贝

把内存一分为二,有用的拷贝,然后清除一边内存。

适合存活对象少的,只扫描一次,效率高。

需要移动对象,对象的引用也需要调整,

  • 标记压缩

清理的同时压缩调整内存位置。

不会产生碎片,方便分配。

需要扫描两遍,需要移动对象,效率低。

常见的垃圾回收器

  • Serial

单线程回收器

回收时所有线程都停止,单线程清除后继续。

  • PS(默认的回收器)

回收时所有线程停止,多线程清理后继续。

  • ParNew

回收时所有线程停止,可以配合CMS使用。

  • 垃圾回收器跟内存大小的关系

    • serial 几十兆
    • PS 上百兆-几个G(JDK默认的垃圾回收器)
    • CMS 20G
    • G1 上百G
    • ZGC 4T - 16T(JDK13)
  • 常见的垃圾回收器的组合参数设定(1.8)

  • 内存泄露

有废对象占据内存空间,这块空间不被回收也无法使用,

  • 内存溢出

不断地有数据占据内存,最后把内存空间占满。

G1和其他的垃圾回收器的区别

  • G1之前的垃圾回收器,有逻辑上的分带,还有物理上的分带。
  • G1只有逻辑上的分带,没有物理上的分带。
  • ZGC没有逻辑分带和物理分带,只有内存。

JVM调优

调优案例

  • 系统cpu经常100%。如何调优?(面试高频)

cpu 100%那么一定是有线程在占用系统资源。

  1. 找出哪个进程cpu高(top)
  2. 该进程中的哪个线程cpu占用高(top -Hp)
  3. 导出该线程的堆栈(jstack)
  4. 查找哪个方法(栈帧)的消耗时间(jstack)
  5. 工作线程占比高|垃圾回收线程占比高

jvm调优经验

jps 定位具体java进程

jstack 定位线程状态,重点关注 WAITING BOCKED

加入有一个进程中100个线程,很多线程都在waiting on,一定要找到是哪个线程持有这把锁。

jinfo +线程名:显示进程详细信息。

jstat -gc 线程号: 显示gc信息。(不好看)

  • 利用JMX实现的图形化界面工具

利用 JMX会消耗服务器性能,还挺大。

jconsole :jdk自带的可视化工具。

jvisualvm: 新的可视化工具(JDK自带)

jprofiler最好用的图形化界面工具。(收费)

  • 如何定位OOM问题

cmdline: arthas

jmap -histo 1736 | head -20

显示前20行的占用cpu的对象。

池线上系统,内存特别大,jmap转dump执行期间会对进程产生很大的影响,甚至卡顿,(电商系统不适合)

  1. 设定参数HeapDump,OOM时会自动产生堆转储文件
  2. 很多服务器备份(高可用),停一台服务器对其他的不影响。

在线分析

  • arthas:阿里的在线jvm分析工具。

heapdump导出堆内存的情况。(也会影响性嫩)

分析dump

  • jhat(jdk自带的dump分析工具)

默认是多大dump文件用多大的内存去分析,分析时最好指定最大内存。

分析完成后它会返回一个port端口,我们可以通过远程连接这个端口来分析dump中的数据。

G1(JDK9的默认回收器)

CMS(老年带回收器)

  • concurrent mark sweep

垃圾回收的线程和工作线程同时运行。

  • CMS的缺点

当老年带满时(内存条碎片过多),会调用老年带单线程回收器来清理。(FGC)

  • CMS
  1. 初始标记:通过GCroot找到根对象。(STW的)

  2. 并发标记:不影响主线程的运行,在程序的运行当中来标记要回收的垃圾。

  3. 重新标记:假如之前并发标记的垃圾被又被root重新连接了,(又不能回收)在STW的情况下重新标记一遍。

  4. 并发清理:不影响程序运行的情况下清理。

G1(垃圾优先)

G1是一种服务端应用使用的垃圾回收器,目标是用在多核、大内存的机器上,它在大多数情况下可以实现指定的GC暂停时间,同时还能保持较高的吞吐量。

特点

  • 并发收集
  • 压缩空闲空间不会延长GC的暂停时间
  • 更易预测的GC暂停时间
  • 适用不需要实现很高的吞吐量的场景

把内存分成多个不同的分区,每个分区都可能是年轻代也可能是老年代。同一时间一个分区只能属于一个代。

三色标记算法

  • 白色:未被标记的对象
  • 灰色:自身被标记,成员变量未被标记
  • 黑色:自身和成员变量均已标记完成,

CMS解决三色标记问题

CMS使用增量更新

G1使用SATB

G1的优化

JDK 8u20 字符串去重
  • 优点:节省大量内存
  • 缺点:略微多占用的cpu时间,新生代回收时间略微增加。

-XX:+UseStringDeduplication

1
2
String s1 = new String("hello");
String s2 = new String("hello");
  • 将所有新分配的字符放入一个队列
  • 当新生代回收时,G1并发检查是否由字符串重复
  • 如果他们值一样,让他们引用同一个char[]
  • 注意,与String.intern()不一样
    • String.intern()关注的是字符串对
    • 而字符串去重关注的是char[]
    • 在JVM内部,使用了不同的字符串表

JDK 8u40并发标记类卸载

所有对象都经过并发标记后,就能知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它所加载的所有类。

-XX:+ClassUnloadingWithConcurrentMark默认启用。

JDK 8 u60回收巨型对象

  • 一个对象大于region的一半时,称之为巨型对象
  • G1不会对巨型对象进行拷贝
  • 巨型对象回收时会被优先考虑
  • G1会跟踪老年代所有的incoming引用,这样老年代incoming引用为0的巨型对象就可以在新生代垃圾回收时处理掉

JDK9 并发标记起始时间调整

  • 并发标记必须在堆空间占满前完成,否则退化为FullGC
  • JDK9之前需使用-XX:InitiatingHeapOccupancyPercent
  • JDK9可以动态调整
    • -XX:InitiatingHeapOccupancyPercent用来设置初始值
    • 进行数据采样并动态调整
    • 总会添加一个安全的空档空间

读《深入理解java虚拟机(第3版)》有感

自动内存管理

java内存区域和内存溢出异常

运行时数据区

  • 程序计数器

字节码解释器通过改变程序计数器的值来选取需要执行的下一条指令。

由于JVM的多线程是通过线程轮流切换来实现的,同一时刻,一个CPU的一个内核,只能执行一条线程中的指令。每条线程都需要有一个独立的程序计数器,每个程序计数器的区域都是线程私有的。

如果一个线程正在运行,执行的为java方法,计数器记录的为正在执行的虚拟机字节码指令的地址。

如果正在执行的是本地方法(native),计数器的值则为空。

程序计数器是JVM中唯一没有任何内存溢出的区域

  • java虚拟机栈

也是线程私有的,生命周期与线程相同。

java每个方法被执行的时候,都会创建一个栈帧用于存储,局部变量表,操作数栈,动态连接,方法出口等。每个方法被调用完毕,就对应着一个栈帧的入栈和出栈。

  1. 局部变量表:存放了,基本数据类型,对象的引用,和returnAddress类型。

数据类型在局部变量表中以sort(槽)来存放,64位长度的long和double类型占用两个槽,其余只占用一个槽。

  • 本地方法栈

虚拟机执行native方法时,需要把本地方法入栈。

  • 方法区

各个线程共享的区域,存储被虚拟机加载类型信息,常量,静态变量,即时编译器编译后的代码缓存等信息。

  • 运行时常量池

用于存储编译期生成的各种字面量和符号引用。

HotSpot

对象的创建

  • 指针碰撞

如果内存的空间是规整的(已使用的内存,是连续的,未使用的内存也是连续的)

在创建新对象时,指针只需要移动所要创建的对象大小的内存就好。

  • 空闲列表

如果java堆中的内存不是规整的,已被使用的内存和未被使用的内存相互交错在一起

虚拟机会维护一个列表,记录哪些内存块是可用的,在列表中找到一块空间足够大的内存空间给对象实例,并在列表上更新实例。

是选择指针碰撞还是空间列表的方式,取决于所采用的垃圾回收器是否有空间压缩整理的能力。

Serial、ParNew等带有压缩整理过程的收集器,系统采用的分配算法是指针碰撞。

CMS这种基于Sweep算法的收集器,使用空闲列表的方式来分配内存。

  • 并发安全问题

如果在并发的情况下,两个线程同时分配内存。可以有两种解决方案:

  1. 对分配内存的动作进行同步处理,虚拟机是采用CAS配上失败重试的方式来保证更新操作的原子性。

  2. TLAB:把内存分配的动作按照线程,按照线程划分成不同的内存进行。

    当每个线程预分配的空间(本地线程分配缓冲)不够时,就采用1中同步的方式给这个线程分配新的缓冲区。

虚拟机是否采用TLAB:通过-XX:+/-UseTLAB参数来 设定。

实际CMS使用TLAB分配对象的速度更快,因为这样可以减少同步方法。

垃圾收集器和内存分配策略

确认对象需要被回收

引用计数算法

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

但是主流的JVM都没有使用引用计数算法来管理内存,原因是引用计数器无法处理很多意外情况。例如循环依赖问题。

可达性分析算法

从GCroot作为起始节点,通过引用关系向下搜索,搜索过的路径叫做引用链。如果某个对象没有任何引用链,就证明此对象不再被使用。

  • GCROOT
  1. 在栈中的引用对象,每个线程使用到的参数,局部变量,临时变量等。
  2. 在方法区中的静态变量。
  3. 在方法区中常量引用的对象,如字符串池中的对象。
  4. native方法引用的对象。
  5. 虚拟机系统引用的对象,如基本数据类型的class对象,异常对象,系统的类加载器等。
  6. 所有被同步锁((synchronized关键字)持有的对象。
  • 并发情况的可达性分析算法

·白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是 白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

·黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代 表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对 象不可能直接(不经过灰色对象)指向某个白色对象。

·灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

垃圾回收算法

  • 标记清除算法:最基本的垃圾回收算法,可用于新生代和老年带。
  • 标记复制算法:常用于新生代,目前主流的垃圾回收期新生代都是采用此算法。
  • 标记整理算法:老年带才会用的垃圾回收算法,性能消耗很高。

标记整理和标记清除算法都需要停掉用户线程来处理(STW)

垃圾回收器

  • Serial收集器

  • ParNew收集器

目前只有ParNew和Serial才能和CMS配合使用。

  • CMS收集器

  • Garbage First收集器

面对堆内存组成回收集来进行回收,不再管他是哪个分带。

把连续的java堆划分成大小相同的内存区域。

回收过程:

  1. 初始标记:只标记和GCROOT有直接关联的对象。(没有停顿)
  2. 并发标记:从GCroot开始,对堆中的对象进行可达性分析。(与用户进程同步运行)
  3. 最终标记:对用户线程进行暂停,处理2步遗留的有变动的标记。
  4. 筛选回收:暂停用户线程,按照每个内存区域的价值,来决定回收哪个区域的内存。(把回收内存中需要留下的数据复制到新的地方,然后清理掉整个区域的数据)。

G1不再追求能够回收所有的垃圾,只要回收速度能追的上使用创建的速度就可以。所以一次不会回收掉全部的垃圾。

低延迟垃圾收集器

Shenandoah(谢南多厄)收集器

  1. 初始标记:标记与GCroot直接关联的对象。此阶段STW
  2. 并发标记:遍历对象图,标记出全部可达对象。与用户线程一起运行。
  3. 最终标记:标记并发标记中间变动的对象。小段的STW
  4. 并发清理:清理整个区域一个存活对象都没有的区域。与用户线程一起
  5. 并发回收:把需要回收的内存区域中存活的对象,复制到其他区域。利用读屏障和转发指针,实现此操作和用户线程一起运行。(G1这一步需要暂停用户线程)
  6. 初始引用更新:把堆中所有指向旧对象地址的指针全部指向新的对象(只是统计出哪些对象指针需要被更新)。会有短暂的STW。
  7. 并发引用更新:并发的更新上面统计的引用。与用户线程一起运行。
  8. 最终引用更新:修正GCROOT中的引用。需要STW
  9. 并发清理:清理需要被清理的内存块。与用户线程一起。

ZGC

目前最强垃圾回收器,回收的停顿时间只与GCROOT的大小有关,与堆内存无关。领先其他回收器一个数量级的差距。吞吐量第一。

引入了染色指针的概念,把少量的信息存在了指针上。但是如果内存超过4TB将无法使用此技术。而且只能在LINUX环境下运行。

  1. 并发标记(Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的 阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记(尽管ZGC中的名字不叫这些)的 短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。与G1、Shenandoah不同的是,ZGC 的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志 位。
  2. 并发预备重新分配:扫描整个堆内存区域,把所有存活的对象都记录下来。
  3. 并发重分配:把被标记的对象都复制到新的内存块中,在旧的内存块中为这些对象建立一个转发表。如果用户线程这个时候并发访问了就的对象,会被内存屏障截取,把这个对象的引用修正到新的区域。(指针的自愈)。
  4. 并发重映射:如果某个旧对象一直没被用户线程访问,就在下一次垃圾回收的并发标记阶段里把这些对象的引用指向新的地址。

一旦某个内存块中的引用全部指向了新的地址。此转发表被释放,内存区域也被回收。

虚拟机性能监控,故障处理

  • jps

可以显示目前运行的java进程

jps还可以通过RMI协议查询远程的RMI虚拟机进程

  • jstat

监视虚拟机各种运行状态的命令行工具。

显示虚拟当前的运行状态,是在运行期查看虚拟当前状态的工具。

  • jinfo

实时的查看和修改虚拟机的各项参数。

  • jmap

用于生成堆内存快照文件。dump文件。

  • jhat(不推荐使用)

与jmap配合使用,用于分析jmap生成的dump文件。

非常耗费性能,而且分析的很简陋。

  • jstack

用于生成虚拟机当前时刻的线程快照。

利用java.lang.Thread类中的getAllStackTraces()方法可以获取所有线程的stack对象,可以实现jstack大部分的功能。

  • 个人用jstack分析分析的线程状态: 运行:RUNNABLE,备注:runnable 在等待获取锁的阻塞:BLOCKED,备注:waiting for monitor entry 调用sleep方法:TIME_WAITING,备注:waiting on condition 调用wait方法:WAITING,备注:in Object.wait()

调优案例分析和实战

大内存硬件上的程序部署策略

一个文档网站,每次操作都会把文档整个读到内存中来。由于文件内容很大,读取到堆内存中就直接到了老年代,不会在Minor GC中被回收。

之前服务器是32位操作系统,只给程序分配了1.5G堆内存。当时用户感受到缓慢但不至于等十几秒。

后来升级了硬件,64位操作系统,程序分配了12G堆内存,垃圾回收器使用了默认的吞吐量优先收集器,由于文档都直接进入了老年代,内存很快就达到阈值,每几分钟就要触发一次full GC,需要等十几秒。

升级了硬件,加大内存条,程序运行更慢,用户体验更差了。

  • 解决方案
  1. 通过一个单独的JVM虚拟机,来管理大量的java堆内存。
  2. 同时使用若干个java虚拟机,建立逻辑集群来利用硬件资源。

方案1的问题:

方案1需要使用G1,谢南多厄等注重延迟的垃圾回收器。这些垃圾回收器并不成熟,而且光垃圾回收器本身就非常耗费性能。

单个JVM管理大堆内存,必须在64位的操作系统中运行。

由于压缩指针的关系,相同的程序,在32位系统中,运行速度和占用内存大小,都要优于64位操作系统。

方案2的问题:

节点竞争系统资源,磁盘资源,各节点如果同时写入某个文件,容易产生IO异常。

如果单个服务器上大量的使用HashMap等本地缓存,每个逻辑JVM节点上都有一份相同的缓存,容易造成内存的浪费。(所以小容量的JVM内存建议使用 集中式缓存)

  • 大结局

最后的部署方案并没有选择升级JDK版本,而 是调整为建立5个32位JDK的逻辑集群,每个进程按2GB内存计算(其中堆固定为1.5GB),占用了 10GB内存。另外建立一个Apache服务作为前端均衡代理作为访问门户。考虑到用户对响应速度比较关心,并且文档服务的主要压力集中在磁盘和内存访问,处理器资源敏感度较低,因此改为CMS收集器 进行垃圾回收。部署方式调整后,服务再没有出现长时间停顿,速度比起硬件升级前有较大提升。

  • 堆外内存导致的内存溢出问题

当堆外内存被不断使用时,由于JVM默认的GC监控没有监控堆外内存的使用量,只在乎堆内存被使用到一定比例时才触发GC。只有FULL GC才会顺手清理一下堆外内存。

严格控制好堆外内存。

由于数据结构问题导致的GC时间变长

程序中有一个巨大的map在新生代,如果触发了Minor GC,而map中的数据也不能被回收。在调用复制算法的时候,就会到导致大量的信息被复制,让GC时间变长。

  • 解决方案

可以禁用缓冲区,让此对象直接被复制到老年代。等到老年代GC的时候再去清理它。

类加载机制

如果是动态链接的情况下,解析会在验证之前

高效并发

java内存模型和线程

java内存交互操作

  • lock(锁定):作用于主内存的变量,把一个变量标识成线程独占的状态。
  • unlock(解锁):作用于主内存的变量,把一个处于锁定状态的对象释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,把一个变量的值从主内存传递到工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,把一个从执行引擎接收的值赋值给工作内存的变量,每当虚拟机遇到一个变量赋值的字节码指令时,都会执行此操作。
  • store(存储):作用于工作内存的变量,把工作内存中一个变量的值传递到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。
  1. 一个变量在同一时刻,只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  2. 如果一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行lock或assign操作以初始化变量的值。
  3. 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  4. 一个变量执行unlock操作之前,必须先把此变量同步回主内存,(执行store、write操作)

对于volatile型变量的特殊规则

java虚拟机提供的最轻量级的同步机制。

  • 案例一

一个用volatile修饰的整型变量,多个线程同时调用++操作来修改此变量。

最终结果并不正确。

原因:volatile只能保证元素的可见性,只有在读取此变量时,可以保证此值的正确性,在执行++操作时,可以其他线程已经完成了++并赋值,导致当前线程给此值赋错值。

所以volatile还是要加synchronized或者JUC相关的锁,来保证操作的原子性。

  • volatile是怎么禁止指令重排序的

指令重排序指,在单个线程内,指令一操作先后顺序对指令二没有影响,cpu会随机的先执行指令1或者指令2

被volatile修饰的变量和普通的变量区别在于,被volatile修饰的变量,在读操作和写操作的时候,都会加lock前缀指令(内存屏障)。

  • volatile的内存屏障的作用

volatile的内存屏障加在此变量的每次读取(load)的前后,和write的前后。

加了内存屏障之后,内存屏障前的指令不会在内存屏障后面运行,内存屏障后的指令不会在内存屏障前运行。

且加了内存屏障的数据,如果是写操作,就会强制把此数据从工作内存写回主内存。让其他的工作内存强制失效,重新从主内存读取数据。

  • volatile的lock前缀的方式

lock前缀不是一种内存屏障,但它能完成类似内存屏障的功能。

lock先对总线/缓存加锁,然后执行后面的指令,最后释放锁后,把高速缓存中的脏数据全部刷新回主内存。

如果lock锁住总线的时候,其他CPU的读写请求会全部被阻塞,知道锁释放。lock后的写操作会让其他CPU相关cache失效,从而从新的内存中读取最新的数据,这个是通过缓存一致性协议做的。

性能:volatile的读操作的性能消耗与普通变量几乎没有什么区别,但写操作可能会慢上一些。(因为lock前缀禁用了CPU的指令重排序)但也比锁的开销低。

  • 再来说说++的问题

这个和++的特性有关,++操作分

  1. 获取i
  2. i自增
  3. 回写i

在执行第1步操作时,volatile生效,保证两条线程一定能拿到最新的i

2操作时,有可能线程A自增了i并回写,但线程B此时已经拿到了i,不会再重新读取A回写的i,因此会产生问题。

虽然volatile会让B线程的i失效,但B线程已经走到了2,不存在读取i的操作,所以会存在问题。

这本身是++指令的问题。

Happens-Before(先行发生规则)

先行发生是java内存模型中定义的两项操作之间的编序关系,操作A先行于操作B,就是说发生操作B之前,操作A产生的影响能被操作B观察到。影响包括修改了内存中共享变量的值,发送了消息,调用了方法等。

几种java已经默认实现了的先行发生规则:

  • 管道锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。(必须是同一个锁,后面只时间上的先后)
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作。(后面指时间上的先后)
  • 线程启动规则:Thread的start方法先行发生于此线程的每一个动作。
  • 线程终止规则:线程中的所有操作都先行发生于此线程的终止检测,我们可以通过Thread.join()方法,Thread::isAlive()的返回值,检测线程是否已经终止执行。
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。
  • 对象终结规则:一个对象的初始化完成,先行发生于它的finalize()方法的开始。

一个普通的set,get方法。两个线程操作同一个对象。

A线程时间上先调用set(1)

B线程时间上后调用get方法

问B线程返回值是多少?

由于上面的操作完全没有遵守先行发生规则,所以虽然时间上A操作先于B,但无法判断B线程get方法的返回值,换句话说,这里的操作不是线程安全的。

解决方案:

把get和set方法都用synchronized修饰,或者把此字段值用volatile修饰,这样可以实现先行发生关系。

时间先后顺序与先行发生原则之间基本没有因果关系,所以我们衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准。

java与线程

线程的实现

  • 操作系统的线程

在一个操作系统中,一个线程相当于一个轻量级进程,对应一个内核核心。

每个线程的操作都是由调度器来统一调度。线程切换需要消耗很大的资源,需要从内核态,切换为系统态,再由调度器来统一分配。

  • 用户线程

完全由用户态统一模拟的线程,利用代码,实现了轻量级进程的大部分操作。

这样的代码设计起来复杂,而且有些问题是在用户态下无法解决的问题。(如果一个用户虚拟线程阻塞,则整个进程都阻塞)。

  • 混合实现

线程的创建,切换,析构等操作由用户态模拟实现,系统内核用来处理处理的映射,用户线程的调用由内核调度器来完成。(这样可以大大降低整个进程被完全阻塞的风险)

  • java线程的实现

jdk1.3之前,主流都是使用一种叫“绿色线程”的虚拟线程实现。

之后采用轻量级进程来实现线程,线程的大部分操作都是由操作系统统一来处理的。

jdk18开始,java又支持了虚拟线程的新实现。

java线程调度

协程

  • 内核线程调度切换为什么成本高?

主要来自于用户态与核心态之间的状态转换。

如果发生了状态转换,操作系统需要把当前线程需要的所有上下文对象保存起来,这些保存动作会涉及到大量设变之间的拷贝。成本极高。

  • 协程的实现

由用户自己模拟多线程,自己来维护线程间切换时保存上下文对象的操作。恢复操作也由用户态自己模拟。

  • 协程的优势

轻量级,一个协程占用内存非常小,java线程池中的线程如果达到两百时,就已经到达瓶颈。

协程可以达到几十万的并存的协程。

  • 缺点

如果遇到synchronize关键字,还是会把整个线程全部挂起。

纤程

java的loom项目

在java虚拟机里,建立了两个并存的java虚拟机实现,可以在程序中同时使用。新模型和旧模型同时使用。

新模型被分为两部分

  • 执行过程

用于维护执行现场,保护、恢复上下文。

  • 调度器

编排所有要执行的代码和顺序。默认的调度器实现就是jdk1.7加入的ForkJoinPool

线程安全和锁优化

  • 绝对线程安全

不管运行环境如何,调用者不需要任何额外的同步措施,调用这个对象都可以得到正确的答案。

vector的获取和修改操作都是同步的。但是在多线程环境下,还是可能出现问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private static Vector<Integer> vector=new Vector<>();

    public static void main(String[] args) throws InterruptedException {
        while (true){
            for(int i=0;i<10;i++){
                vector.add(i);
            }

            Thread removeThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    //要想安全必须在这一步锁住整个vector
                    for (int i = 0; i < vector.size(); i++) {
                        vector.remove(i);
                    }
                }
            });

            Thread printThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    //要想安全必须在这一步锁住整个vector
                    for (int i = 0; i < vector.size(); i++) {
                        System.out.println(vector.get(i));
                    }
                }
            });
            removeThread.start();
            printThread.start();
            //不要同时产生过多的线程
            while (Thread.activeCount()>20);
        }

    }

如何解决vector的线程安全问题?

如果让想让vector达到完全的线程安全,需要维护一组一致性的快照访问(类似于mysql),每个对其中元素进行改动的操作都要产生新的快照。这样需要付出极大的维护成本。

线程安全的实现方法

  1. 互斥同步

共享数据在同一时刻。只能被一条(或一些,当使用信号量的时候)线程使用。

synchronize:

详见JUC相关的笔记

lock接口的各种锁实现

  1. 非阻塞同步

先不管是否存在线程竞争问题,先去做,做完后检查,如果没有被更改,就提交操作。

CAS

如果发生ABA问题,其实采用加锁的同步方案,要比加版本号的方案更好。

  1. 无同步方案

可重入代码(纯代码):

可以在代码执行的任何时刻中断它,转而去执行新代码(也可以是自己),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果造成影响。如:Rust。

所有可重入代码都是线程安全的,并不是所有线程安全的代码都是可重入代码。

线程本地存储:

把当前线程需要操作的数据,只保存在一个线程中独有,其他线程无法获取和改变这个变量。ThreadLocal。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计