hello云胜

技术与生活

0%

Netty中的高性能任务调度–时间轮算法

Netty中很多地方都要用到定时任务,比如最常见的心跳检测。对于Netty这种高性能组件,在定时任务调度方面有什么独到之处?

为了实现高性能的定时任务调度,Netty 引入了时间轮算法来驱动定时任务的执行。

定时任务的本质

定时任务一般有三种表现形式:周期性执行,延时执行,指定时间执行。

定时器的本质是要设计一种数据结构,能够存储和调度任务集合,并且离执行时间越近的任务拥有越高的优先级。

那么定时器怎么知道一个任务快到期了呢?

定时器需要通过轮询的方式,每隔一个时间片去检查是否有任务到期。

所以定时器内部至少有一个存储任务的队列,和一个执行轮询的异步线程。

java原生提供了三种定时器的实现:Timer、DelayedQueue 和 ScheduledThreadPoolExecutor。

Timer

1
2
3
4
5
6
7
Timer timer = new Timer();
timer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
System.out.println(LocalDateTime.now());
}
}, 1000, 1000);

看一下Timer的结构

1
2
3
4
5
public class Timer {
private final TaskQueue queue = new TaskQueue();

private final TimerThread thread = new TimerThread(queue);
}

使用TaskQueue存储任务队列。使用一个TimerThread作为轮询线程。

看看TaskQueue的数据结构

1
2
3
class TaskQueue {
private TimerTask[] queue = new TimerTask[128];
}

实际上,TaskQueue是一个由数组实现的小根堆。所以最近的任务始终在堆顶,取到这个任务的时间复杂度永远是O(1)。采用小根堆这种数据结构存储非常合理。根据小根堆的特点,添加和删除一个节点的时间复杂度是O(logn)

然后启动TimerThread线程不断轮询TaskQueue中的任务,看看堆顶任务该不该执行。

Timer的缺陷

Timer结构清晰,简单。但是他有很大的缺陷,基本不会被使用。

  • Timer是单线程
  • 调度是基于系统的绝对时间,如果系统时间不正确,可能会出问题
  • 一个TimerTask出现异常并且没有捕获处理,Timer会异常退出,其他任务也不会得到执行了。

DelayedQueue

DelayedQueue 是可以用于延迟执行的阻塞队列。内部使用PriorityQueue优先级队列来存储对象。

DelayQueue 中的每个对象都必须实现 Delayed 接口,并重写 compareTo 和 getDelay 方法。

compareTo 方法用于优先级队列排序,getDelay 方法用于计算消息延时。

延时队列一般用于失败重试的场景。

新增和删除对象的时间复杂度是O(logn)

ScheduledThreadPoolExecutor

因为Timer的上述缺陷,java提供了ScheduledThreadPoolExecutor进行替代。

1
2
3
4
ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);

executor.scheduleAtFixedRate(() -> System.out.println("Hello World"), 1000, 2000, TimeUnit.MILLISECONDS); // 1s 延迟后开始执行任务,每 2s 重复执行一次

ScheduledThreadPoolExecutor内部使用重新设计的延时阻塞队列DelayedWorkQueue。DelayedWorkQueue内部也是个优先级队列。

使用线程池不断轮询执行任务。

新增和删除任务的时间复杂度是O(logn)

总结:从这三个定时器来看,他们都是由任务队列,任务管理,任务调度三种角色,新增和删除的时间复杂度都是O(logn)。

所以有没有时间复杂度更小的定时器呢?

时间轮算法

对于性能要求较高的场景,我们一般使用时间轮算法。

原理

时间轮这个名字听起来高大上,其实解释完了也很简单。

技术来源于生活。时间轮我们完全可以类别我们生活中的钟表。比如,我们将钟表分为60个槽slot。分别代表60s。假设当前指针指在其实位置0,那么需要延时3秒执行的任务,可以挂在3这个槽上。延时100秒的只能挂在40这个槽上。这样我们还需要记录一下这个任务本轮不执行,下一轮执行,可以在任务上加一个标记为round=1。轮第一圈的时候坚持round,不是0的,减一。是0的,立即执行。

这样会有多个任务挂在同一个slot下,这个hashmap很像。不多说了。

这其实就是个有限个数的环形队列。新增和删除任务的时间复杂度都是O(1)。

这就是时间轮的基本原理。

HashedWheelTimer

Netty中时间轮的实现是HashedWheelTimer 。

1
2
3
4
public HashedWheelTimer(
ThreadFactory threadFactory,
long tickDuration, TimeUnit unit, int ticksPerWheel, boolean leakDetection,
long maxPendingTimeouts)

HashedWheelTimer的构造函数揭示了HashedWheelTimer的结构核心属性

  • threadFactory,执行线程池。但是里面只创建一个线程
  • tickDuration,时钟每跳一次的时间间隔。就是一个slot代表多少时间
  • unit,一跳的时间单位
  • ticksPerWheel,时间轮上一共有多少个 槽,默认 512 个。分配的 slot 越多,占用的内存空间就越大
  • maxPendingTimeouts:最大运行等待的任务数
  • leakDetection,是否开启内存泄漏检测

