这篇文章来讲一下JVM中的重点之一——JVM的内存结构

目录

1.概述

2.程序计数器

3.虚拟机栈

3.1栈的介绍

3.2栈的相关问题

3.3栈内存溢出问题

3.4线程运行诊断

4.本地方法栈

5.堆

5.1堆的概述

5.2堆内存溢出问题

5.3堆内存诊断

6.方法区

6.1方法区的概述

6.2方法区的内存溢出问题

7.运行时常量池

8.直接内存

8.1直接内存概述

8.2直接内存的基本使用

8.3直接内存的内存溢出

8.4直接内存的释放原理

9.小结


1.概述

其实由上一篇文章就可以知道,JVM的内存结构如下图所示:

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存的划分如上图所示(这是根据《java虚拟机规范》划分的,在学习高并发的时候,我们可以将上图简化一下,这样便于学习)

2.程序计数器

定义:程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

解释:下面通过一个具体的例子来说明一下:

具体流程就如上图所示,下面解释一下:

我们编写好java的源代码,然后通过javac命令将其编译为java的二进制字节码文件,注意,这个文件cpu是不认识的,是不能直接运行的。然后jvm将字节码文件的首行命令地址交给程序计数器然后解释器(即字节码解释器)从程序计数器中得到地址,然后根据地址在内存中找指令,与此同时,程序计数器中的地址就变为下一条命令的地址。解释器找到指令后再对其编译,最后交给CPU运行。然后解释器再在程序计数器中得地址,然后程序计数器的地址再变为下一条命令地址,就这样,将整个程序执行完毕。

简而言之,程序计数器的作用:记录下一条要执行的jvm的命令的地址

程序计数器的特点:

  1. 是一块较小的内存
  2. 线程私有,随线程创建而创建,随线程销毁而销毁
  3. 是JVM内存区域中唯一一块不会存在内存溢出的区域

解释:说一下第二点,可以思考一下CPU的时间片轮转,每个线程执行一段时间,然后切换线程,这时当然需要程序计数器来记录当前线程执行到哪条命令了

3.虚拟机栈

下面来介绍一下虚拟机栈

3.1栈的介绍

栈(数据结构)的特点:先进后出

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

解释:

虚拟机栈:就是java程序中一个线程运行时需要的内存空间。一个线程一个栈,2个线程2个栈,多个线程多个栈。

栈帧:每个方法运行时需要的内存空间,是栈的组成部分,一个栈中可以有多个栈帧。栈帧中存储方法的参数,局部变量,返回地址等元素。

活动栈帧:即该线程正在执行的方法的空间,在栈顶,每个线程只有一个活动栈帧

我们的程序在运行的时候,假设是一个线程,那么就会开辟一个栈,存储线程中的一些变量等内容。然后我们的线程中会由方法,每有一个方法就会在这个栈内开辟一个栈帧,存储这个方法的相关参数,变量,返回地址等内容,如果方法执行完了,那么栈帧就被回收了,即出栈了,如果这个方法调用了别的方法,那么别的方法入栈,如果还有调用,那么再入栈,直到有一个方法可以直接结束,然后出栈。这就是栈的相关流程。

下面看一下具体的栈和栈帧

3.2栈的相关问题

问题:垃圾回收是否涉及栈内存?

答:不会涉及。栈里面包含栈帧,栈帧是方法运行时开辟的空间,当方法运行结束了,栈帧会自动的出栈,不会栈用栈的内存空间,所以垃圾回收不会涉及到栈内存

问题:栈内存是不是越大越好?

答:不是的。因为我们的物理内存的大小是一定的,如果栈内存大,那么栈的数量就会少,那么运行存在的线程数量就会少,所以栈内存不是越大越好。

拓展:在JVM中,我们可以通过 “-Xss size” 来设置java虚拟机栈的大小,一般情况下,Linux,macOS等系统下的JVM的栈的大小是1024kb,而Windows是根据计算机的虚拟内存大小来设置的。

问题:方法内的局部变量是不是线程安全的?

答:如果方法内的局部变量没有逃离该方法的作用范围,那么该局部变量是线程安全的;如果逃离了,那就不是线程安全的,就比如方法的最后你返回了这个局部变量,那就不是线程安全的。除此之外,如果这个局部变量引用了对象,那就要考虑上面的问题,如果只是基本类型的变量,那就是线程安全的。

解释:考虑是不是线程安全就要考虑这个变量是各个线程共享的还是这个变量对每个线程来说是私有的。方法的局部变量是在该方法对应的栈帧中的,而该栈帧又是在该线程栈中的,这个局部变量对这个栈来说,是私有的,是属于相应的栈帧的,如果其他线程要调用这个方法也会创建出这个方法的栈帧,然后创建出这个局部变量。所以这个变量对线程是私有的,是线程安全的。如果这个变量是static的,那么就不是线程安全的。

下面来分析一下下面几个方法是否是线程安全的:

结果:m1线程安全,其余的都不是线程安全的。

解释:m2方法中sb作为参数传入方法,那么这个sb就不是m2方法私有的,可能有别的线程来调用。m3同理,sb作为返回值,也可能有别的线程来调用。

判断一个变量是否是线程安全的,就要看它是否是该方法的局部变量,还要看它是否逃离了该方法的作用范围。

3.3栈内存溢出问题

下面来考虑一下栈内存溢出的问题

