一篇文章让你所有看懂!内存-java模型-jvm结构

计算机内存

相信每一个人都有一台电脑,也有diy电脑的经历。如今一台功能强大的diy电脑大概3k就能组装起来,一个i5-8400 的cpu 869元,DDR4 内存 1200块钱,b360主板300元 散热器50元 机械硬盘200元 350w电源300元 机箱100元 ,没错,只要3k就能拿到一个性能强大的6C6T电脑。java

要说一台PC中最重要的部件是什么?你们看价格也会看明白,是cpu和内存,下面我来介绍一下cpu和内存之间的关系。程序员

cpu与内存缓存的千丝万缕

cpu相关术语

首先说明一下相关的cpu术语:算法

  • socket:cpu插在主板上那个槽与cpu称做一个socket。
  • Die:核心(Die)又称为内核,是cpu的物理组成部分之一。cpu也会分为多die cpu与单die cpu,譬如咱们如今强大的AMD TR-2990WX就是4die cpu,每一个die里面有8个核心(core)
  • core:也就是物理核心了。core这个词是英特尔起的,起初是为了与竞争对手AMD区别开,后面用的多了也淡了。
  • thread:就是硬件线程数。一个程序执行可能须要多个线程一块儿进行~而如今也就比较强大的超线程技术,过去的cpu每每一个cpu核心只支持一个线程,如今一些强大的cpu中,就譬如IBM 的POWER 9 ,支持8核心32个线程(平均一个核心4个线程),理论性能很是强大。

总结一下,以明星cpu AMD TR-2990WX做为栗子,这个cpu使用一个socket,一个socket里面有4个die,总共32个物理核心64个线程数据库

cpu缓存

咱们都知道,cpu将要处理的数据会放到内存中保存,但是,为何会这样,将内存缓存硬盘行不行呢?编程

答案固然是不行的。cpu的处理速度很强大,内存的速度虽然很是快速可是根本跟不上cpu的步伐,因此,就出现的缓存。与来自DRAM家族的内存不一样,缓存SRAM与内存最大的特色是,特别快,容量小,结构复杂,成本也高。数组

形成内存和缓存性能差别,主要有如下缘由:缓存

  1. DRAM储存一位数据只须要一个电容加上一个晶体管,而SRAM须要6个晶体管。因为DRAM保存数据实际上是在电容里面的,电容须要充放电才能进行读写操做,这就致使其读写数据就有比较大的延迟问题。
  2. 存储能够看错一个二维数组,每一个存储单元都有其行地址列地址。SRAM的容量很小,其存储单元比较短(行列短),能够一次性传输到SRAM中;而DRAM,须要分别传送行列地址。
  3. SRAM的频率和cpu频率比较接近;而DRAM的频率和cpu差距比较大。

近代的缓存一般被集成到cpu当中,为了适应性能与成本的须要,现实中的缓存每每使用金字塔型多级缓存架构。也就是 当CPU要读取一个数据时,首先从一级缓存中查找,若是没有找到再从二级缓存中查找,若是仍是没有就从三级缓存或内存中查找。性能优化

下面是英特尔最近以来用的初代skylake架构服务器

能够看到,每一个个核心有专属的L1,L2缓存,他们共享一个L3缓存。若是cpu若是要访问内存中的数据,必需要通过L1,L2,L3,LLC(或者L4)四层缓存。网络

缓存一致性问题

最开始的cpu,其实只是一个核心一个线程的,当时根本不须要考虑缓存一致性问题, 单线程,也就是cpu核心的缓存只被一个线程访问。缓存独占,不会出现访问冲突等问题。

后来超线程技术来到咱们视野, ''单核CPU多线程'' ,也就是进程中的多个线程会同时访问进程中的共享数据,CPU将某块内存加载到缓存后,不一样线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即便发生线程的切换,缓存仍然不会失效。但因为任什么时候刻只能有一个线程在执行,所以不会出现缓存访问冲突。

时代不断发展,**“多核CPU多线程”**来了,即多个线程访问进程中的某个共享内存,且这多个线程分别在不一样的核心上执行,则每一个核心都会在各自的caehe中保留一份共享内存的缓冲。因为多核是能够并行的,可能会出现多个线程同时写各自的缓存的状况,而各自的cache之间的数据就有可能不一样。

这就是咱们说的 缓存一致性 问题。

目前公认最好的解决方案是英特尔的 MESI协议 ,下面咱们着重介绍。

MESI协议

首先说说I/O操做的单位问题,大部分人都知道,在内存中操做I/O不是以字节为单位,而是以“块”为单位,这是为何呢?

其实这是由于I/O操做的数据访问有空间连续性特征,即须要访问内存空间不少数据,可是I/O操做比较慢,读一个字节和读N个字节的时间基本相同。

机智的intel就规定了,cpu缓存中最小的存储单元是 缓存行 cache line ,在x86的cpu中,一个 cache line 储存64字节,每一级的缓存都会被划分红许多组 cache line 。

缓存工做原理请看:point_right:维基百科