整个时间轮是一个HashedWheelBucket 数组,每个槽是一个HashedWheelBucket。 HashedWheelBucket内部是一个双向链表。链表的每一个对象是一个HashedWheelTimeout 对象。一个HashedWheelTimeout 代表一个定时任务。

工作线程执行轮询,是直接sleep一跳的时间。为了避免线程频繁sleep唤醒,一跳的时间至少是1ms。

HashedWheelTimer 并不是十全十美的,他也有一些潜在的问题:

  • 如果长时间没有到期任务,那么会存在时间轮空推进的现象。
  • 因为 Worker 是单线程的,只适用于处理耗时较短的任务,如果一个任务执行的时间过长,会造成 Worker 线程阻塞。
  • 相比传统定时器的实现方式,内存占用较大。

空推进问题可以参考kafka的多级时间轮

从session、cookie到token以及JWT

主要讲token和jwt技术,关于session和cookie文章很多。简单提一下

session和cookie

现在一般都是session和cookie一起用,一起提。但是他们俩其实不是一定要在一起。

首先牢记一点,http协议是无状态的。就是说,一个请求过来,服务器不知道这个请求的用户是不是已经登录过了,不知道他的状态。只能再把这个请求重定向到登陆页面。

这样用户就疯了,怎么一直让我登录。

所以,前人想了一个办法,在第一次登录后,在服务器端记录一个会话id(sessionId),记录一下用户及其状态。然后把sessionId回给浏览器。浏览器将这个sessionId记录到cookie里,下一次请求再带上。这样服务器从请求中拿到cookie里的sessionId,到自己的存储(一般是用redis)里查一下,得到用户的状态。之后就可以愉快的进行下面的操作了。

总之,

  1. session是服务器端的,cookie是浏览器端的
  2. cookie只是实现session的其中一种方案。虽然是最常用的,但并不是唯一的方法。禁用cookie后还有其他方法存储,比如放在url中
  3. 现在后端服务都是分布式部署,session一般统一放在redis集群中。这样有个问题就是一旦redis故障,可能会影响所有的用户请求。

所以,在后台进行session的存储和运维这件事是非常重要和危险的,对可靠性的要求非常高。

解决问题其实一直有两条路,一是解决问题,二是解决问题本身。

那么,我们有没有可能不存储session呢?

token

其实是可以的。这样来一步步思考:

  1. 如果我们讲所有信息全部放在cookie里,那么只要cookie将用户的id和状态给服务器传过去就好了。

  2. 但是,这样非常危险。用户可以随意伪造cookie,并且非常容易被劫持

  3. 所以,问题变成了,怎么确保安全性?

  4. 答案就是做签名。在用户第一次登录时,服务端使用如SHA256算法对数据进行加密。就称之为token。

    639

    下一次浏览器把加密后的token带过来,服务器再使用相同的算法对数据进行一次加密,比较两次加密的结果,相等即为验证通过。

    640

    因为私钥只要服务器知道。所以用户过来的请求时无法伪造的。

这样一来,服务器不需要再费力的保存session数据。服务器端时无状态的。即使流量大增,只要增加服务器即可。

token的优势:

  • 无状态、可扩展

  • 支持移动设备(移动设备是没有cookie的)

  • 跨程序调用

  • 安全

现在大部分你见到过的API和Web应用都使用token。例如Facebook, Twitter, Google+, GitHub等。

JWT

我们知道了token技术是个好东西,那么我们怎么用呢?

JWT就是token的一种实现方式,并且基本是java web领域的事实标准。

JWT全称是JSON Web Token。基本可以看出是使用JSON格式传输token

JWT 由 3 部分构成:

  1. Header :描述 JWT 的元数据。定义了生成签名的算法以及 Token 的类型。
  2. Payload(负载):用来存放实际需要传递的数据
  3. Signature(签名):服务器通过PayloadHeader和一个密钥(secret)使用 Header 里面指定的签名算法(默认是 HMAC SHA256)生成。

流程:

在基于 Token 进行身份验证的的应用程序中,用户登录时,服务器通过PayloadHeader和一个密钥(secret)创建令牌(Token)并将 Token 发送给客户端,

然后客户端将 Token 保存在 Cookie 或者 localStorage 里面,以后客户端发出的所有请求都会携带这个令牌。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP Header 的 Authorization字段中:Authorization: 你的Token

jwt

JWT 与 Oauth2.0

Oauth 2.0 是一种授权机制,用来授权第三方应用,获取用户数据,它与 JWT 其实并不是一个层面的东西。Oauth2.0 是一个方便的第三方授权规范,而 JWT 是一个 token 结构规范。只是 JWT 常用来登陆鉴权,而 Oauth2.0 在授权时也涉及到了登陆,所以就比较容易搞混。

jvm命令

jps

打印当前服务器上的java进程

1
2
3
4
$ jps
10963 redis-1.0.0.jar
11326 Jps

jinfo

打印java进程的信息

