jvm内存结构_聊聊JVM内存结构

起因

我们经常会在面试的时候被问到JVM内存结构,很多人会觉得这东西真的有用吗?也就是面试造火箭,入职拧螺丝。问这个就是纯粹来刁难人的吧。

但实际上,我们细想一下。

•假设你不知道局部变量实际上属于线程栈中的而非堆里面的,那么你就可能会担心是不是我这样写会有并发问题;•假设你不知道对象实例是分配在堆中,属于JVM共用的,那么你根本也就不会考虑在进行某些访问时进行加锁;•还有很多高并发的问题,比如volatile的原理,如果不知道JMM,怎么知道volatile的实现原理呢;... 这一系列的问题,如果你对JVM内存结构不了解的话,都很难去回答这些问题。

内存结构是什么

JVM内存结构JVM底层内存管理的一个虚拟架构,你可以对比理解为操作系统中的内存模型,而对于JVM来说,JMM就相当于是它的内存管理模型。包括高并发等都依赖了,并且还依赖后续的JMM(Java Memory Model)——JVM内存模型

内存结构长什么样

既然内存结构这么重要,那么我们来看一下它是长啥样的

dbeb80b7b00a224dacd7e35918b04c79.png

图片转自https://www.chenxun.wiki/2017/01/16/thread-02/

我们看到里面有两个东西,Stack(栈)和Heap(堆)。写过程序的同学基本都知道Stack是啥东西,我们的每一个方法(函数)在执行的时候,都会有一个栈在维护它的执行顺序,比如A方法调用B方法,那么栈的顺序是这样的:

e52ec4f03a17a901fb833a39fc48b708.png

满足后入先出的原则,对于方法调用来说是非常合适的,比如前面的A调用B,那肯定B结束后要恢复A的执行上下文,所以B弹出栈,这个是很直观的事情。

程序计数器——PC

这里的PC不是指的Personal Computer啊,当然不是啦。

a036716a1747b0059d75c919a80c9a24.png

程序计数器这个很好理解,它实际上就是记录你当前的代码执行行号,因为Java作为面向对象语言,实际上它的执行顺序肯定不会是一路向下的,所以在执行过程中的跳转就需要由PC去做控制。这个跟下面的JVM StackNative Stack是紧密结合在一起的。

虚拟机栈——JVM Stack

虚拟机的信息没那么简单,但实际上也差不多。每一个线程有自己的栈,而栈里面分成一个个的栈桢(Stack Frame,大家可以理解为我们上面的A方法和B方法),每个栈桢就相当于是当前线程执行的上下文,它包含了当前函数执行需要的信息,包括:局部变量,参数,返回值等。当一个栈桢(方法)执行完成后,它会从栈中被弹出。这里上面的PC就会把值更新为当前栈顶即将要执行的代码行数,而增加一个栈桢(调用另外一个方法)也是类似的,会构建一个新的栈桢,并把当前的栈桢加入到栈顶,再执行正常的调用操作。

这其中,实际上我们会留意到,栈桢和栈桢之前多少都会有一些联系,比如上一个栈桢的返回值是下一个栈桢的参数。类似这样的,实际上栈桢在处理完成的时候也会把当前的一些返回值作为结果push到栈中,供下一个栈使用。

我们再关注一下我们的图中,我们可以看到JVM Stack是线程隔离的,这就意味着实际上每个线程都有自己的栈,在这里面的我们并不需要考虑多线程问题。

但没有多线程问题不代表没有任何问题,Stack既然是栈,肯定也有栈的固有限制,JVM虚拟机会限制栈的大小(可以通过参数-Xss进行设置),当超过栈大小时会抛出StackOverflowError

本地方法栈——Native Method Stack

本地方法栈类似JVM Stack,区别最主要的就是JVMJava内部调用的栈,而Native Method是调用的本地方法的,这种一般是通过JNI调用的,当然还有一些其他语言的,比如JPython等基于JVM的脚本语言。

方法区——Method Area

方法区是虚拟机中所有线程共用的一块区域,它保存了被当前JVM加载的类信息、常量、静态变量、编译后产生的代码等数据。当我们通过Class对象获取类的相关信息时,都是通过这里去返回的。这里一般也被划分作为的一部分,但实际上它并不属于,有些资料把它称为Non-Heap以作为区别。

常量池——Constant Pool

我们在上面方法区介绍的时候说过它会保存当前被JVM加载的常量,而我们这里说的常量池也是方法区的一块。它保存了如字符串常量final变量值类名方法名。常量池各细的可以分为两种:

Class常量池

这种实际上就是我们平常用的最多的,比如类名方法名final变量值

运行期常量池

运行期常量池从名字来看就是说在程序的运行期间是可以修改的。一般情况下,我们在运行时的所谓字符串字面量,就是比如String str = "helloworld"类似的定义,直接确认String值的定义。另外我们看的最多的就是String.intern(这个也是面试的一个常见点,比如String的对比啊,以后的文章再展开讲),它也可以把字符串加入到常量池中。

堆——Heap

进入到我们最主要的重头戏了。是我们JVM麻烦的区域,麻烦就麻烦在这里是GC的最主要战场——垃圾收集的最前线。在JVM中实例化的对象及数组(数组为什么要特殊拿出来呢?大家可以思考下)都是存储在堆上的,这也就意味着它是所有线程共用的,并且是会有高并发的问题,因为共用一份,这也就导致了某个线程对对象进行修改的时候,其他线程有可能不能拿到最新的值——高并发的内容我们会后面涉及。

我们继续来看看的逻辑结构

c8601875c9f96cbf011b3b903f2b16d7.png

图片转自https://juejin.im/post/5dc0e88df265da4d461ebfd0

我们可以看到,堆分为两个大块:

新生代——Young Generation

从新生代的名字就可以看到,这是一些比较年轻的对象存放的位置,年轻是指对象从创建到当前的时间都比较短,当然,再年轻过了一段时间也会变老,就会晋升到后面的老年代。这里涉及到了一些GC的算法问题,我们后面再学习。暂时先有一个概念即可。新生代又区分为几个:

•Eden区 新创建的对象都会先在该区(前提是能放得下)•Survivor0区 Eden区的存活对象会迁移到该•Survivor1区 和Survivor1区互为备份,两个间的对象会互相迁移,最后会以对象的存活次数决定是否晋升到老年代。

老年代——Old Generation

从名字也可以看出,老年代当然是比较老的对象呆的地方了。但多老才算老呢,这个会由前面的新生代晋升的存活次数来决定。

永久代——PermGen Space

其实严格来说,永久代并不属于里面的,但由于它也叫代,那么为了好看,我们也在这里一起描述。虽然说叫永久代,但实际上它是属于方法区的。在JSP时代,我们经常会遇到一个错误叫java.lang.OutOfMemoryError: PermGen space,这一般是由于我们加载的文件太多,导致方法区超出限制了。而在JDK 8中,HotSpot已经把永久代改为元空间(MetaSpace)。我们可以看一下下面这个小例子,看看怎么用MetaSpace的相关参数。

public class MetaSpaceTest {    public static void main(String[] args) {        StringBuilder sb = new StringBuilder();        IntStream.range(0, Integer.MAX_VALUE).forEach(idx -> {            sb.append("str" + idx);        });    }}

我们这里设置最大的MetaSpace为:-XX:MaxMetaspaceSize=128m 这里不断构造新的String,运行几秒后我们会看到这样的错误:

fc613efc2304fc7a4c8cc74a2aac9497.png

我们可以看到现在已经是Heap size的错误了。这实际上意味着方法区已经被修改为元空间了。一般情况下它受限于当前机器的物理内存。如果说它为了解决什么问题,最直接还是之前的OOM错误吧,修改后就可以减少很多OOM的错误。

JMM是什么

大家一看到JMM,第一眼想到的是啥——这东西该不会又是啥网络用语简称吧。难道是姐妹们加美眉加猫猫类似的,我只能说你想多了。

cf335c2f0f4f58a145bdce36734bb22b.png

那么我们就来开谜了,JMM确实是简称,但是Java Memory Model——Java内存模型的简称。

JMM长啥样

我们直接来看看图

f46262f2259975d3c81385b0c1091cc7.png

转自https://www.cnblogs.com/kukri/p/9109639.html

这跟我们上面的内存结构看着是不是好像有点关联的样子。我们可以看到每个线程都有自己的工作内存,虽然工作内存也是属于线程的,但它跟我们上面的内存结构关系不大,它们只是主内存映射到特定线程的一个虚拟概念罢了。当然,如果为了帮助理解,我们可以这样理解。工作内存实际上也是属于JVM Stack的,只是它独立于JVM Stack,不归属于内存结构

我们可以看到图中,工作内存和主内存的操作是通过JMM来控制的。那具体是怎么控制的呢?

我们先把问题放下,先理一下内存操作有哪些。读、写,还有呢?没了,就这两个。那么JMM实际上要解决的就是读和写的问题。这里又涉及到CPUMESI协议,这个我们大概了解一下先,后面我们讲到volatile和高并发相关的知识的时候我们再细说。