接下来咱们看看MESI规范,这实际上是用四种缓存行状态命名的,咱们定义了CPU中每一个缓存行使用4种状态进行标记(使用额外的两位(bit)表示),分别是:

  • M: 被修改(Modified)

    该缓存行只被缓存在该CPU的缓存中,而且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存须要在将来的某个时间点(容许其它CPU读取请主存中相应内存以前)写回(write back)主存。当被写回主存以后,该缓存行的状态会变成独享(exclusive)状态。

  • E: 独享的(Exclusive)

    该缓存行只被缓存在该CPU的缓存中,它是未被修改过的(clean),与主存中数据一致。该状态能够在任什么时候刻当有其它CPU读取该内存时变成共享状态(shared)。一样地,当CPU修改该缓存行中内容时,该状态能够变成Modified状态。

  • S: 共享的(Shared)

    该状态意味着该缓存行可能被多个CPU缓存,而且各个缓存中的数据与主存数据一致(clean),当有一个CPU修改该缓存行中,其它CPU中该缓存行能够被做废(变成无效状态(Invalid))。

  • I: 无效的(Invalid)

    该缓存是无效的(可能有其它CPU修改了该缓存行)。

然而,只是有这四种状态也会带来必定的问题。下面引用一下oracle的文档。

同时更新来自不一样处理器的相同缓存代码行中的单个元素会使整个缓存代码行无效,即便这些更新在逻辑上是彼此独立的。每次对缓存代码行的单个元素进行更新时,都会将此代码行标记为 无效 。其余访问同一代码行中不一样元素的处理器将看到该代码行已标记为 无效 。即便所访问的元素未被修改,也会强制它们从内存或其余位置获取该代码行的较新副本。这是由于基于缓存代码行保持缓存一致性,而不是针对单个元素的。所以,互连通讯和开销方面都将有所增加。而且,正在进行缓存代码行更新的时候,禁止访问该代码行中的元素。

MESI协议,能够保证缓存的一致性,可是没法保证明时性。这种状况称为伪共享。

伪共享问题

伪共享问题其实在Java中是真实存在的一个问题。假设有以下所示的java class

class MyObiect{
    long a;
    long b;
    long c;
}
复制代码

按照java规范,MyObiect对象是在堆空间中分配的,a、b、c这三个变量在内存空间中是近邻,分别占8字节,长度之和为24字节。而咱们的x86的缓存行是64字节,这三个变量彻底有可能会在一个缓存行中,而且被两个不一样的cpu核心共享!

根据MESI协议,若是不一样物理核心cpu中的线程1和线程2要互斥的对这几个变量进行操做,颇有可能要互相抢占资源,致使原来的并行变成串行,大大下降了系统的并发性,这就是缓存的伪共享。

解决伪共享

其实解决伪共享很简单,只须要将这几个变量分别放到不一样的缓存行便可。在java8中,就已经提供了普适性的解决方案,即采用 @Contended 注解来保证对象中的变量或者属性不在一个缓存行中~

@Contended
class VolatileObiect{
    volatile long a = 1L;
    volatile long b = 2L;
    volatile long c = 3L;
}
复制代码

内存不一致性问题

上面我说了MESI协议在多核心cpu中解决缓存一致性的问题,下面咱们说说cpu的内存不一致性问题。

三种cpu架构

首先,要了解三个名词:

  • SMP(Symmetric Multi-Processor)

SMP ,对称多处理系统内有许多紧耦合多处理器,在这样的系统中,全部的CPU共享所有资源,如总线,内存和I/O系统等,操做系统或管理数据库的复本只有一个,这种系统有一个最大的特色就是共享全部资源。多个CPU之间没有区别,平等地访问内存、外设、一个操做系统。操做系统管理着一个队列,每一个处理器依次处理队列中的进程。若是两个处理器同时请求访问一个资源(例如同一段内存地址),由硬件、软件的锁机制去解决资源争用问题。