1
2
3
4
5
6
7
$ jinfo -flags 10963
Attaching to process ID 10963, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.151-b12
Non-default VM flags: -XX:CICompilerCount=12 -XX:InitialHeapSize=1054867456 -XX:MaxHeapSize=16846422016 -XX:MaxNewSize=5615124480 -XX:MinHeapDeltaBytes=524288 -XX:NewSize=351272960 -XX:OldSize=703594496 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseFastUnorderedTimeStamps -XX:+UseParallelGC
Command line:

可以看到jvm的配置信息。

我在启动这个jar时并没有指定Xms和Xmx。所以都是默认值。

我的服务器是64G内存,所以默认的Xms是1G,Xmx是16G。即服务器内存的1/64和1/4。

jstat

jstat命令可以查看堆内存各部分的使用量,以及加载类的数量

注:本实例启动jvm时,没有指定任何JVM参数

class

输出类加载相关信息

1
2
3
$ jstat -class 19295
Loaded Bytes Unloaded Bytes Time
16603 31015.9 147 221.0 18.84

一共加载了16603个类,大小31K。卸载了147个类。装载和卸载总耗时18秒

gc命令

打印堆相关信息

1
2
3
$ jstat -gc 19295
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
5632.0 5632.0 4445.3 0.0 1688064.0 1456406.7 388608.0 154223.4 105876.0 100496.0 11964.0 11033.2 4114 27.677 9 5.965 33.642
  • **S0C:**S0区的大小 5632Kb,约5M
  • **S1C:**S1区的大小
  • **S0U:**S0区使用的大小
  • **S1U:**S1区使用的大小,s0和s1同一时间只会有一个在用
  • **EC:**伊甸园区的大小 1.6G,还挺大
  • **EU:**伊甸园区的使用大小
  • **OC:**老年代大小 约400M
  • **OU:**老年代使用大小
  • **MC:**方法区大小 约100M
  • **MU:**方法区使用大小
  • **CCSC:**压缩类空间大小
  • **CCSU:**压缩类空间使用大小
  • **YGC:**年轻代垃圾回收次数
  • **YGCT:**年轻代垃圾回收消耗时间
  • **FGC:**老年代垃圾回收次数
  • **FGCT:**老年代垃圾回收消耗时间
  • **GCT:**垃圾回收消耗总时间

jstat -gccapacity

显示各个代的对象大小

1
2
3
$ jstat -gccapacity 19295
NGCMN NGCMX NGC S0C S1C EC OGCMN OGCMX OGC OC MCMN MCMX MC CCSMN CCSMX CCSC YGC FGC
106496.0 1699840.0 1699840.0 6144.0 6144.0 1687552.0 212992.0 3399680.0 388608.0 388608.0 0.0 1142784.0 105876.0 0.0 1048576.0 11964.0 4120 9
  • NGCMN:新生代中初始化大小 新生代=s0+s1+eden
  • NGCMX:新生代最大大小
  • NGC:当前新生代容量
  • OGCMN:老年代初始化大小
  • OGCMX:老年代最大大小。因为这个jvm启动时采用默认配置。老年代动态涨上来的。
  • MCMN:元空间相关
  • CCSMN: 压缩类相关

jstat -gcmetacapacity

看元空间中的内存信息

1
2
3
$ jstat -gcmetacapacity 19295
MCMN MCMX MC CCSMN CCSMX CCSC YGC FGC FGCT GCT
0.0 1142784.0 105876.0 0.0 1048576.0 11964.0 4120 9 5.965 33.707

jstat -gcnew

新生代相关信息

1
2
3
$ jstat -gcnew 19295
S0C S1C S0U S1U TT MTT DSS EC EU YGC YGCT
6144.0 6144.0 3996.1 0.0 15 15 6144.0 1687552.0 1278569.1 4120 27.742

TT: Tenuring threshold。新生代的持有代数。默认是15。15代后的对象进入老年代。

DSS:Desired survivor size (kB)。期望的S区大小。

jstat -gcnewcapacity

1
2
3
$ jstat -gcnewcapacity 19295
NGCMN NGCMX NGC S0CMX S0C S1CMX S1C ECMX EC YGC FGC
106496.0 1699840.0 1699840.0 566272.0 6144.0 566272.0 6144.0 1698816.0 1687552.0 4121 9

jstat -gcold

1
2
3
$ jstat -gcold 19295
MC MU CCSC CCSU OC OU YGC FGC FGCT GCT
105876.0 100496.0 11964.0 11033.2 388608.0 154615.5 4121 9 5.965 33.718

jstat -gcoldcapacity

1
2
3
$ jstat -gcoldcapacity 19295
OGCMN OGCMX OGC OC YGC FGC FGCT GCT
212992.0 3399680.0 388608.0 388608.0 4121 9 5.965 33.718

gcutil命令

1
2
3
$ jstat -gcutil 19295
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
93.02 0.00 65.43 39.71 94.92 92.22 4116 27.698 9 5.965 33.663

-gccause

1
2
3
$ jstat -gccause 19295
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT LGCC GCC
0.00 67.96 73.01 39.79 94.92 92.22 4121 27.753 9 5.965 33.718 Allocation Failure No GC