Modify——修改

CPU可以发送标志告诉其他CPU说这个变量我已经修改了,你们不要再用自己的值了,来主内存中拿。

Exclusive——独占

这种标记情况下表明当前工作内存中的值是独占的,其他的CPU并没有相应的缓存值。

Shared——共享

表明当前的值各CPU上都有

Invalid——失效

表明当前CPU中的值已经不能再使用了,需要从主存加载。

为什么需要JMM

我们可以看到,由于每个线程都会有自己的工作内存——即缓存。对于共享变量来说,某一个CPU修改了,其他CPU不一定能及时发现,或者更极端的是一直发现不了。所以我们需要有一个协调者帮我们去做这样的事情。

共享变量如果被其中一个CPU改了,那么要通知我,我就不用自己的了,我去主内存中重新加载放到工作内存中。当然JMM的作用不止于此,它还有一些约束读写禁止重排序等功能。这些我们留待后面的文章再细说。

回到前面的问题

说了这么多,那么我们前面的问题的答案是啥呢?

访问局部变量要不要加锁呢?

回顾我们前面说的的情况。如果你说局部变量是保存在JVM栈中的,而JVM栈中是线程隔离的,所以可以不加锁。那么,你肯定是没有仔细看那一块的相关知识。变量包含两种,一种是基础变量,一种是引用变量,对于基础变量,它直接分配在上,这是线程隔离的,所以如果是基础变量,随便来,不需要加锁;而对于引用变量,我们知道,只有这个变量是在上的,而它真正的对象是在上的,而上的则是需要进行加锁的。我们来看一个例子:

public class ConcurrentTest {    public static void main(String[] args) {        int id = 11;        PrimitiveClass primitiveClass = new PrimitiveClass(id);        PrimitiveClass primitiveClass2 = new PrimitiveClass(id);        primitiveClass.print();        primitiveClass2.print();        ComplexClass complexClass = new ComplexClass(primitiveClass);        ComplexClass complexClass2 = new ComplexClass(primitiveClass);        complexClass.print();        complexClass2.print();    }    static class PrimitiveClass {        private long id;        public PrimitiveClass(long id) {            this.id = id;        }        public void print() {            System.out.println("here is id:" + id);        }    }    static class ComplexClass {        private PrimitiveClass primitiveClass;        public ComplexClass(PrimitiveClass primitiveClass) {            this.primitiveClass = primitiveClass;        }        public void print() {            System.out.println("here is shared:" + primitiveClass);        }    }}

我们来看看结果:我们看到PrimitiveClass实际上看不出任何区别,因为它是基本类型,传入的值实际上就是该值本身,并没有太多需要特殊处理的地方。而ComplexClass就比较特殊了,它里面有一个引用类型,而且我们看到引用的值实际上是同一个,所以就意味着,我们对当前的类做的改动,都会直接体现到该对象身上。

我们来看看内存示意图:

7258af1961f853fecfa7bf9cf586d00b.png

虽然我们没用多个线程,但我们new了多次,可以间接理解为我们是用多个线程的,意会一下。我们可以看到,对于对象,我们都是指向堆中的实例值,而对于基本类型,它们却是保存在中的。这也就解释了我们上面的,局部变量什么时候需要加锁,又什么时候不需要加锁。

对象实例分配在堆中,那是不是都要加锁呢?

其实这个答案我们在上面分析的时候也顺便回答了。如果你的对象是一个可变对象,什么叫可变对象呢,就是内有可变属性。这个可变状态这里不细说哈,有兴趣的同学可以找找其他文章了解一下。当你的对象拥有可变属性,那么当多线程访问该对象时,就需要进行加锁。现在流行的函数式编程在高并发下有原生的优势就是因为它去掉了可变属性,所有结果都只是一个中间变量,用完即丢。

那么volatile的实现原理呢?

回到我们上面的对JMM的分析上,MESI实际上就是volatile实现的基础。当然还有一些更细的一些东西,比如happens-before原则,禁止重排序等,这些就留到我们后面再来讲。

总结

今天我们一起了解了JVM内存结构JMM,知道了内存结构中分成哪几部分,并且哪些是线程独占,哪些又是共用的;而JMM我们就了解了它出现的原因和基本的MESI协议。当然,很多东西要细讲的话可以讲很多,我们暂时先这样,留待后面继续。

参考文章:http://tutorials.jenkov.com/java-concurrency/java-memory-model.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值