✨你好啊,我是“ 罗师傅”,是一名程序猿哦。
🌍主页链接:楚门的世界 - 一个热爱学习和运动的程序猿
☀️博文主更方向为:分享自己的快乐 briup-jp3-ing
❤️一个“不想让我曾没有做好的也成为你的遗憾”的博主。
💪很高兴与你相遇,一起加油!

前言

目标:JVM、JDK源码、高并发、MySql优化

虚拟机概述

发展历程

Java 往事

版本迭代

两种JDK

JVM体系

  • JDK(Java Development Kit)是Java语言的软件开发工具包,也是整个Java开发的核心,它包含了JRE和开发工具包
  • JRE(Java Runtime Environment), Java运行环境,包含了JVM和Java的核心类库(Java API)
  • JVM(Java virtual Machine),Java虚拟机,它是运行在操作系统之上的,它与硬件没有直接的交互

总结:JDK是开发人民的工具包,它包含了Java的运行环境和虚拟机,而一次编到处运行就是基于JVM

各种虚拟机

清单

  • Sun Classic VM

查看

JVM整体架构

Java运行过程

  • 源码编译:通过Java源码编译器将Java代码编译成JVM字节码(.class文件)
  • 类加载:通过ClassLoader及其子类来完成JVM的类加载(.class文件—>加载—>方法区中)
  • 类执行:字节码被装入内存,进入JVM虚拟机,被解释器解释执行

JVM模型

  • 类加载子系统

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

  • 运行时数据区

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。

这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

  • 执行引擎

执行引擎用于执行JVM字节码指令,主要有两种方式,解释执行和编译执行。

区别在于,解释执行是在执行时翻译成虚拟机指令执行,而编译执行是在执行之前先进行编译再执行。

解释执行启动快,执行效率低;编译执行,启动慢,执行效率高。

垃圾回收器自动管理运行数据区的内存,将无用的内存占用进行清除,释放内存资源。

  • 本地方法库、本地库接口

在JDK的底层,有一些实现需要调用本地方法完成(使用C或C++写的方法),就是通过本地库接口调用 的,比如System.currentTimeMillis()方法。

1
public static native long currentTimeMillis(); 

类文件结构

测试案例

源代码

1
2
3
4
5
6
7
8
9
10
package com.briup.classfile;

public class ClassStruct {
private static String name = "JVM";
private static final int age = 18;

public static void main(String[] args) {
System.out.println("Hello " + name);
}
}

编译

1
javac ClassStruct.java

字节码结构

二进制概览

1)插件使用

本人使用的是notepad++ 插件 Hex Editor,当然你也可以使用vsCode 中的Hex Editor这个插件

  • vsCode
  • notepad++

查看字节码

2)class文件时一个二进制文件,转换后以16进制展示,实际上class文件就是一张表,它由以下数据项构成,这些数据项从头到尾严格按照以下顺序排列:

class文件只有两种数据类型:无符号数和表

魔数与版本

  • 魔数

开头的4个字节表示的时魔数, CAFEBABA=咖啡宝宝

魔数就是要来区分文件类型的一种标志,一般都是用文件的前几个字节来表示

  • 版本号

紧跟着魔数后面的4位是版本号,同样也是4个字节,其中前2个字节表示 副版本号 ,后2个字节表示 主版本号

在开发中,有时会遇到类似Unsupported major.minor version 51.0的错误,一般情况下都是JDK版本不匹 配造成的。 虽然JDK在执行代码时基本上向下兼容,但开发环境和服务器环境JDK最好一致,不要尝试这个坑。

区分和理解两个环境:编译环境、运行环境

常量池

再往下遵从相同的规律: 计数器(标注后面有多少个) + 对应个数的结构体

下面以常量池为例子:

常量池记录了JVM内的一堆常量信息,这部分由 【2个字节计数】 + 【n个cp_info结构】组成

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。

  • 字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。

  • 符号引用属于编译原理方面的概念,包括下面三类常量:

    • 类和接口的全限定名(Fully Qualified Name)
    • 字段的名称和描述符(Descriptor)
    • 方法的名称和描述符