jstat -compiler

Just-in-Time(即时)编译器的信息

1
2
3
$ jstat -compiler 19295
Compiled Failed Invalid Time FailedType FailedMethod
25969 5 0 197.59 1 com/mysql/jdbc/AbandonedConnectionCleanupThread run

-printcompilation

1
2
3
$ jstat -printcompilation 19295
Compiled Size Type Method
25969 84 1 java/util/LinkedList toArray

Method:最近进行了JIT编译的方法

周期性输出

上面的命令都可以在后面加上周期操作:周期(单位是毫秒) 次数。

1
jstat -gc 19295 5000 10

就是每5秒循环一次,一共输出10次

jmap

主要用于打印指定Java进程堆内存的细节

-heap

打印一个堆的摘要信息,包括使用的GC算法、堆配置信息和generation wise heap usage。

-histo[:live]

打印堆的柱状图。其中包括每个Java类、对象数量、内存大小(单位:字节)、完全限定的类名。打印的虚拟机内部的类名称将会带有一个’*’前缀。如果指定了live子选项,则只计算活动的对象。

打印堆快照

1
jmap -dump:format=b,file=xxx pid

jhat

jhat来分析jmap生成的堆转储快照

1
jhat xxx堆转储文件

分析结果在7000端口开启一个web服务

浏览器访问:localhost:7000

不常用,功能相对来说比较简陋, 更多的是使用jvisualvm, 以及专业用于分析堆转储快照文件的Eclipse Memory Analyzer、 IBM HeapAnalyzer等工具

jvisualvm

一个界面工具,本地的java进程可以直接连上。远程的需要开启。

image-20210928101621514

image-20210928101722744

Memory Analyzer (MAT)

mat是eclipse出品的开源jvm堆转储文件分析工具。

image-20210928111923125

从小我就对Java有着深厚的感情,算下来有几十年的Java经验了。当年的Java还是Sun公司的,我有着多年的Servlet经验,CURD经验,在现在已经被自我革新,转而研究人生的哲学。罢了,不吹了。本文是关于Java故障排查的,属上篇。

为了保证文章的流畅性,我决定一口气把它写完。因为相关方面的培训做的多了,就不需要在写的时候参考资料、翻源代码。掐指一算,本文一个小时没花掉,但篇幅已经较长了。

长了,那就割断。本篇就定为内存排查的上篇,主要讲一些原理。为什么要讲原理?开车还需要了解汽车结构么?

这还真不能相比。

汽车很少坏,出了问题你会花钱给拖车公司、4S店。你还会每年给它买上保险。

反观Java,三天两头出问题,找人解决还找不到人,给钱都不一定能解决问题。能比么?盘点来盘点去,最后只能靠自己。

  • 1.内存里都有啥
  • 2.操作系统内存
  • 3.JVM内存划分
  • 4.一图解千愁,jvm内存从来没有这么简单过!
  • 5.为什么会有内存问题
  • 6.垃圾回收器
  • 7.重要概念GC Roots
  • 8.对象的提升

1.内存里都有啥

要想排查内存问题,我们就需要看一下内存里都有啥。我们先来看一下操作系统内存的划分,然后再来看一下JVM内存的划分。由于JVM本身是作为一个正常的应用运行在操作系统上的,所以它的行为同时会受到操作系统的限制。

2.操作系统内存

img

我们首先从操作系统的实现来说起。通常情况下,我们写了一个C语言程序,编译后,会发现里面的内存地址是固定的。其实我们的应用程序在编译之后,这些地址都是虚拟地址。他需要经过一层翻译之后,才能映射到真正的物理内存,MMU就是负责地址转换的硬件。

img

那我们操作系统的可用内存到底是多少呢?它其实是分为两部分的。一部分是物理内存,指的是我们插的那根内存条;另一部分就是使用磁盘模拟的虚拟内存,在Linux通常称做swap分区。所以,可用内存 = 物理内存 + 虚拟内存。如果你的系统开了swap,可用内存就比物理内存大。

img

通过top命令和free命令都可以看到内存的使用情况。

top命令可以看到每一个进程的内存使用情况,我们平常关注的是RES这一列,它代表的是进程实际的内存占用,我们平常在搭建监控系统的时候,监控的也是这个数值。

我们再来看一下free命令的展示。它的展示其实是有一些混乱的,具体的关系可以看上面的图。通常情况下,free显示的数值都是比较小的,但这并不等于系统的可用内存就那么一点点。Linux操作系统启动后,随着机器的运行,剩余内存会迅速被buffer和cache这些缓冲区和缓存迅速占满,而这些内存再应用的内存空间不足时,是可以释放的。可用内存 = free + buffers + cached

具体每一个区域的内存使用情况,可以通过/proc/meminfo进行查看的。

1
2
3
4
5
6
7
# cat /proc/meminfo
MemTotal: 3881692 kB
MemFree: 249248 kB
MemAvailable: 1510048 kB
Buffers: 92384 kB
Cached: 1340716 kB
40+ more ...

3.JVM内存划分

接下来,我们才来看一下JVM的内存区域划分。

