【JVM】亿级流量调优(一)
亿级流量调优
oop模型
前面的klass模型,它是Java类的元信息在JVM中的存在形式。这个oop模型是Java对象在JVM中的存在形式
内存分配策略:
- 1.空闲列表
- 2.指针碰撞(jvm采用的)
2.1 top指针:执行的是可用内存的起始位置
2.2 采用CAS的方式 - 3.TLAB 线程私有堆
- 4.PLAB 老年代的线程私有堆
对象的创建
Java是一门面向对象的编程语言,在Java程序运行过程中无时无刻都有对象被创建出来。在语言层面上,创建对象(例如克隆、反序列化)通常仅仅是一个new关键字而已,而在虚拟机中,对象(引用类型的对象)的创建又是一个怎样的过程呢?
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号一弄,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行响应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针想空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为"指针碰撞"(Bump the Pointer)。如果Java堆中的内存并不是规整的,已使用内存和空闲的内存相互交错,那就没有办法简单进行指针碰撞了,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为"空闲列表"(Free List).选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法时指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表
(内存分配算法也跟对象的存活周期有关,新生代大部分对象都朝生夕死,复制算法进行GC完之后,内存就是规整的,而老年代,存活对象相比新生代来说存活率要高,内存不太容易规整,如果不带整理的话,使用指针碰撞失败的概率会高很多。所以老年代采用空闲列表)
除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案,一种是堆分配内存空间的动作进行同步处理——实际上迅即采用CAS配上失败重试的方式保证更新操作的原子性;另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用了TLAB,可以通过-XX:+/-UseTLAB参数来设定
内存分配完成之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
接下来,虚拟机要对对象进行必要的设置,例如这个对象时哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态不同,如是否启用偏向锁等,对象头会有不同的设置方式。
在上面的工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始——方法还没有执行,所有的字段都还为0,所以,一般来说(由字节码中是否跟随invokespecial指令所决定),执行new指令之后会接着执行方法,把对象按照程序员的医院进行初始化,这样一个真正可用的对象才算完全生成出来
HotSpot源码,_new字节码指令分析
- 1.确保常量池中存放的是已解释的类
- 2.确保对象所属类型已经经过初始化阶段
- 3.取对象长度
- 4.记录是否需要将对象所有字段置为零
- 5.是否在TLAB中分配对象,否则直接在Eden中分配对象.cmpxchg是x86中的CAS指令,这里是一个C++方法,通过CAS方式分配空间,如果并发失败,转到retry中充实,知道成功分配为止
- 6…如果需要,则为对象初始化零值
- 7.根据是否启用偏向锁来设置对象头信息
- 8.将对象引用入栈,继续执行下一条指令
1.空闲列表
OS把不常用的内存写到硬盘上,如果有进程需要读取引发缺页异常,进而会去硬盘上读
空闲列表机制
在操作系统中,内存分配策略的空闲列表机制是一种管理内存资源的方法。以下是其基本原理和步骤
-
1.基本原理:
-
1.1 内存块管理:操作系统将内存划分为多个块(block),每个块可以是空闲的,也可以是已分配的
-
1.2 空闲列表:操作系统维护一个记录所有空闲内存块的列表,称为空闲列表。这个列表通常会记录每个空闲块的大小和起始地址。
-
2.步骤:
-
2.1 初始化:当系统启动时,除了操作系统本身占用的内存外,其余的内存都被视为一个大的空闲快,并被加入到空闲列表中
-
2.2 分配内存:
-
2.2.1 当一个进程请求内存时,操作系统会根据请求的大小在空闲列表中查找何时的空闲块
-
2.2.2 查找策略可以是首次适配(first fit)、最佳适配(best fit)或最坏适配(worst fit)
-
2.2.3 一旦找到合适的空闲块,操作系统会从空闲列表中移除该块,并将其标记为已分配,然后将内存分配给请求的进程
-
2.3 内存释放
-
2.3.1 当进程释放内存时,操作系统会回收这块内存,并将其标记为空闲
-
2.3.2 操作系统可能会将这块空闲与周围的空闲块合并,形成一个更大的空闲块,以减少内存碎片
2.3.3 合并后的空闲块或新的空闲块会被重新加入到空闲列表中 -
2.4 碎片整理
-
2.4.1 随着内存的分配和释放,内存可能会出现碎片化,即空闲内存分散在各个角落,导致无法满足大的内存请求
-
2.4.2 空闲列表机制可能会通过移动已分配的内存块来整理碎片,但这在实际操作中可能比较复杂且耗时
-
优点:
简单性:空闲列表机制相对简单,易于实现
灵活性:可以根据不同的内存分配策略(如首次适配、最佳适配等)来优化内存使用 -
缺点:
维护开销:随着内存分配和释放的频繁进行,空闲列表的维护可能会带来一定的开销
内存碎片:可能导致内存碎片,尤其时当空闲块和已分配块的大小频繁变动时。
操作系统为什么不采用指针碰撞机制进行内存分配?
操作系统不采用指针碰撞的机制进行内存分配,主要是因为操作系统的内存管理需要面对复杂和多样化的环境。以下是一些关键原因:
- 1.多任务和多用户
操作系统:必须支持多个进程和线程的运行,每个进程或线程可能需要不同大小的内存,且分配和释内存的时间点是随机的
指针碰撞:适用于单一连续内存分配的场景,不适合处理多任务环境下的复杂内存需求 - 2.内存碎片
操作系统:需要处理内存碎片问题,因为不同大小的内存块被分配和释放后,内存中可能会留下无法被利用的小空闲块
指针碰撞:不擅长处理内存碎片,因为它假设内存分配是连续的,如果内存碎片严重,指针碰撞机制将无法有效工作 - 3.内存分配的灵活性
操作系统:需要能够分配任意大小的内存块以满足不同进程的需求
指针碰撞:通常需要一个连续的内存区域,并且当内存区域不足以容纳新分配的内存块是,需要额外的机制来处理这种情况 - 4.物理内存与虚拟内存
操作系统:使用虚拟内存技术,将物理内存与虚拟内存地址映射起来,这使得内存分配更加复杂
指针碰撞:适用于简单的物理内存分配,不涉及复杂的地址映射 - 5.安全性和隔离性
操作系统:需要确保不同进程的内存是隔离的,防止一个进程访问或修改另一个进程的内存
指针碰撞:需要额外的机制来保证内存的安全性和隔离性 - 6.性能考量
操作系统:必须高效地管理内存以满足性能需求,这通常意味着需要一个能够快速响应的内存分配策略
指针碰撞:虽然分配速度快,但在多任务环境下,它可能导致内存利用率低下,因为它可能留下很多小的空闲内存块 - 7.系统调用和API
操作系统:提供了系统调用和API供应用程序请求和释放内存,这些调用需要能够处理各种复杂的内存分配请求
指针碰撞:无法直接适应这些系统调用和API的需求
2.指针碰撞(CAS)
如图所示,bottom指向内存区域的头部,end指向内存区域的尾部,top指针开始指向头部(可用内存的起始位置)。如果new_top = top(当前top) + 对象大小满足这个等式,则分配成功,并top=new_top
为什么JVM不采用空闲列表的内存分配策略而是采用指针碰撞的形式?
Java虚拟机(JVM)内存分配策略与操作系统分配策略的不同,主要由以下几个因素决定的:
- 1.内存管理的抽象层级不同:
操作系统:操作系统负责管理物理内存,直接与硬件交互,需要处理多种复杂情况,如内存碎片、多进程/线程的内存需求等
JVM:JVM运行在操作系统之上,主要负责管理Java程序的运行时内存,它对内存的管理更加抽象化,并且通常不需要处理硬件级别的内存碎片问题 - 2.内存分配的特点
空闲列表:适用于需要频繁分配和释放不同大小内存的场景,且物理内存可能存在碎片
指针碰撞:适用于对象大小相对一致且频繁创建和销毁的场景,如JVM中的对象分配 - 3.JVM内存分配的具体考虑
效率:指针碰撞时一种非常高效的内存分配方式。在JVM中,当一个新的对象需要被分配时,只需要移动以下指针(分配指针),而不需要遍历整个空闲列表来查找合适的内存块。这大大减少了内存分配的开销
内存连续性:指针碰撞可以保证分配的内存是连续的,这对于提高缓存命中率有好处,因为连续的内存访问往往能更好地利用CPU缓存
内存碎片:由于JVM通常会分配和释放大量大小相似的对象,内存碎片问题不像在操作系统中那么严重。JVM通过垃圾回收来管理内存,看可以在GC过程中重新整理内存,减少碎片
垃圾回收:JVM采用垃圾回收机制来自动管理内存。当对象不再被引用时,垃圾回收期会自动回收他们所占用的内存。这种方式与空闲列表的内存分配策略相比,减少了手动内存释放的复杂性,并且通过不同的垃圾回收u算法来优化没存使用 - 4.JVM的内存模型:
堆空间:JVM的堆空间是用于存储Java对象的地方,通常分为老年代和新生代等,不同代的内存管理策略不同。新生代采用复制算法,老年代采用标记-清除或标记-整理算法。这些算法与指针碰撞的内存分配策略更为契合。
JVM选择指针碰撞的内存分配策略,而不是空闲列表,是因为这种策略更符合JVM内存管理的需求,能够提供更高的内存分配和回收效率,并且与JVM的垃圾回收机制更为兼容
3.TLAB线程私有堆(新生代)
JVM里面如果用锁来控制对象内存的分配的话,会比较繁琐,它是堆中某一块私有内存区域,如果用完了再还给JVM,重新盛情一块更大的内存
4.PLAB(TLAB的老年代)
对象的内存布局
- 1.MarkWord:
32位机器下占4B,64位机器下占8B - 2.类型指针:
开启指针压缩占4B,关闭:占8B - 3.数组长度
如果是数组对象:4B,非数组对象的话是没有的 - 4.对象头中的对齐填充(数组对象才有)对象头尾部
- 5.实例数据:非静态属性数据
boolean:1B
byte:1B
char:Java(2B) C++(1B)
int:4B
float:4B
long:8B
double:8B
引用类型:开启指针压缩:4B 关闭8B - 6.对齐填充区域中的对齐填充
跟jvm规范有关,所有的对象大小都必须能被8整除,也叫8字节对齐
计算对象大小
没有属性的对象
MarkWord:(64位)8B
类型指针:(默认是开启指针压缩的)4B
由于8B+4B=12B,不够被8整除,所以对齐填充4B
对象大小:8B+4B+4B=16B
有属性的对象
Mark Word:8B
类型指针:4B
实例数据:4B+4B
对象大小
数组对象
Mark Word:8B
类型指针:注意这里的数组类型是int类型,也就是说它的OopDesc是TypeArrayOopDesc,4B
数组长度:4B
实例数据:4B+4B+4B
8B+4B+4B+4B+4B+4B=28B,还是不能被8整除,所以对齐填充需要补4B
8B+4B+4B+4B+4B+4B+4B=32B