其中cp_info由多种类型:

  • 直接类型,存的就是当前值,这种像Integer、Long等长度都是确定的
  • 引用类型,存的是指向其他位置的指针

3)案例

下面以String为例,String是一种引用类,它会指向一个utf8类型来存储真实的信息

1
2
# JDK提供了一个工具javap,可以查看常量列表的详细内容:
javap -v ClassStruct.class

其他信息

运行数据区

字节码只是一个二进制文件存放在那里,要想在JVM里跑起来,先得有个运行的内存环境,也就是我们 所说的JVM运行时数据区。

  • 运行时数据区的位置

运行时数据区是JVM中最为重要的部分,执行引擎频繁操作的就是它。类的初始化,以及对象空间的分配、垃圾的回收都是在这块区域发生的。

  • 区域划分

程序计数器

概述

溢出异常

没有!在虚拟机规范中,没有对这块区域设定内存溢出规范,也是唯一一个不会溢出的区域

案例

虚拟机栈

概述

  • 是线程私有的,生命周期与线程相同
  • 它描述的是Java方法执行的当前线程的内存模型,每个方法被执行的时候,Java虚拟机都会创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每个方法从被调用直至执行完毕的过程,就对应着 一个栈帧在虚拟机栈中从入栈到出栈的过程

溢出异常

案例一:进出栈顺序

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
/**
* 程序模拟进栈、出栈过程
* 先进后出
*/
public class StackInAndOut {
/**
* 定义方法一
*/
public static void A() {
System.out.println("进入方法A");
}

/**
* 定义方法二;调用方法一
*/
public static void B() {
A();
System.out.println("进入方法B");
}

public static void main(String[] args) {
B();
System.out.println("进入Main方法");
}
}
// 进入方法A
// 进入方法B
// 进入Main方法

案例二:栈深度溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 通过一个程序模拟线程请求的栈深度大于虚拟机所允许的栈深度;
* 抛出StackOverflowError
*/
public class StackOverFlow {
/**
* 定义方法,循环嵌套自己
*/
public static void B() {
B();
System.out.println("进入方法B");
}

public static void main(String[] args) {
B();
System.out.println("进入Main方法");
}
}

案例三:栈内存溢出

试试就试试~

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
* 栈内存溢出,注意!很危险,谨慎执行
* 执行时可能会卡死系统,直到内存耗尽
* */
public class StackOutOfMem {
public static void main(String[] args) {
while (true) {
new Thread(() -> {
while(true);
}).start();
}
}
}

本地方法栈

概述

  • 本地方法栈的功能和特点类似于虚拟机栈,均具有线程隔离的特点
  • 不同的是,本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的Java方法
  • 虚拟机规范里对这块所用的语言、数据结构、没有强制规定,虚拟机可以自由实现它
  • 甚至,HotSpot把它和虚拟机合并成了1个

溢出异常

和虚拟机栈一样,也是两个:

  • 如果创建的栈的深度大于虚拟机允许的深度,抛出 StackOverFlowError。
  • 内存申请不够的时候,抛出 OutOfMemoryError。

Java堆是垃圾收集器管理的内存区域,因此它也被称作“GC堆”,这就是我们做JVM调优的重点区域。

JDK1.7

JDK1.8

需要特别说明的是:Metaspace所占用的内存空间不是在虚拟机内部,而是在本地内存空间中,这也是与1.7的永久代最大的区别

溢出异常

内存不足时,抛出 java.lang.OutOfMemoryError: Java heap space

案例:堆溢出

  • 代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 堆溢出,Out Of Memory 测试这个只需要第三个和第四个 参数设计就可以哈!