img

在JVM中,最大的内存区域就是堆,我们平常创建的大部分对象,都会存放在这里。所谓的垃圾回收,也主要针对的是这一部分。

多本JVM书籍描述:JVM中,除了程序计数器,其他区域都是可能溢出的。我们这里依然同意这个结论。下面仅对这些内存区域做简要的介绍,因为有些知识对我们的内存排查无益。

  • :JVM堆中的数据,是共享的,是占用内存最大的一块区域
  • 虚拟机栈:Java虚拟机栈,是基于线程的,用来服务字节码指令的运行
  • 程序计数器:当前线程所执行的字节码的行号指示器
  • 元空间:方法区就在这里,非堆 本地内存:其他的内存占用空间

img

类比上面这张图,我们可以归位一些常用对象的分配位置。不要纠结什么栈上分配逃逸分析,也不用关注栈帧和操作数栈这种双层的结构,这些小细节对于对象的汪洋大海来说,影响实在是太小。我们关注的内存区域,其实就只有堆内内存堆外内存两个概念。

4.一图解千愁,jvm内存从来没有这么简单过!

下面这篇文章,详细的讲解了每个区域。本来想要揉在一块,但怕突出不了它的重要性。所以开始直接读原文吧。

明星文章:《一图解千愁,jvm内存从来没有这么简单过!》

img

5.为什么会有内存问题

统计显示,我们平常的工作中,OOM/ML问题占比5%左右,平均处理时间却达到40天左右。这就可以看出这种问题的排查,是非常的困难的。

但让人无语的是,遇到内存问题,工程师们的现场保护意识往往不足,特别的不足。只知道一个内存溢出的结果,但什么都没留下。监控没有,日志没有,甚至连发生的时间点都不清楚。这样的问题,鬼才知道原因。

6.垃圾回收器

内存问题有两种模式,一种是内存溢出,一种是内存泄漏。

  • 内存溢出 OutOfMemoryError,简称OOM,堆是最常见的情况,堆外内存排查困难。
  • 内存泄漏 Memory Leak,简称ML,主要指的是分配的内存没有得到释放。内存一直在增长,有OOM风险;GC时该回收的回收不掉;或者能够回收掉但很快又占满,产生压力。

内存问题影响也是非常大的,比如下面这三种场景。

  • 发生OOM Error,应用停止(最严重)
  • 频繁GC,GC时间长,GC线程时间片占用高
  • 服务卡顿,请求响应时间变长

说到这卡顿问题,就不得不提一嘴垃圾回收器。

img

很多同学一看上面的图,就知道我们要说G1垃圾回收器了,这也是我的推荐。CMS等垃圾回收器,回收时间不可控,如果你有条件,当然要避免使用,CMS也将要在Java14中被移除,我也真心不希望你掌握一些即将过时的经验。ZGC虽然厉害,但还太新,几乎没有人敢吃螃蟹,那剩下的就是G1了。

G1通过三个简单的配置参数,大部分情况下即可获取优异的性能,工程师幸福了很多。三个参数如下:

  • MaxGCPauseMillis 预定目标,自动调整。
  • G1HeapRegionSize 小堆区大小。
  • InitiatingHeapOccupancyPercent 堆内存比例阈值,启动并发标记。

如果你还是不放心,想要了解一下G1的原理,那我们也可以捎带提上两嘴。G1其实还是有年轻代老年代的概念的,只不过它的内存是不连续的。

如图所示,G1将内存切分成大小相等的区域,这些区域叫做小堆区,是垃圾回收的最小单位。以前的垃圾回收器都是整代回收,而G1是部分回收,那就可以根据配置的最小延迟时间合理的选取小堆区的数量,回收过程就显得智能了很多。

7.重要概念GC Roots

如图所示,要确定哪些是垃圾,就需要有一种找到垃圾的方法。其实,我们上一句的表述是不正确的。在JVM中,找垃圾的方法和我们理解的正好相反:它是首先找到存活的对象,对存活的对象做标记,然后把其他对象一股脑的回收掉。

JVM在垃圾回收时,关心的是不要把不是垃圾的对象给回收了,而不是把垃圾对象给清理的干干净净。

img

要找到哪些是存活对象,就需要从源头上追溯。在JVM中,常见的GC Roots就有静态的成员变量等,比如一个静态的HashMap。

另外一部分,就是线程所关联的虚拟机栈和本地方法栈里面的内容。

我们说了这老半天,其实这种追溯方式有一个专有的名词:可达性分析法。与之类似的还有引用计数法,但由于有环形依赖的问题,所以几乎没有回收器使用这种形式。

并不是说只要是和GC Roots有一条联系(Reference Chain),对象就是存活的,它还与对象的引用级别有关。

  • 强引用:属于最普通最强硬的一种存在,只有在和GC Roots断绝关系时,才会被消灭掉
  • 软引用:只有在内存不足时,系统则会回收软引用对象
  • 弱引用:当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象
  • 虚引用:虚引用主要用来跟踪对象被垃圾回收的活动