造成栈内存溢出的原因

  1. 栈帧过多。典型的例子就是无限递归。一个方法就是一个栈帧,栈帧太多就导致了栈内存溢出
  2. 栈帧过大。这个很好理解,就是一个栈帧的大小直接超过了栈的内存,那就导致了栈内存溢出,这个不太常见,可以在一个方法中无限的创建对象,这个就可以尝试一下爆栈了。

下面具体演示一下:

3.4线程运行诊断

栈与线程是密不可分的,这里讲一下线程的运行诊断相关内容:

4.本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机 栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native) 方法服务。

解释:本地方法,就是不是用java代码写的一些方法,而是更接近操作系统的,用C或C++写的一写方法。当我们的程序需要运行这些本地方法的时候,就对其开辟一块本地方法栈。

5.堆

下面来介绍一下JVM中的堆

5.1堆的概述

对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。在《Java虚拟机规范》中对Java堆的描述是:“所有 的对象实例以及数组都应当在堆上分配”。

Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”(Garbage Collected Heap,幸好国内没翻译成“垃圾堆”)。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常会出现“新生代”“老年代”“永久代”“Eden空间”“From Survivor空 间”“To Survivor空间”等名词。

如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区 (Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java 堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

简而言之:

堆:通过new关键字,创建的对象会存储在堆内存中

特点:是线程共享的,堆中对象需要考虑线程安全的问题;它有垃圾回收机制。

5.2堆内存溢出问题

堆内存溢出原因:堆中对象实例太多,并且都有引用,无法作为垃圾被回收

看下实例:

5.3堆内存诊断

下面看一下如何进行堆内存诊断:

这些内容了解一下就行,具体怎么操作在这里不是重点,就不过多解释了。

6.方法区

下面来讲一下方法区

6.1方法区的概述

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载 的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。

简单来说,可以这样理解:我们的java代码,除了堆中的内容,其余的内容都在方法区中存着。

下面通过一张图来具体的看一下方法区:

6.2方法区的内存溢出问题

下面来看一下方法区的内存溢出问题

注意:1.8以前会导致永久代内存溢出;1.8以后会导致元空间内存溢出

下面通过具体实例来看一下:

很显然,就是因为类加载过多了,导致了方法区的内存溢出。

7.运行时常量池

下面来看一下运行时常量池

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

下面具体的来看一下:

然后反编译.class文件:

然后上面的几行代码就变成了下面的几条指令:

第一部分:0,3,5,8可以理解为当前指令的地址,与程序计数器有关。第二部分是JVM指令,第三部分就可以理解为常量池中的常量的地址,第四部分就是注释了。

这就是反编译出来的常量池,JVM指令后面跟的地址就是这些常量在常量池中的地址,字节码解释器会读取到JVM指令,然后根据其后面的地址在常量池中找到相应的常量,然后构成一个完整的语句,然后就可以编译成机器语言再交于CPU执行了

运行时常量池:常量池是.class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址,此时,常量池就是运行时常量池。运行时常量池在方法区中

8.直接内存

下面我们来看一下直接内存

8.1直接内存概述

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中 定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。

直接内存:不是java虚拟机内存,是操作系统内存,但是我们也会频繁的使用。

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

8.2直接内存的基本使用

首先,我们来看一下内存分布:

用于java代码无法直接读取系统内存中的内容,所以系统内存中的内容会复制一份到java的堆内存中,这部分称为java缓存区。然后CPU的程序再从java堆中的java缓存区区读取内容。

这张图显示的就是直接内存的读取。java会在系统内存中划分一块区域,这个区域中的内容java代码可以直接访问,这就体现了直接内存读取速度快的特点。

8.3直接内存的内存溢出

直接内存也是会发生内存溢出的问题的,下面看一下具体的实例:

8.4直接内存的释放原理

对于直接内存的释放,我们只需要知道它不接受JVM的内存管理,至于直接内存具体是如何释放的,我们只需要知道下面这两点就行:

  1. 使用了Unsafe对象完成直接内存的分配回收,并且回收需要主动调用freeMemory方法
  2. ByteBuffer 的实现类内部,使用了Cleaner (虛引用) 来监测ByteBuffer对象,一旦 ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存

9.小结

下面来小结一下JVM的内存结构

jvm即java虚拟机,也就是我们java程序运行时的环境。它是一套标准,它是直接与底层操作系统相连接的,在程序运行时,它是在我们想计算机内存中的一块实实在在的内存区域,计算机将这块内存区域划分给了jvm,然后由jvm对这块内存区域进行管理划分。

jvm对内存的划分有虚拟机栈,本地方法栈,程序计数器,堆,方法区五大部分。其中前三个是线程私有的,后两个是线程共享的。并且只有程序计数器不会发生内存溢出,其余的都有可能发生内存溢出。

程序计数器记录的是下一条指令的号码,栈是与线程有关的,堆是创建对象的地方,方法区里面放的是常量,静态变量,类型信息,编译后的代码缓存,即堆中没有的东西都放在方法区中。

在jdk1.6中,方法区是直接在jvm中的,在jdk1.8以后,方法区在jvm中就仅仅是一个概念了,没有实实在在的区域,是一块元空间,此时的方法区被移动到计算机内存中了。方法区中有一个很重要的区域,即常量池,常量池与运行时常量池是不一样的,这点要明白。

除此之外,还有一块直接内存,它不在jvm中,是在操作系统内存中的。它的作用就是加快文件读写速度。

以上就是整个jvm内存结构的小结了。这里只是概述,每一点都是可以展开的,这个可以看上面的详解。