* -XX:+HeapDumpOnOutOfMemoryError
* -XX:HeapDumpPath=D:\
* 下面两个参数是设置堆内存的初始值和最大值的
* -Xms20m
* -Xmx20m
*/
public class HeapOOM {
Byte[] bytes = new Byte[1024*1024];
public static void main(String[] args) {
List<HeapOOM> list = new ArrayList<HeapOOM>();
int i = 0;
while (true) {
System.out.println(++i);
list.add(new HeapOOM());
}
}
}
  • 启动

注意启动时,指定一下堆的大小:-Xms20m -Xmx20m

方法区

溢出异常

  • 1.6:OutOfMemoryError: PermGen space
  • 1.8:OutOfMemoryError: Metaspace

案例一:1.6方法区溢出

在1.6里,字符串常量是运行时常量池的一部分,归属于方法区,放在了永久代里。

1
2
3
4
5
6
/*
如果字符串常量池里有这个字符串,直接返回引用,不再额外添加
如果没有,加进去,返回新创建的引用
*/
String.intern()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* Explain: 测试1.6版本的jvm的方法区异常
* -Xms10m 初始堆大小
* -Xmx10m 最大堆大小
* -XX:PermSize=6M 方法区初始大小
* -XX:MaxPermSize=6m 最大方法区大小
*
* 修改pom文件里的版本为1.6,jvm启动参数里改为1.6 (我没有1.6.。。。。。。。。。。。。。。。。)
*
*/
public class ConstantOOM {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
int i = 0;
while (true){
System.out.println(++i);
list.add(String.valueOf(i).intern());
}
}
}

案例二:1.8方法区溢出

1
2
3
// 所以不论配置下面那个参数都不生效,因为字符串常量池根本不在元空间中。
-XX:PermSize=6M -XX:MaxPermSize=6M
-XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M
  • 那如何才能让元空间溢出呢?

既然字符串常量池不在这里,那就换其他的,类的基本信息总在元空间吧?

我们来试一下 cglib是一个强大的、高性能、高质量的Code生成类库,它可以在运行期扩展Java类与实现Java接口,可以在运行时生成大量的对象。

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
/**
* Explain: jdk1.8以后舍弃了方法区,改为了元空间(Metaspace)
* 元空间不占用堆空间,使用的是直接内存,也就是我们服务器的物理内存
* -Xms10m
* -Xmx10m
* -XX:+PrintGCDetails 打印垃圾回收信息
* -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M
*/
public class MetaspaceSize {
public static void main(final String[] args) throws InterruptedException {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(MetaspaceSize.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] objects,
MethodProxy methodProxy) throws Throwable {
return methodProxy.invokeSuper(objects,args);
}
});
enhancer.create();
}
}
}

一个案例

假设有个Bootstrap的类,执行main方法,在JVM里,它从class文件到跑起来,大致经过如下步骤:

  • 首先JVM会将这个Bootstrap.class信息加载到内存中的方法区
  • 接着,主线程开辟一块内存空间,准备好程序计数器pc、虚拟机栈、本地方法栈
  • 然后,JVM会在Heap堆上位Bootstrap创建一个Class<Bootstrap>的类实例
  • JVM开始执行main方法,这时在虚拟机栈里为main方法创建一个栈帧
  • main方法在执行的过程之中,调用了greeting方法,则JVM会为greeting方法再创建一个栈帧,推到虚拟机栈顶,再main的上面,每次只有一个栈帧处于活动状态,当前为greeting
  • 当greeting方法运行完成后,则greeting方法出栈,当前活动帧指向main,方法继续往下运行