平常情况下,我们使用的对象就是强引用。软引用和弱引用在一些缓存框架中用的比较广泛,对象的重要程度也比较弱。

8.对象的提升

大多数垃圾回收器都是分代垃圾回收,我们从上面对G1的描述就能够看出来。

img

如图所示,是典型的分代回收内存模型。对象从年轻代提升到老年代,有四种方式。

  1. 常规提升,对象够老。比如从from到to转了15圈还没有被回收掉。控制参数就是-XX:MaxTenuringThreshold。这个值在CMS下默认为6,G1下默认为15
  2. 分配担保 Survivor 空间不够,老年代担保。
  3. 大对象直接在老年代分配
  4. 动态对象年龄判定。比如在G1里的TenuringThreshold会随着堆内对象的分布而变化

对于垃圾回收器的优化,就是要确保尽量多的对象在年轻代里分配,减少对象提升到老年代的可能。虽然这种思想在G1里弱化了许多。

End

了解了操作系统的内存里都有啥,又了解了JVM的内存里都有啥,我们就可以淡定纵容的针对于每一种出现问题的情况,进行针对性排查和优化。

文章到这里嘎然而止。下一篇,我们以几个实际的案例,来看一下Java的内存问题排查的具体过程。

JVM架构

image-20210419181737112

从上图可以很清晰的看出,jvm架构分成三大部分

  • 类加载子系统
  • 运行时数据区
  • 执行引擎

1,类加载子系统

Java的动态类加载功能就是由类加载子系统完成的。类加载子系统在运行时第一次遇到一个class文件时就去加载、链接、初始化class文件。注意是运行时,不是编译时。

1.1 加载

类首先由类加载子系统加载进来。具体分为启动类加载器(BootStrap ClassLoader)、扩展类加载器(Extension ClassLoader)和应用类加载器(Application ClassLoader)三个类加载器。

  • 启动类加载器:优先级最高的类加载器。加载的是rt.jar
  • 扩展类加载器:负责加载java.ext.dirs系统属性指定的目录下的jar,也会加载jdk安装目录下的jre/lib/ext文件夹下jar,如果我们自己大的jar包,放到这些目录下,也会被扩展类加载器加载。
  • 应用类加载器:负责加载应用程序classpath目录下的所有jar和class文件。

加载过程遵循双亲委派机制

1.2 链接

  1. 校验:进行二进制代码的合法性验证工作,如果非法会报错。
  2. 准备:在这个阶段所有的静态变量会分配内存,并初始化类型的默认值。
  3. 解析:所有的符号引用替换成真正的内存地址引用

1.3初始化

类加载的随后阶段。此时进行所有静态变量的初始化,静态代码块也会得到执行。

2,运行时数据区

运行时数据区主要分五个部分。

  • 方法区

    所有类级别的信息都存在这,包括类的静态变量。一个JVM只有一个方法区,方法区是线程共享的。

  • 堆区

    所有的对象实例及其实例变量都存在这。同样一个JVM只有一个堆,堆是线程共享的。

  • 栈区

    每个线程有自己独立的栈。栈是线程安全的。每次方法调用产生一个栈帧。所有的本地变量在对应的栈内存中分配。

    一个栈帧又可以分成三部分

    • 局部变量表(Local Variable Array,简称LVA),顾名思义存储本地变量
    • 操作数栈(Operand stack),进行运算
    • 帧数据(Frame data),存放的帧数据用于支持正常方法返回以及异常派发
  • PC寄存器

    线程独享,存储当前执行的指令的地址。

  • 本地方法栈

    线程独享,存储本地方法执行的信息。

3,执行引擎

二进制代码是由执行引擎执行的。执行引擎可以分成三部分。

  • 解释器

    解释器解析代码快,但是执行慢。如果一个方法调用多次,对解释器来说,要进行多次解释执行。

  • JIT即时编译器

    JIT即时编译器可以弥补解释器的缺点。执行引擎使用解释器编译代码,当它发现重复代码时,使用JIT即时编译器将重复代码转成本地方法代码。这个本地方法代码会在方法重复调用时直接执行,提高了效率。

  • 垃圾回收器

4,本地方法接口

本地方法接口和本地方法库交互,使执行引擎可以调用本地方法库

5,本地方法库

就是本地方法库

10JVM整体架构

image-20201125222843726

详细图

image-20201129100215780

中文版

image-20201129100252111

代码执行流程

image-20201125223323375

只要遵循字节码规范,就可以被JVM执行

编译器有解释执行器呵JIT即时编译器两部分合用组成。

JIT编译器,对字节码进行二次编译,直接生产二进制代码。好处是以后执行会更快,效率更高。用于反复执行的热点代码。

java是基于栈的架构

指令集更少,但是实现一个功能的需要的指令更多。性能比基于寄存器的架构差。但是需要的资源少,可移植性更强

类加载器,getClassLoader方法和getClass方法

image-20201129103116433

类加载过程

image-20201129103407628

自定义类加载器

原因:

  1. 隔离加载类,避免类冲突

  2. 修改类加载的方式

  3. 扩展加载源

  4. 防止源码卸载。对源码进行加密。使用自定义类加载器进行解密