[

所谓对称多处理器结构,是指服务器中多个 CPU 对称工做,无主次或从属关系。各 CPU 共享相同的物理内存,每一个 CPU 访问内存中的任何地址所需时间是相同的,所以 SMP 也被称为一致存储器访问结构 (UMA : Uniform Memory Access) 。对 SMP 服务器进行扩展的方式包括增长内存、使用更快的 CPU 、增长 CPU 、扩充 I/O( 槽口数与总线数 ) 以及添加更多的外部设备 ( 一般是磁盘存储 ) 。

SMP 服务器的主要特征是共享,系统中全部资源 (CPU 、内存、 I/O 等 ) 都是共享的。也正是因为这种特征,致使了 SMP 服务器的主要问题,那就是它的扩展能力很是有限。对于 SMP 服务器而言,每个共享的环节均可能形成 SMP 服务器扩展时的瓶颈,而最受限制的则是内存。因为每一个 CPU 必须经过相同的内存总线访问相同的内存资源,所以随着 CPU 数量的增长,内存访问冲突将迅速增长,最终会形成 CPU 资源的浪费,使 CPU 性能的有效性大大下降。实验证实, SMP 服务器 CPU 利用率最好的状况是 2 至 4 个 CPU 。

[

  • NUMA(Non-Uniform Memory Access)

因为 SMP 在扩展能力上的限制,人们开始探究如何进行有效地扩展从而构建大型系统的技术, NUMA 就是这种努力下的结果之一。利用 NUMA 技术,能够把几十个 CPU( 甚至上百个 CPU) 组合在一个服务器内。其NUMA 服务器 CPU 模块结构如图所示:

NUMA 服务器的基本特征是具备多个 CPU 模块,每一个 CPU 模块由多个 CPU( 如 4 个 ) 组成,而且具备独立的本地内存、 I/O 槽口等。因为其节点之间能够经过互联模块 ( 如称为 Crossbar Switch) 进行链接和信息交互,所以每一个 CPU 能够访问整个系统的内存 ( 这是 NUMA 系统与 MPP 系统的重要差异 ) 。显然,访问本地内存的速度将远远高于访问远地内存 ( 系统内其它节点的内存 ) 的速度,这也是非一致存储访问 NUMA 的由来。因为这个特色,为了更好地发挥系统性能,开发应用程序时须要尽可能减小不一样 CPU 模块之间的信息交互。

利用 NUMA 技术,能够较好地解决原来 SMP 系统的扩展问题,在一个物理服务器内能够支持上百个 CPU 。比较典型的 NUMA 服务器的例子包括 HP 的 Superdome 、 SUN15K 、 IBMp690 等。

但 NUMA 技术一样有必定缺陷,因为访问远地内存的延时远远超过本地内存,所以当 CPU 数量增长时,系统性能没法线性增长。如 HP 公司发布 Superdome 服务器时,曾公布了它与 HP 其它 UNIX 服务器的相对性能值,结果发现, 64 路 CPU 的 Superdome (NUMA 结构 ) 的相对性能值是 20 ,而 8 路 N4000( 共享的 SMP 结构 ) 的相对性能值是 6.3 。从这个结果能够看到, 8 倍数量的 CPU 换来的只是 3 倍性能的提高。

  • MPP(Massive Parallel Processing)

和 NUMA 不一样, MPP 提供了另一种进行系统扩展的方式,它由多个 SMP 服务器经过必定的节点互联网络进行链接,协同工做,完成相同的任务,从用户的角度来看是一个服务器系统。其基本特征是由多个 SMP 服务器 ( 每一个 SMP 服务器称节点 ) 经过节点互联网络链接而成,每一个节点只访问本身的本地资源 ( 内存、存储等 ) ,是一种彻底无共享 (Share Nothing) 结构,于是扩展能力最好,理论上其扩展无限制,目前的技术可实现 512 个节点互联,数千个 CPU 。目前业界对节点互联网络暂无标准,如 NCR 的 Bynet , IBM 的 SPSwitch ,它们都采用了不一样的内部实现机制。但节点互联网仅供 MPP 服务器内部使用,对用户而言是透明的。

在 MPP 系统中,每一个 SMP 节点也能够运行本身的操做系统、数据库等。但和 NUMA 不一样的是,它不存在异地内存访问的问题。换言之,每一个节点内的 CPU 不能访问另外一个节点的内存。节点之间的信息交互是经过节点互联网络实现的,这个过程通常称为数据重分配 (Data Redistribution) 。

可是 MPP 服务器须要一种复杂的机制来调度和平衡各个节点的负载和并行处理过程。目前一些基于 MPP 技术的服务器每每经过系统级软件 ( 如数据库 ) 来屏蔽这种复杂性。举例来讲, NCR 的 Teradata 就是基于 MPP 技术的一个关系数据库软件,基于此数据库来开发应用时,无论后台服务器由多少个节点组成,开发人员所面对的都是同一个数据库系统,而不须要考虑如何调度其中某几个节点的负载。

MPP (Massively Parallel Processing),大规模并行处理系统,这样的系统是由许多松耦合的处理单元组成的,要注意的是这里指的是处理单元而不是处理器。每一个单元内的CPU都有本身私有的资源,如总线,内存,硬盘等。在每一个单元内都有操做系统和管理数据库的实例复本。这种结构最大的特色在于不共享资源。

NUMA结构下的缓存一致性

要知道,MESI协议解决的是传统SMP结构下缓存的一致性,为了在NUMA架构也实现缓存一致性,intel引入了MESI的一个拓展协议--MESIF,可是目前并无什么资料,也无法研究,更多消息请查阅intel的wiki。

Java内存模型

原由

咱们写程序,为何要考虑内存模型呢,咱们前面说了,缓存一致性问题、内存一致问题是硬件的不断升级致使的。解决问题,最简单直接的作法就是废除CPU缓存,让CPU直接和主存交互。可是,这么作虽然能够保证多线程下的并发问题。可是,这就有点时代倒退了。

因此,为了保证并发编程中能够知足原子性、可见性及有序性。有一个重要的概念,那就是——内存模型。

即为了保证共享内存的正确性(可见性、有序性、原子性),须要内存模型来定义了共享内存系统中多线程程序读写操做行为的相应规范~

JMM

Java内存模型是根据英文Java Memory Model(JMM)翻译过来的。其实JMM并不像JVM内存结构同样是真实存在的。它 是一种符合内存模型规范的,屏蔽了各类硬件和操做系统的访问差别的,保证了Java程序在各类平台下对内存的访问都能保证效果一致的机制及规范 。就像JSR-133: Java Memory Model and Thread Specification 中描述了,JMM是和多线程相关的,他描述了一组规则或规范,这个规范定义了一个线程对共享变量的写入时对另外一个线程是可见的。

那么,简单总结下,Java的多线程之间是经过共享内存进行通讯的,而因为采用共享内存进行通讯,在通讯过程当中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通讯以及与其相关的一系列特性而创建的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是 volatile 、 synchronized 等关键字。

在JMM中,咱们把多个线程间通讯的共享内存称之为主内存,而在并发编程中多个线程都维护了一个本身的本地内存(这是个抽象概念),其中保存的数据是主内存中的数据拷贝。而 JMM主要是控制本地内存和主内存之间的数据交互的 。

在Java中,JMM是一个很是重要的概念,正是因为有了JMM,Java的并发编程才能避免不少问题。

JMM应用

了解Java多线程的朋友都知道,在Java中提供了一系列和并发处理相关的关键字,好比 volatile 、 synchronized 、 final 、 concurrent 包等。其实这些就是Java内存模型封装了底层的实现后提供给咱们使用的一些关键字。

在开发多线程的代码的时候,咱们能够直接使用 synchronized 等关键字来控制并发,历来就不须要关心底层的编译器优化、缓存一致性等问题。因此, Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。

并发编程要解决原子性、有序性和可见性的问题,咱们就再来看下,在Java中,分别使用什么方式来保证。

原子性

原子性是指在一个操做中就是cpu不能够在中途暂停而后再调度,既不被中断操做,要不执行完成,要不就不执行。

JMM提供保证了访问基本数据类型的原子性(其实在写一个工做内存变量到主内存是分主要两步:store、write),可是实际业务处理场景每每是须要更大的范围的原子性保证。

在Java中,为了保证原子性,提供了两个高级的字节码指令 monitorenter 和 monitorexit ,而这两个字节码,在Java中对应的关键字就是 synchronized 。

所以,在Java中可使用 synchronized 来保证方法和代码块内的操做是原子性的。这里推荐一篇文章 深刻理解Java并发之synchronized实现原理 。

可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其余线程可以当即看获得修改的值。

Java内存模型是经过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存做为传递媒介的方式来实现的。

Java中的 volatile 关键字提供了一个功能,那就是被其修饰的变量在被修改后能够当即同步到 主内存 ,被其修饰的变量在每次是用以前都从主内存刷新。所以,可使用 volatile 来保证多线程操做时变量的可见性。

除了 volatile ,Java中的 synchronized 和 final 、 static 三个关键字也能够实现可见性。下面分享一下个人读书笔记:

有序性

有序性即程序执行的顺序按照代码的前后顺序执行。

在Java中,可使用 synchronized 和 volatile 来保证多线程之间操做的有序性。实现方式有所区别:

volatile 关键字会禁止指令重排。 synchronized 关键字保证同一时刻只容许一条线程操做。

好了,这里简单的介绍完了Java并发编程中解决原子性、可见性以及有序性可使用的关键字。读者可能发现了,好像 synchronized 关键字是万能的,他能够同时知足以上三种特性,这其实也是不少人滥用 synchronized 的缘由。

可是 synchronized 是比较影响性能的,虽然编译器提供了不少锁优化技术,可是也不建议过分使用。

JVM

咱们都知道,Java代码是要运行在虚拟机上的,而虚拟机在执行Java程序的过程当中会把所管理的内存划分为若干个不一样的数据区域,这些区域都有各自的用途。下面咱们来讲说JVM运行时内存区域结构

JVM运行时内存区域结构

在《Java虚拟机规范(Java SE 8)》中描述了JVM运行时内存区域结构以下:

1.程序计数器

程序计数器(Program Counter Register),也有称做为PC寄存器。想必学过汇编语言的朋友对程序计数器这个概念并不陌生,在汇编语言中,程序计数器是指CPU中的寄存器,它保存的是程序当前执行的指令的地址(也能够说保存下一条指令的所在存储单元的地址),当CPU须要执行指令时,须要从程序计数器中获得当前须要执行的指令所在存储单元的地址,而后根据获得的地址获取到指令,在获得指令以后,程序计数器便自动加1或者根据转移指针获得下一条指令的地址,如此循环,直至执行完全部的指令。

虽然JVM中的程序计数器并不像汇编语言中的程序计数器同样是物理概念上的CPU寄存器,可是JVM中的程序计数器的功能跟汇编语言中的程序计数器的功能在逻辑上是等同的,也就是说是用来指示 执行哪条指令的。

因为在JVM中,多线程是经过线程轮流切换来得到CPU执行时间的,所以,在任一具体时刻,一个CPU的内核只会执行一条线程中的指令,所以,为了可以使得每一个线程都在线程切换后可以恢复在切换以前的程序执行位置,每一个线程都须要有本身独立的程序计数器,而且不能互相被干扰,不然就会影响到程序的正常执行次序。所以,能够这么说,程序计数器是每一个线程所私有的。

在JVM规范中规定,若是线程执行的是非native方法,则程序计数器中保存的是当前须要执行的指令的地址;若是线程执行的是native方法,则程序计数器中的值是undefined。

因为程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,所以,对于程序计数器是不会发生内存溢出现象(OutOfMemory)的。

2.Java栈

Java栈也称做虚拟机栈(Java Vitual Machine Stack),也就是咱们经常所说的栈,跟C语言的数据段中的栈相似。事实上,Java栈是Java方法执行的内存模型。为何这么说呢?下面就来解释一下其中的缘由。

Java栈中存放的是一个个的栈帧,每一个栈帧对应一个被调用的方法,在栈帧中包括局部变量表(Local Variables)、操做数栈(Operand Stack)、指向当前方法所属的类的运行时常量池(运行时常量池的概念在方法区部分会谈到)的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些额外的附加信息。当线程执行一个方法时,就会随之建立一个对应的栈帧,并将创建的栈帧压栈。当方法执行完毕以后,便会将栈帧出栈。所以可知,线程当前执行的方法所对应的栈帧一定位于Java栈的顶部。讲到这里,你们就应该会明白为何 在 使用 递归方法的时候容易致使栈内存溢出的现象了以及为何栈区的空间不用程序员去管理了(固然在Java中,程序员基本不用关系到内存分配和释放的事情,由于Java有本身的垃圾回收机制),这部分空间的分配和释放都是由系统自动实施的。对于全部的程序设计语言来讲,栈这部分空间对程序员来讲是不透明的。下图表示了一个Java栈的模型:

局部变量表,顾名思义,想必不用解释你们应该明白它的做用了吧。就是用来存储方法中的局部变量(包括在方法中声明的非静态变量以及函数形参)。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用。局部变量表的大小在编译器就能够肯定其大小了,所以在程序执行期间局部变量表的大小是不会改变的。

操做数栈,想必学过数据结构中的栈的朋友想必对表达式求值问题不会陌生,栈最典型的一个应用就是用来对表达式求值。想一想一个线程执行方法的过程当中,实际上就是不断执行语句的过程,而归根到底就是进行计算的过程。所以能够这么说,程序中的全部计算过程都是在借助于操做数栈来完成的。

指向运行时常量池的引用,由于在方法执行的过程当中有可能须要用到类中的常量,因此必需要有一个引用指向运行时常量。

方法返回地址,当一个方法执行完毕以后,要返回以前调用它的地方,所以在栈帧中必须保存一个方法返回地址。

因为每一个线程正在执行的方法可能不一样,所以每一个线程都会有一个本身的Java栈,互不干扰。

3.本地方法栈

本地方法栈与Java栈的做用和原理很是类似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。在JVM规范中,并无对本地方发展的具体实现方法以及数据结构做强制规定,虚拟机能够自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。

4.堆

在C语言中,堆这部分空间是惟一一个程序员能够管理的内存区域。程序员能够经过malloc函数和free函数在堆上申请和释放空间。那么在Java中是怎么样的呢?

Java中的堆是用来存储对象自己的以及数组(固然,数组引用是存放在Java栈中的)。只不过和C语言中的不一样,在Java中,程序员基本不用去关心空间释放的问题,Java的垃圾回收机制会自动进行处理。所以这部分空间也是Java垃圾收集器管理的主要区域。另外,堆是被全部线程共享的,在JVM中只有一个堆。

5.方法区

方法区在JVM中也是一个很是重要的区域,它与堆同样,是被线程共享的区域。在方法区中,存储了每一个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。

在Class文件中除了类的字段、方法、接口等描述信息外,还有一项信息是常量池,用来存储编译期间生成的字面量和符号引用。

在方法区中有一个很是重要的部分就是运行时常量池,它是每个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被建立出来。固然并不是Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,好比String的intern方法。

在JVM规范中,没有强制要求方法区必须实现垃圾回收。不少人习惯将方法区称为“永久代”,是由于HotSpot虚拟机以永久代来实现方法区,从而JVM的垃圾收集器能够像管理堆区同样管理这部分区域,从而不须要专门为这部分设计垃圾回收机制。不过自从JDK7以后,Hotspot虚拟机便将运行时常量池从永久代移除了。

Java对象模型的内存布局

java是一种面向对象的语言,而Java对象在JVM中的存储也是有必定的结构的。而这个关于Java对象自身的存储模型称之为Java对象模型。

HotSpot虚拟机中,设计了一个OOP-Klass Model。OOP(Ordinary Object Pointer)指的是普通对象指针,而Klass用来描述对象实例的具体类型。

每个Java类,在被JVM加载的时候,JVM会给这个类建立一个 instanceKlass ,保存在方法区,用来在JVM层表示该Java类。当咱们在Java代码中,使用new建立一个对象的时候,JVM会建立一个 instanceOopDesc 对象,对象在内存中存储的布局能够分为3块区域:对象头(Header)、 实例数据(Instance Data)和对齐填充(Padding)。

  1. 对象头:标记字(32位虚拟机4B,64位虚拟机8B) + 类型指针(32位虚拟机4B,64位虚拟机8B)+ [数组长(对于数组对象才须要此部分信息)]
  2. 实例数据:存储的是真正有效数据,如各类字段内容,各字段的分配策略为longs/doubles、ints、shorts/chars、bytes/boolean、oops(ordinary object pointers),相同宽度的字段老是被分配到一块儿,便于以后取数据。父类定义的变量会出如今子类定义的变量的前面。
  3. 对齐填充:对于64位虚拟机来讲,对象大小必须是8B的整数倍,不够的话须要占位填充

JVM内存垃圾收集器

为了理解现有收集器,咱们须要先了解一些术语。最基本的垃圾收集涉及识别再也不使用的内存并使其可重用。现代收集器在几个阶段进行这一过程,对于这些阶段咱们每每有以下描述:

  • 并行- 在JVM运行时,同时存在应用程序线程和垃圾收集器线程。 并行阶段是由多个gc线程执行,即gc工做在它们之间分配。 不涉及GC线程是否须要暂停应用程序线程。
  • 串行- 串行阶段仅在单个gc线程上执行。与以前同样,它也没有说明GC线程是否须要暂停应用程序线程。
  • STW - STW阶段,应用程序线程被暂停,以便gc执行其工做。 当应用程序由于GC暂停时,这一般是因为Stop The World阶段。
  • 并发 -若是一个阶段是并发的,那么GC线程能够和应用程序线程同时进行。 并发阶段很复杂,由于它们须要在阶段完成以前处理可能使工做无效(译者注:由于是并发进行的,GC线程在完成一阶段的同时,应用线程也在工做产生操做内存,因此须要额外处理)的应用程序线程。
  • 增量 -若是一个阶段是增量的,那么它能够运行一段时间以后因为某些条件提早终止,例如须要执行更高优先级的gc阶段,同时仍然完成生产性工做。 增量阶段与须要彻底完成的阶段造成鲜明对比。

Serial收集器

Serial收集器是最基本的收集器,这是一个单线程收集器,它仍然是JVM在Client模式下的默认新生代收集器。它有着优于其余收集器的地方:简单而高效(与其余收集器的单线程比较),Serial收集器因为没有线程交互的开销,专心只作垃圾收集天然也得到最高的效率。在用户桌面场景下,分配给JVM的内存不会太多,停顿时间彻底能够在几十到一百多毫秒之间,只要收集不频繁,这是彻底能够接受的。

ParNew收集器

ParNew是Serial的多线程版本,在回收算法、对象分配原则上都是一致的。ParNew收集器是许多运行在Server模式下的默认新生代垃圾收集器,其主要在于除了Serial收集器,目前只有ParNew收集器可以与CMS收集器配合工做。

Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代垃圾收集器,其使用的算法是复制算法,也是并行的多线程收集器。

Parallel Scavenge 收集器更关注可控制的吞吐量,吞吐量等于运行用户代码的时间/(运行用户代码的时间+垃圾收集时间)。直观上,只要最大的垃圾收集停顿时间越小,吞吐量是越高的,可是GC停顿时间的缩短是以牺牲吞吐量和新生代空间做为代价的。好比原来10秒收集一次,每次停顿100毫秒,如今变成5秒收集一次,每次停顿70毫秒。停顿时间降低的同时,吞吐量也降低了。

停顿时间越短就越适合须要与用户交互的程序;而高吞吐量则能够最高效的利用CPU的时间,尽快的完成计算任务,主要适用于后台运算。

Serial Old收集器

Serial Old收集器是Serial收集器的老年代版本,也是一个单线程收集器,采用“标记-整理算法”进行回收。其运行过程与Serial收集器同样。

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法进行垃圾回收。其一般与Parallel Scavenge收集器配合使用,“吞吐量优先”收集器是这个组合的特色,在注重吞吐量和CPU资源敏感的场合,均可以使用这个组合。

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短停顿时间为目标的收集器,CMS收集器采用标记--清除算法,运行在老年代。主要包含如下几个步骤:

  • 初始标记
  • 并发标记
  • 从新标记
  • 并发清除

其中初始标记和从新标记仍然须要“Stop the world”。初始标记仅仅标记GC Root能直接关联的对象,并发标记就是进行GC Root Tracing过程,而从新标记则是为了修正并发标记期间,因用户程序继续运行而致使标记变更的那部分对象的标记记录。

因为整个过程当中最耗时的并发标记和并发清除,收集线程和用户线程一块儿工做,因此整体上来讲,CMS收集器回收过程是与用户线程并发执行的。虽然CMS优势是并发收集、低停顿,很大程度上已是一个不错的垃圾收集器,可是仍是有三个显著的缺点:

  1. CMS收集器对CPU资源很敏感。在并发阶段,虽然它不会致使用户线程停顿,可是会由于占用一部分线程(CPU资源)而致使应用程序变慢。
  2. CMS收集器不能处理浮动垃圾。所谓的“浮动垃圾”,就是在并发标记阶段,因为用户程序在运行,那么天然就会有新的垃圾产生,这部分垃圾被标记事后,CMS没法在当次集中处理它们,只好在下一次GC的时候处理,这部分未处理的垃圾就称为“浮动垃圾”。也是因为在垃圾收集阶段程序还须要运行,即还须要预留足够的内存空间供用户使用,所以CMS收集器不能像其余收集器那样等到老年代几乎填满才进行收集,须要预留一部分空间提供并发收集时程序运做使用。要是CMS预留的内存空间不能知足程序的要求,这是JVM就会启动预备方案:临时启动Serial Old收集器来收集老年代,这样停顿的时间就会很长。
  3. 因为CMS使用标记--清除算法,因此在收集以后会产生大量内存碎片。当内存碎片过多时,将会给分配大对象带来困难,这是就会进行Full GC。

G1收集器

G1收集器与CMS相比有很大的改进:

· G1收集器采用标记--整理算法实现。

· 能够很是精确地控制停顿。

​ G1收集器能够实如今基本不牺牲吞吐量的状况下完成低停顿的内存回收,这是因为它极力的避免全区域的回收,G1收集器将Java堆(包括新生代和老年代)划分为多个区域(Region),并在后台维护一个优先列表,每次根据容许的时间,优先回收垃圾最多的区域 。

ZGC收集器

Java 11 新加入的ZGC垃圾收集器号称能够达到10ms 如下的 GC 停顿,ZGC给Hotspot Garbage Collectors增长了两种新技术:着色指针和读屏障。下面引用国外文章说的内容:

着色指针

着色指针是一种将信息存储在指针(或使用Java术语引用)中的技术。由于在64位平台上(ZGC仅支持64位平台),指针能够处理更多的内存,所以可使用一些位来存储状态。 ZGC将限制最大支持4Tb堆(42-bits),那么会剩下22位可用,它目前使用了4位: finalizable , remap , mark0 和 mark1 。 咱们稍后解释它们的用途。

着色指针的一个问题是,当您须要取消着色时,它须要额外的工做(由于须要屏蔽信息位)。 像SPARC这样的平台有内置硬件支持指针屏蔽因此不是问题,而对于x86平台来讲,ZGC团队使用了简洁的多重映射技巧。

多重映射

要了解多重映射的工做原理,咱们须要简要解释虚拟内存和物理内存之间的区别。 物理内存是系统可用的实际内存,一般是安装的DRAM芯片的容量。 虚拟内存是抽象的,这意味着应用程序对(一般是隔离的)物理内存有本身的视图。 操做系统负责维护虚拟内存和物理内存范围之间的映射,它经过使用页表和处理器的内存管理单元(MMU)和转换查找缓冲器(TLB)来实现这一点,后者转换应用程序请求的地址。

多重映射涉及将不一样范围的虚拟内存映射到同一物理内存。 因为设计中只有一个 remap , mark0 和 mark1 在任什么时候间点均可觉得1,所以可使用三个映射来完成此操做。 ZGC源代码中有一个很好的图表能够说明这一点。

读屏障

读屏障是每当应用程序线程从堆加载引用时运行的代码片断(即访问对象上的非原生字段non-primitive field):

void printName( Person person ) {
    String name = person.name;  // 这里触发读屏障
                                // 由于须要从heap读取引用 
                                // 
    System.out.println(name);   // 这里没有直接触发读屏障
}
复制代码

在上面的代码中,String name =person.name 访问了堆上的person引用,而后将引用加载到本地的name变量。此时触发读屏障。 Systemt.out那行不会直接触发读屏障,由于没有来自堆的引用加载(name是局部变量,所以没有从堆加载引用)。 可是System和out,或者println内部可能会触发其余读屏障。

这与其余GC使用的写屏障造成对比,例如G1。读屏障的工做是检查引用的状态,并在将引用(或者甚至是不一样的引用)返回给应用程序以前执行一些工做。 在ZGC中,它经过测试加载的引用来执行此任务,以查看是否设置了某些位。 若是经过了测试,则不执行任何其余工做,若是失败,则在将引用返回给应用程序以前执行某些特定于阶段的任务。

标记

如今咱们了解了这两种新技术是什么,让咱们来看看ZG的GC循环。

GC循环的第一部分是标记。标记包括查找和标记运行中的应用程序能够访问的全部堆对象,换句话说,查找不是垃圾的对象。

ZGC的标记分为三个阶段。 第一阶段是STW,其中GC roots被标记为活对象。 GC roots相似于局部变量,经过它能够访问堆上其余对象。 若是一个对象不能经过遍历从roots开始的对象图来访问,那么应用程序也就没法访问它,则该对象被认为是垃圾。从roots访问的对象集合称为Live集。GC roots标记步骤很是短,由于roots的总数一般比较小。

该阶段完成后,应用程序恢复执行,ZGC开始下一阶段,该阶段同时遍历对象图并标记全部可访问的对象。 在此阶段期间,读屏障针使用掩码测试全部已加载的引用,该掩码肯定它们是否已标记或还没有标记,若是还没有标记引用,则将其添加到队列以进行标记。

在遍历完成以后,有一个最终的,时间很短的的Stop The World阶段,这个阶段处理一些边缘状况(咱们如今将它忽略),该阶段完成以后标记阶段就完成了。

重定位

GC循环的下一个主要部分是重定位。重定位涉及移动活动对象以释放部分堆内存。 为何要移动对象而不是填补空隙? 有些GC实际是这样作的,可是它致使了一个不幸的后果,即分配内存变得更加昂贵,由于当须要分配内存时,内存分配器须要找到能够放置对象的空闲空间。 相比之下,若是能够释放大块内存,那么分配内存就很简单,只须要将指针递增新对象所需的内存大小便可。

ZGC将堆分红许多页面,在此阶段开始时,它同时选择一组须要重定位活动对象的页面。选择重定位集后,会出现一个Stop The World暂停,其中ZGC重定位该集合中root对象,并将他们的引用映射到新位置。与以前的Stop The World步骤同样,此处涉及的暂停时间仅取决于root的数量以及重定位集的大小与对象的总活动集的比率,这一般至关小。因此不像不少收集器那样,暂停时间随堆增长而增长。

移动root后,下一阶段是并发重定位。 在此阶段,GC线程遍历重定位集并从新定位其包含的页中全部对象。 若是应用程序线程试图在GC从新定位对象以前加载它们,那么应用程序线程也能够重定位该对象,这能够经过读屏障(在从堆加载引用时触发)

这可确保应用程序看到的全部引用都已更新,而且应用程序不可能同时对重定位的对象进行操做。

GC线程最终将对重定位集中的全部对象重定位,然而可能仍有引用指向这些对象的旧位置。 GC能够遍历对象图并从新映射这些引用到新位置,可是这一步代价很高昂。 所以这一步与下一个标记阶段合并在一块儿。在下一个GC周期的标记阶段遍历对象对象图的时候,若是发现未重映射的引用,则将其从新映射,而后标记为活动状态。

JVM内存优化

在《深刻理解Java虚拟机》一书中讲了不少jvm优化思路,下面我来简单说说。

java内存抖动

堆内存都有必定的大小,能容纳的数据是有限制的,当Java堆的大小太大时,垃圾收集会启动中止堆中再也不应用的对象,来释放内存。如今,内存抖动这个术语可用于描述在极短期内分配给对象的过程。 具体如何优化请谷歌查询~

jvm大页内存

什么是内存分页?

CPU是经过寻址来访问内存的。32位CPU的寻址宽度是 0~0xFFFFFFFF,即4G,也就是说可支持的物理内存最大是4G。但在实践过程当中,程序须要使用4G内存,而可用物理内存小于4G,致使程序不得不下降内存占用。为了解决此类问题,现代CPU引入了 MMU (Memory Management Unit,内存管理单元)。

MMU 的核心思想是利用虚拟地址替代物理地址,即CPU寻址时使用虚址,由MMU负责将虚址映射为物理地址。MMU的引入,解决了对物理内存的限制,对程序来讲,就像本身在使用4G内存同样。

内存分页(Paging)是在使用MMU的基础上,提出的一种内存管理机制。它将虚拟地址和物理地址按固定大小(4K)分割成页(page)和页帧(page frame),并保证页与页帧的大小相同。这种机制,从数据结构上,保证了访问内存的高效,并使OS能支持非连续性的内存分配。在程序内存不够用时,还能够将不经常使用的物理内存页转移到其余存储设备上,好比磁盘,这就是虚拟内存。

要知道,虚拟地址与物理地址须要经过映射,才能使CPU正常工做。而映射就须要存储映射表。在现代CPU架构中,映射关系一般被存储在物理内存上一个被称之为页表(page table)的地方。 页表是被存储在内存中的,CPU经过总线访问内存,确定慢于直接访问寄存器的。为了进一步优化性能,现代CPU架构引入了 TLB (Translation lookaside buffer,页表寄存器缓冲),用来缓存一部分常常访问的页表内容 。

为何要支持大内存分页?

TLB是有限的,这点毫无疑问。当超出TLB的存储极限时,就会发生 TLB miss,因而OS就会命令CPU去访问内存上的页表。若是频繁的出现TLB miss,程序的性能会降低地很快。

为了让TLB能够存储更多的页地址映射关系,咱们的作法是调大内存分页大小。

若是一个页4M,对比一个页4K,前者可让TLB多存储1000个页地址映射关系,性能的提高是比较可观的。

开启JVM大页内存

JVM启用时加参数 -XX:LargePageSizeInBytes=10m 若是JDK是在1.5 update5之前的,还须要加 -XX:+UseLargePages,做用是启用大内存页支持。

经过软引用和弱引用提高JVM内存使用性能


强软弱虚

  1. 强引用:

只要引用存在,垃圾回收器永远不会回收

Object obj = new Object();

//可直接经过obj取得对应的对象 如obj.equels(new Object());

而这样 obj对象对后面new Object的一个强引用,只有当obj这个引用被释放以后,对象才会被释放掉,这也是咱们常常所用到的编码形式。

  1. 软引用(能够实现缓存):

非必须引用,内存溢出以前进行回收,能够经过如下代码实现

Object obj = new Object();

SoftReference<Object> sf = new SoftReference<Object>(obj);

obj = null;

sf.get();//有时候会返回null
复制代码

这时候sf是对obj的一个软引用,经过sf.get()方法能够取到这个对象,固然,当这个对象被标记为须要回收的对象时,则返回null;软引用主要用户实现相似缓存的功能,在内存足够的状况下直接经过软引用取值,无需从繁忙的真实来源查询数据,提高速度;当内存不足时,自动删除这部分缓存数据,从真正的来源查询这些数据。

在此我向你们推荐一个架构学习交流群。交流学习君羊号:821169538  里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多。

  1. 弱引用(用来在回调函数中防止内存泄露):

第二次垃圾回收时回收,能够经过以下代码实现

Object obj = new Object();

WeakReference<Object> wf = new WeakReference<Object>(obj);

obj = null;

wf.get();//有时候会返回null

wf.isEnQueued();//返回是否被垃圾回收器标记为即将回收的垃圾
复制代码

弱引用是在第二次垃圾回收时回收,短期内经过弱引用取对应的数据,能够取到,当执行过第二次垃圾回收时,将返回null。弱引用主要用于监控对象是否已经被垃圾回收器标记为即将回收的垃圾,能够经过弱引用的isEnQueued方法返回对象是否被垃圾回收器标记。

  1. 虚引用:

垃圾回收时回收,没法经过引用取到对象值,能够经过以下代码实现

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj=null;
pf.get();//永远返回null
pf.isEnQueued();//返回是否从内存中已经删除
复制代码

虚引用是每次垃圾回收的时候都会被回收,经过虚引用的get方法永远获取到的数据为null,所以也被成为幽灵引用。虚引用主要用于检测对象是否已经从内存中删除。

原文  https://juejin.im/post/5bb76e356fb9a05d2567f150

相关文章
相关标签/搜索