归纳总结

  • 独享/共享的角度:
    • 独享:程序计数器、虚拟机栈、本地方法栈
    • 共享:堆、方法区
  • error的角度:
    • 程序计数器:不会溢出,比较特殊,其他都会
    • 两个栈:可能会发生两种溢出
      • 深度超了报:StackOverflowError
      • 空间不足报:OutOfMemoryError
    • 堆:只会再空间不足时,报:OutOfMemoryError,会提示heapSpace
    • 方法区:空间不足时,报:OutOfMemoryError
      • 1.6 是 permspace
      • 1.8 是 meterspace
  • 归属
    • 计数器、虚拟机栈、本地方法中:线程创建必须配套申请,真正的物理空间
    • 堆:真正的物理空间,但是内部结构的划分有变动
    • 方法区:最没归属感的一块,原因就是它是一个逻辑概念。1.6被放在了堆的永久代,1.8被拆分, 一部分在元空间,一部分被放在了堆里(方法区的运行时常量池里面的类对象,包括字符串常量)
    • 直接内存:这块实际上不属于运行时数据区的一部分,而是直接操作物理内存。在nio操作里 DirectByteBuffer类可以对native操作,避免流在堆内外的拷贝。

类加载

加载

注意:

  • 加载的字节码来源,不一定非得是class文件,可以是符合字节码规范的任意地方,甚至二进制流等。
  • 从字节码到内存,是由类加载器(ClassLoader)完成的

系统类加载器

  • Bootstrap
  • ExtClassLoader
  • AppClassLoader
  • Code
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
35
36
37
/**
* Explain: 查看各个加载器,如:BootstrapClassLoader,ExtClassLoader,ApplicationClassLoader
*/
public class GetClassLoaders {
public static void main(String[] args) {
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
ClassLoader parent = systemClassLoader.getParent();

System.out.println("systemClassLoader:" + systemClassLoader);
System.out.println("systemClassLoader-parent:" + parent);
System.out.println("-----------------------------------------");

String[] bootstrap = System.getProperty("sun.boot.class.path").split(":");
String[] ext = System.getProperty("java.ext.dirs").split(":");
String[] app = System.getProperty("java.class.path").split(":");

System.out.println("bootstrap:");
for (String s : bootstrap) {
System.out.println(s);
}

System.out.println();

System.out.println("ext:");
for (String s : ext) {
System.out.println(s);
}

System.out.println();

//app是默认加载器,注意启动控制台的 -classpath 选项
System.out.println("app:");
for (String s : app) {
System.out.println(s);
}
}
}

自定义类加载器

除了上面系统提供的3种类加载器,JVM允许自己定义类加载器,典型的在Tomcat上:

双亲委派(重点)