步骤

运行时方法区

image-20201204213522142

栈帧

image-20201206224103947

image-20201209091620968

image-20201212211214385

image-20201217213358857

对象存储

image-20201219220350869

垃圾收集器

image-20201225220215577

红色的线,已废弃

绿色的线,jdk14废弃

image-20201230112543975

jstat

jstat命令工具是排除JVMGC问题不可或缺的工具

既可以查看堆内存各部分的使用量,也可以查看加载类的数量。

启动jvm时,没有指定任何JVM参数

class

输出类加载相关信息

1
2
3
4
$ jstat -class 19295
Loaded Bytes Unloaded Bytes Time
16603 31015.9 147 221.0 18.84

一共加载了16603个类,大小31K。卸载了147个类。装载和卸载总耗时18秒

gc命令

打印堆相关信息

1
2
3
$ jstat -gc 19295
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
5632.0 5632.0 4445.3 0.0 1688064.0 1456406.7 388608.0 154223.4 105876.0 100496.0 11964.0 11033.2 4114 27.677 9 5.965 33.642
  • **S0C:**S0区的大小 5632Kb,约5M
  • **S1C:**S1区的大小
  • **S0U:**S0区使用的大小
  • **S1U:**S1区使用的大小,s0和s1同一时间只会有一个在用
  • **EC:**伊甸园区的大小 1.6G,还挺大
  • **EU:**伊甸园区的使用大小
  • **OC:**老年代大小 约400M
  • **OU:**老年代使用大小
  • **MC:**方法区大小 约100M
  • **MU:**方法区使用大小
  • **CCSC:**压缩类空间大小
  • **CCSU:**压缩类空间使用大小
  • **YGC:**年轻代垃圾回收次数
  • **YGCT:**年轻代垃圾回收消耗时间
  • **FGC:**老年代垃圾回收次数
  • **FGCT:**老年代垃圾回收消耗时间
  • **GCT:**垃圾回收消耗总时间

jstat -gccapacity

显示各个代的对象大小

1
2
3
4
$ jstat -gccapacity 19295
NGCMN NGCMX NGC S0C S1C EC OGCMN OGCMX OGC OC MCMN MCMX MC CCSMN CCSMX CCSC YGC FGC
106496.0 1699840.0 1699840.0 6144.0 6144.0 1687552.0 212992.0 3399680.0 388608.0 388608.0 0.0 1142784.0 105876.0 0.0 1048576.0 11964.0 4120 9

  • NGCMN:新生代中初始化大小 新生代=s0+s1+eden
  • NGCMX:新生代最大大小
  • NGC:当前新生代容量
  • OGCMN:老年代初始化大小
  • OGCMX:老年代最大大小。因为这个jvm启动时采用默认配置。老年代动态涨上来的。
  • MCMN: 元空间相关
  • CCSMN: 压缩类相关

jstat -gcmetacapacity

看元空间中的内存信息

1
2
3
$ jstat -gcmetacapacity 19295
MCMN MCMX MC CCSMN CCSMX CCSC YGC FGC FGCT GCT
0.0 1142784.0 105876.0 0.0 1048576.0 11964.0 4120 9 5.965 33.707

jstat -gcnew

新生代相关信息

1
2
3
$ jstat -gcnew 19295
S0C S1C S0U S1U TT MTT DSS EC EU YGC YGCT
6144.0 6144.0 3996.1 0.0 15 15 6144.0 1687552.0 1278569.1 4120 27.742

TT: Tenuring threshold。新生代的持有代数。默认是15。15代后的对象进入老年代。

DSS:Desired survivor size (kB)。期望的S区大小。

jstat -gcnewcapacity

1
2
3
$ jstat -gcnewcapacity 19295
NGCMN NGCMX NGC S0CMX S0C S1CMX S1C ECMX EC YGC FGC
106496.0 1699840.0 1699840.0 566272.0 6144.0 566272.0 6144.0 1698816.0 1687552.0 4121 9

jstat -gcold

1
2
3
4
$ jstat -gcold 19295
MC MU CCSC CCSU OC OU YGC FGC FGCT GCT
105876.0 100496.0 11964.0 11033.2 388608.0 154615.5 4121 9 5.965 33.718

jstat -gcoldcapacity

1
2
3
$ jstat -gcoldcapacity 19295
OGCMN OGCMX OGC OC YGC FGC FGCT GCT
212992.0 3399680.0 388608.0 388608.0 4121 9 5.965 33.718

gcutil命令

1
2
3
$ jstat -gcutil 19295
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
93.02 0.00 65.43 39.71 94.92 92.22 4116 27.698 9 5.965 33.663

-gccause

1
2
3
$ jstat -gccause 19295
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT LGCC GCC
0.00 67.96 73.01 39.79 94.92 92.22 4121 27.753 9 5.965 33.718 Allocation Failure No GC

jstat -compiler

Just-in-Time(即时)编译器的信息

1
2
3
$ jstat -compiler 19295
Compiled Failed Invalid Time FailedType FailedMethod
25969 5 0 197.59 1 com/mysql/jdbc/AbandonedConnectionCleanupThread run

-printcompilation

1
2
3
$ jstat -printcompilation 19295
Compiled Size Type Method
25969 84 1 java/util/LinkedList toArray

Method: 最近进行了JIT编译的方法

周期性输出

上面的命令都可以在后面加上周期操作: 周期(单位是毫秒) 次数。

1
jstat -gc 19295 5000 10

就是每5秒循环一次,一共输出10次

今天要和第三方公司对接一个服务。通过rest服务传文件和一些参数过去。难度不大,先用postman调用了一下,顺利返回结果。于是开写,因为比较熟悉apache.httpcomponents的httpclient,写的也比较顺手。所以直接写了代码,测试总是失败。因为服务提供者没有人员支持,我只能得到一个失败错误,没有任何有效信息。

一次次检查自己的代码,确实没有什么问题。眼看着交工的dead line要到了,没办法。赶紧把以前的一份用java原生的HttpUrlConnection发送POST请求的代码拿来改了改,测试成功。

但是心里觉得太奇怪,没道理httpclient不好使啊。

我倒要看看他们发出的包到底有什么不一样。

使用Fiddler抓包

抓包工具我这边使用的是fiddler。关于fiddler的基本操作这里就不讲了。

使用postman的请求包:

image-20200313173218949

image-20200313210053002

对代码进行抓包。这里有点操作需要讲讲了。

首先看下你的抓包工具监听的端口是啥,默认是8888.

image-20200313222148038

然后需要对代码进行一些改造。fiddler可以方便的抓取浏览器,操作系统的http请求,但是我们在代码里发出的http,fiddler是抓不到的。需要在代码里设置代理。

java HttpUrlConnection的请求包:

设置代理的代码:

1
2
3
4
Proxy proxy = new Proxy(java.net.Proxy.Type.HTTP,
new InetSocketAddress("127.0.0.1", 8888));
URL realUrl = new URL(url);
HttpURLConnection urlConnection = (HttpURLConnection) realUrl.openConnection(proxy);

image-20200313202539770

image-20200313202710364

使用apache commons 的HttpClient

设置代理的代码:

1
2
3
4
5
6
7
8
//设置代理IP、端口、协议(请分别替换)
HttpHost proxy = new HttpHost("127.0.0.1", 8888, "http");

//把代理设置到请求配置
RequestConfig defaultRequestConfig = RequestConfig.custom()
.setProxy(proxy)
.build();
CloseableHttpClient client = HttpClients.custom().setDefaultRequestConfig(defaultRequestConfig).build();

image-20200313205829683

image-20200313205919387

通过抓包,发现了问题的根源原来是中文乱码。又是编码问题。

问题解决:

通过自定义一个contentType

1
ContentType contentType = ContentType.create("text/plain", Charset.forName("UTF-8"));

然后在addTextBody时,指明使用自定义的这个contentType

1
builder.addTextBody(entry.getKey(), entry.getValue(), ContentType.TEXT_PLAIN);

测试,好了

image-20200313210525502

text/plain和一个ContentType.TEXT_PLAIN很像啊,

改成ContentType.TEXT_PLAIN试试,发现也不行。我们来对比下:

1
ContentType.create("text/plain", Charset.forName("UTF-8"));
1
ContentType TEXT_PLAIN = create("text/plain", Consts.ISO_8859_1);

最后发现是编码格式的问题。

一句话,记住:通过httpClient发送form表单中有中文的,要设置编码格式为ContentType.create("text/plain", Charset.forName("UTF-8"));

Graphics2D 文字抗锯齿

![image-20200605170401969](Graphics2D 文字抗锯齿.assets/image-20200605170401969.png)

添加抗锯齿代码

1
2
// 抗锯齿
graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB);

效果如下

![image-20200605170312480](Graphics2D 文字抗锯齿.assets/image-20200605170312480.png)

效果还是有一些的。就是有点模糊

文字删除线

1
2
3
4
5
6
7
Font marketPriceFont = new Font("微软雅黑", Font.PLAIN, 16);
graphics.setColor(Color.BLACK);
String marketPriceStr = "¥ " + marketPrice;
AttributedString as = new AttributedString(marketPriceStr);
as.addAttribute(TextAttribute.FONT, marketPriceFont);
as.addAttribute(TextAttribute.STRIKETHROUGH, TextAttribute.STRIKETHROUGH_ON, 0, marketPriceStr.length());
graphics.drawString(as.getIterator(), 100, 450);

![image-20200605172311145](Graphics2D 文字抗锯齿.assets/image-20200605172311145.png)

加背景色

1
2
3
4
5
6
Font actFont = new Font("微软雅黑", Font.PLAIN, 16);
graphics.setColor(new Color(0xFF0052));
graphics.setFont(actFont);
graphics.fillRect(60, 460, 80, 25);
graphics.setColor(Color.white);
graphics.drawString(actName, 70, 480);

![image-20200605175344187](Graphics2D 文字抗锯齿.assets/image-20200605175344187.png)

排查go依赖

image-20231108181941965