类加载器会优先调父类的load方法,如果父类能加载,直接用父类的,否则最 后一步才是自己尝试加载

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
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 首先,检测是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
//如果没有加载,开始按如下规则执行:
long t0 = System.nanoTime();
try {
if (parent != null) {
//重点!父加载器不为空则调用父加载器的loadClass
c = parent.loadClass(name, false);
} else {
//父加载器为空则调用Bootstrap Classloader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
long t1 = System.nanoTime();
//父加载器没有找到,则调用findclass,自己查找并加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

验证

加载完成后,class里定义的类结构就进入了内存的方法区。 接下来,验证是连接阶段的第一步。

实际上,验证和上面的加载是交互进行的(比如class文件格式验证)。

之所以把验证放在加载的后面,是因为除了基本的class文件格式,还需要其他很多验证,我们逐个来看:

文件格式验证

元数据验证

字节码验证

符号引用验证

准备

变量类型

注意是类变量,也就是类里的静态变量,而不是new的那些实例变量,new的在下面的初始化阶段

  • 类变量 = 静态变量
  • 实例变量 = 实例化new出来的那些

存储位置

理论上这些值都在方法区里,但是注意,方法区本身就是一个逻辑概念。

  • 1.6里,在永久代
  • 1.8以后,静态类变量如果是一个对象,其实它在堆里

初始化值

注意:即使是static变量,它在这个阶段初始化进内存的依然是它的初始值,而不是你想要什么就是什么

1
2
3
4
5
6
//普通类变量:在准备阶段为它开了内存空间,但是它的value是int的初始值,也就是 0!
//而真正的123赋值,是在类构造器,也就是下面的初始化阶段
public static int a = 123;
//final修饰的类变量,编译成字节码后,是一个ConstantValue类型
//这种类型,在准备阶段,直接给定值123,后期也没有二次初始化一说
public static final int b = 123;

解析

初始化

概述

两个初始化

  • 类变量与实例变量的区分

这里所说的初始化是一个class类加载到内存的过程,所谓的初始化值是类里定义的类变量,也就是静态变量

这个初始化要和new一个类区分开来,new的是实例变量,是在执行阶段才创建的

  • 实例变量创建的过程(new)
    • 在方法区中找到对应类型的类信息
    • 在当前方法栈帧的本地变量表中放置一个reference指针
    • 在堆中开辟一块空间,放这个对象的实例
    • 将指针指向堆里对象的地址

对象创建

对象创建

概述

从你new一个对象开始,发生了什么?

遇到new指令,JVM首先要做的事是检查有没有这个类,有的话,加载它!

内存分配

类加载检查通过后,就要给新对象分配内存。

因为一个类型确定后,它内部定义了哪些结构哪些值,所需要的内存空间也就确定了。

  • 指针碰撞(Bump The Pointer)

这种分配前提是内存中有整片连续的空间,用的在一边,空闲的在另一边,一个指针指向分界线。

需要多少指针往2空闲那边移动多少,直接划分出来一段,给当前对象。

  • 空闲列表(Free List)
  • 并发性

无论指针移动还是空闲列表的同一个指针空间,在并发分配的情况下会不会有问题?

确实有并发问题,那JVM是如何解决的呢?

方式一:CAS原子操作+失败重试

方式二:本地线程分配缓冲(TLAB)

对象创建时内存分配的流程:

  • 内存区域
    • 栈上分配使用的是栈来进行对象内存的分配
    • TLAB分配使用的是Eden区域进行内存分配,实际上还是属于堆内存
  • 优先级
    • 栈上分配优先于TLAB分配进行,逃逸分析中若可进行栈上分配优化,会优先进行对象栈上直接分配内存
    • 当无法进行栈上直接分配时,则会进行TLAB分配

内存布局

对象在堆上的布局,可以分为三个部分:对象头、实例数据、对齐填充。

对象头

一般分为两部分,Mark Word 和 类型指针(HotSpot)

  • Mark Work, 官方叫法,其实就是存储对象自己运行时的数据

如哈希码、GC分代年龄、锁状态标记、线程持有的锁、偏向的线程id (具体的分类在下面)

  • 类型指针

指向当前对象的类型,也就是方法区里,类信息的地址。(当然这里不是绝对的,HotSpot这么设计的。)

实例数据

对齐填充

对象的访问

我们的程序运行时,每一个方法相关的变量信息都在栈里,那么怎么找到这个对象呢?(栈中变量引用如何指向堆中对象)

句柄访问

栈指针指向堆里的一个句柄的地址,这个句柄再定义两指针分别指向类型和实例

很显然,拦截回收移动对象的话只需要改句柄即可,不会波及到栈,但是多了一次寻址操作。

直接地址

很显然,垃圾回收移动对象要改栈里的地址值,但是它减少了一次寻址操作。

备注:HotSpot使用的是直接地址方式

对象的销毁

JVM参数

分类

标准参数

-X参数

-XX参数

参数查询

垃圾回收概述

背景

实际上,垃圾回收并不是Java首创的,垃圾收集的历史远比Java语言本身还要久。

最早使用垃圾回收功能的语言是Lisp,于1960年诞生于麻省理工学院。

回收事件三要素

在哪收(地点)

什么时候收(时间)

回收谁(人物)

引用计数法

可达性分析

回收算法(策略)

标记清除算法

标记整理算法

标记复制算法

分代

回收器(执行者)

串行

并行

并发 - CMS

并发 - G1

并发 - ZGC(了解)

归纳总结

调优实战

环境

初始状态

参数

执行日志

日志分析

初步调优

参数

二次分析

二次调优

参数

日志分析

小结