java基础加强二(jvm内存结构)

一、Jvm内存结构

   jvm的内存结构也有叫运行时数据区的,Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分为若干个不同的数据区域。每个区域都有各自的作用。网上也有各种各样的模型图,会有所差别,所以要说明一下,jvm的内存结构是java虚拟机规范,并不是实现,只要符合规范,不同的虚拟机可以有自己的自由度。目前做javaEE的主流使用的是oracle公司的jdk,它的虚拟机是HotSpot VM,新版本的应该还加入了JRockit VM,所以是合并版。其他公司比如IBM有J9 vm。我们也从网上找一个图片进行说明。

这个图的好处是有颜色,橙色的部分是线程私有,绿色部分是线程共享的。并且有执行引擎,类加载系统,和运行时数据区,这个是jvm的三个主要子系统。

还记得上篇有关String的字符串常量池吗,就是共享的,而值传递中说到的栈就是线程私有的。其中的方法区在java8中被元空间替代了,但道理是一样的。

我们用一个简单的java代码的执行过程来详细说明一下各个区域。

public class  demoClass{

    public static void main(String[] args) {
        User user =new User();
        user.setName("小明");
    }

}

首先要了解java的工作原理,Java源码——>通过Java编译器编译成字节码class文件——>jvm执行字节码变成机器码——>操作内存。Java源码我们知道就是我们写的代码,java编译器我们熟知的就是javac,当然还有很多其他的,比如eclipse中的ecj,class文件就是字节码文件,一种中间文件,与平台无关,可以被jvm加载使用,所及java就可跨平台了。jvm执行字节码主要是通过执行引擎。了解了这些,我们可以虚拟出jvm的工作过程:(以下所谓的步骤只是我们为了方便理解抽象出来的,jvm并不是完全这样工作的)

第一步:编译成class文件,然后被类加载器加载,上图的mian方法应该是被加载到方法区(jdk1.8叫元空间),但是还有“小明”字符串,那么小明应该被加载到堆中的string pool中(jdk1.6以前也是字方法区的常量池中)。图就是这样:

方法区:用于存储已被加载的类信息、常量、静态变量、即时编译器编译后的代码。也就是说类被加载后class中很多信息就被存在这里了,主要是类描述,常量等。运行时常量池存在方法区中。这里有两个常量池,一个是运行时常量池,一个是class常量池(class文件中,保存的是字面值和符号引用),加载完成之后,class常量池里的东西也就转移到运行时常量池里了。

也有称方法区为永久代的,这主要是因为HotSpot VM,方法区是jvm规范定义的,不同厂商实现方法区的形式也就不相同,HotSpot就是用“永久代”这个概念实现的,J9不一定有这东西,但是一定有方法区,因为那是jvm规范,后来到了jdk1.8,HotSpot就改成了元空间来实现。这种改变是有必要的,因为永久代的调优是很困难,即使有参数设置,但是大小也很难确定,还有会经常因为空间不够发生内存溢出的问题,永久代中存储的类信息其实就是元数据,full gc后元数据位置会移动,所以需要特殊处理,这也比较消耗虚拟机性能。改为元空间后,类的元信息被存储在元空间中。元空间没有使用堆内存,而是与堆不相连的本地内存区域。所以,理论上jvm系统可以使用的内存有多大,元空间就有多大,也简化了full gc的操作。所以以后没有永久代了,也别再讨论了。

方法区中到底存了什么?可以参看JAVA基础篇005-方法区是什么、方法区存放什么_百里慕溪-CSDN博客_方法区存放什么

类加载过程:从网上找到一个很好的图片,加载主要分加载,链接,初始化三个阶段,加载器也根据不同的功能分成几种加载器,链接的过程有分验证,准备,解析等过程,初始化就是执行构造方法,在jvm指令中体现为<clinit>和<init>。加载一个类会遵循“双亲委派模型”,感兴趣的可以去网上查一下,这里不过多写了。

第二步:字节码执行引擎区执行字节码,在上一节讲string的时候我们使用过javap工具(字节码解析工具),看到了很多像ldc,astore之类的语句,那就是jvm指令。执行引擎读的就是那些东西,那么执行引擎怎么知道每个线程的执行情况呢,这就有了程序计数器,程序计数器会记下一步的指令是什么,而由于计数器是线程私有的,所以多个线程的话会有多个计数器。

程序计数器:     它是当前线程执行字节码的信号指示器,存的是下一步执行指令的地址。好比马路上的指示牌,告诉你接下来往哪走,虽然图上画的很大,其实是一快比较小的区域,  此处没有GC也没有OOM(内存溢出),而且是线程私有的,每个线程有一个计数器。

当线程执行的是一个java方法时,那么计数器记录的是虚拟机字节码指令地址,当线程执行的不是java方法而是native方法时,这个时候计数器的值则为空。(native方法就是本地方法,比如前面讲的string的intern方法,systerm.gc()也是。在Java中native关键字修饰的方法,java调用非java代码的接口,它们的实现不是java代码,大多时候是和平台(本地系统)一样的代码,比如C,C++代码)。

执行引擎:解释器,即时编译器(jit),垃圾回收器(GC)都属于执行引擎。

第三步:当方法开始执行时,就会创建栈,每个线程都有自己的栈,每个方法都有自己的栈帧,栈帧是栈的执行单位,栈中主要存储的是局部变量和方法的引用,对象的引用等,那么方法在执行时对栈帧的操作其实就是在操作堆和方法区的资源,因为那些变量和引用都是指向堆和方法区的。

虚拟机栈:大图中的java栈就是虚拟机栈,它是主管Java方法运行的。每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。栈结构是先进后出的数据结构,每个方法执行会创建一个栈帧,可以理解为栈帧就是栈的单位,栈帧里的结构我们看下图:

局部变量表:存储局部变量,方法中我们创建的基本类型(比如int类型)的数据,对象的引用(比如代码中的user)都会存储在这里,它是一个数组,数组里叫做槽(slot)。slot里放需要存的数据,slot是可以重复利用的,具体可以去查一下。

操作数栈:方法开始的时候这里是空的,随着方法的运行不停往里边读写数据,主要进行数据运算的地方,比如计算5+6这种,加减乘除,同时作为计算过程中变量临时的存储空间,它本身是数组,但是操作它只能和栈一样,先进后出,所以叫“栈”。如果调用方法,方法的返回值也会被存到这里。

动态链接:也可称为指向运行时常量池的方法引用,我们说每个方法对应一个栈帧,那么栈帧中应该存一个当前方法的引用,干什么活呢:将存在常量池中的符号引用转换为直接引用,记得上面说的class常量池和运行时常量池吗,当Java程序被编译完成的时候,每个类或,接口和方法都会有一个符号引用(你可以理解为它的名号,那种不会和别人重名的名号,一看就认识,存在jvm指令里),当程序运行,创建方法区,那么这些符号引用会被加载到运行时常量池中,当我们需要调用这个方法的时候,光知道名字没用,需要知道它的确切位置,它是哪个栈帧,指令是什么,我们好去操作啊,那么就可以通过常量池找到动态链接,动态链接就变成了直接引用。

说到这里,我们可以分析一下上面的代码:demoClass类和user类已经被加载完毕,方法区和堆的string pool中已经有了数据,运行时常量池已经有了两个方法的符号引用,当main开始运行,创建main方法的栈帧,并压入栈中作为当前栈帧(栈顶),让后调用user.setName()方法,创建setName方法的栈帧,压入栈顶,main方法就到了下面,这时候setName()方法在常量池中的符号引用就和setName方法中的动态链接勾上了,当main方法调用user.setName方法时会找到常量池中的符号引用,通过它的动态链接转换成直接引用,通过直接引用找到setName方法,调用就成功了。setName方法运行完成后,setName方法的栈帧被弹出,main方法继续运行,运行完成后main方法的栈帧出栈,方法结束。

方法出口:也有叫返回地址的,但叫出口其实更准确一些,因为一个方法的结束有多种情况,方法正常运行结束叫正常完成出口,如果遇到异常退出叫异常完成出口,无论是什么方式,方法结束后指令都需要回到方法被调用的位置,比如main方法调用setName方法,是在第二行,调用完成后,应该回到这个位置继续运行main方法,就像dbug的过程,我们进入某个方法调试,调完后应该回到进入的位置,然后继续走,这个过程肯定是执行引擎和程序计数器完成的,我们不过多讨论了。

本地方法栈:运行本地方法的,native方法。

最后要说的是堆,在上面的代码中我们创建了一个对象,new User(),这个操作就是在堆中创建了一个对象。最后说堆,是因为我们还要说一个和堆密切相关的东西:垃圾回收(GC)

:堆是jvm内存中比较大的一块,和元空间一样是共享区域,不是线程私有的。堆中负责存放创建的对象实例包括数组。栈中对象实例的引用(比如局部变量user)指的就是在堆中的内存地址。现在主流虚拟机都是把堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old )。新生代 ( Young ) 又被划分为三个区域:Eden(伊甸园)、From Survivor(简称S0)、To Survivor(简称S1)。这就是分代管理,我们找到了一个比较好的图:

把堆内存进行分代区分,是为了更高效的进行回收操作,这就要聊到GC了。

二、垃圾回收(GC)

  我们知道,java编程大多时候我们只负责new,jvm会自己回收内存,那么它就需要一套机制自动完成回收,这就避免了编程中人为大意造成的内存泄露(内存空间使用完后没有被回收释放)问题,这是java的一大特点。

1.如何判断和发现垃圾?

<1>引用计数法     <2>根可达算法

引用计数法:一种早期算法,很简单,每个对象中分配一块空间用来存储被引用的次数,被引用该次数+1,反之,减少一个引用,次数-1,直到为0,那么该对象判断为垃圾,被回收。但是这种算法漏了一种情况,就是如果两个对象相互引用,那么次数最低是1,那么就无法回收。

根可达法:也叫根搜索法,理论基础是离散数学的图论,从GC ROOT(就是根,栈,方法区) 开始搜索,找到引用的链。这个链上会有节点,比如局部变量表中有一个A对象的引用0x0001,它就是根,A对象在堆中,A又引用了B,但如果0x0001被删除,那么A对象和根不可达,链就断了,A和B就会被回收。但如果C也引用了B,C和根可达,那么这条链就需要B节点,B就不会回收,大体就是这个意思。

2.怎么回收?

<1>标记-清除法:找到存活对象后,进行标记,那么没有被标记的会被回收。那么回收后就象下面这样了,灰色的要被回收对象,绿色的是存活对象,如果存活对象较多,垃圾对象较少,效率比较高,但是会造成内存碎片,东一块西一块的。

<2>复制法:为了解决内存碎片问题,有了复制法,就是将内存变成上下两段,一段空闲,一段活动,将活动的那段内存里存活的对象复制到空闲段,在空闲段里整齐排列,就不会产生碎片。然后清空活动段,活动段和空闲段再互换,空闲段里全是存活的对象,就变成活动段。该方法解决了内存碎片问题,但内存需要更多,有一半总是利用不到,而且如果存活的对象很多,效率就不好保证了。如果存活对象少的话,效率会高。

<3>标记-整理法:也叫标记压缩法和标记清除法一样进行标记,但是清除的时候不同,清除垃圾对象后将存活对象向一边压缩,将全部空闲的内存连成片,就解决了内存碎片的问题,但是这样算法操作对象的移动需要更新内存指针,复杂度变高,操作成本也变高了

可见,似乎没有特别完美的方法进行垃圾回收,总会有一点代价,所以jvm厂商们想了一个方法,进行分代内存管理,每代代表一段内存,采用不同的回收策略。

<4>分代回收法:原理是对象的生命周期是不一样的,有的很短,有的很长。而且据统计,大部分对象都很”短命“,那么将堆分成年轻代(Young Generation)和老年代(Old Generation),老年代内存会大一些,年轻代比老年代大概1:2。年轻代里的对象也有刚创建和存活的两种,所以分成伊甸园和存活区,存活区再分成S0和S1,比例大概是8:1:1。

大部分的对象在伊甸园(Eden)中创建,当空间被占满,因为创建的对象有大量会快速到期,所以可以使用复制法将存活对象复制到S0中,Eden被清空,这样可以付出较少的成本,但是Eden区不能和存活区互换,因为内存相差太多,所以存活区还有个S1,当再次进行垃圾回收的时候,就会同时扫描Eden和S0,同时进行垃圾回收,把存活对象复制到S1中,对象的年龄也会+1,S0和Eden被清空,然后S1和S0互换。这个时候S0中有了年龄较高的对象。这个过程会循环往复,不断进行,那么对象的年龄也会不断增大,当达到一个阈(yu)值(参数-XX:MaxTenuringThreshold 默认15),就会将年轻代中达到15的对象移到老年代中,这样一个过程其实就是一个Minor GC过程。minor gc会经常被触发,所以是一个高频的回收动作。

从minor Gc过程中就看出jvm提高了回收的效率,jvm在年轻代使用复制法,但是如果对整个年轻代进行分段复制法,是非常浪费空间的,因为新生的对象多而且大都短命,所以Eden区直接用大内存并用复制法把存活对象移走,把存活区分成两段,在这两段进行交换,如此一来,就不会产生内存碎片。

对象到老年代后,说明这是一个生命周期比较长的对象,那么老年代就不需要频繁的回收,所以使用的是标记-整理法或者标记-清除法,当老年代空间满了后,会触发major GC,major Gc会整理老年代,也有说会触发Full GC 的,这里有一些混乱,主要是因为HotSpot虚拟机是有7个垃圾回收器(Serial,ParNew,Parallel  Scavenger,Serial Old,Parallel  Old,CMS ,G1),这些回收器回收的时候会造成不同的结果,导致说法不一,通常情况下会major gc会触发Full  gc,而且HotSpot发展这么多年,到底怎么样恐怕要问官方的开发才知道了。但是Full GC应该是比较明确的,就是要回收整个堆空间还有永久代(元空间),这个过程是比较慢的,所以尽量避免Full gc的发生。

jvm调优应该怎么调?

如果时非常精确的调优比较复杂,按照官方的文档你应该设置两个目标,一个是吞吐量目标,一个是最大暂停时间目标。他们相互矛盾,此消彼长,所以往往需要一个折中的方案。

oracle官方文档

但平时我们自己启动服务器也有需要调整vm参数的,往往只需要调整四个参数就够了

MetaspaceSize:元空间大小

MaxMetaspaceSize:元空间最大值,(默认很大)

Xms:堆内存大小

Xmx:堆内存最大值

以现代计算机的性能,元空间往往不用调整,基本够用,但是xms和xmx有时候需要调整,按照官方的说明,堆内存初始值一般为物流内存的1/64,最大1G,最大堆内存初始值为1/4物理内存,最大1G,所以实际上一般的项目应该是够用了。如果发生oom,很多时候都是代码问题或是哪里出现了严重耗内存的问题,而不是jvm的问题。

当然也有需要调的时候,尤其是老的jdk版本,创建的类太多而PermGen(永久代)设置的太小,导致OOM,或者分配给jvm的堆内存太多,导致整个物理内存都不足了。

调优的正确步骤不是上来就瞎写参数,至少应该有个大概的估计,如果出现oom我们也应该先找到问题,一般我们也要经过一下步骤

1.记录日志,查看日志   2.做好监控 3.根据日志和监控数据修改

我们可以使用专业的监控工具,也可以使用java自带的jconsole。所以jvm调优不是特意的,而是按需的,在没有任何需要或是问题下不需要调优。

三、java内存模型,jvm内存结构,Java对象模型什么区别。

    首先,这三个不是同一个东西,jvm内存结构上面已经说了。

    java内存模型和jvm内存结构容易让人混淆,但并不是一回事,你可以理解为java内存模型是靠jvm内存实现的,是抽象出来的概念,并不是真实的物理内存模型,它主要解决的是多线程问题,java是支持多线程的,但是我们编程的时候并不关心计算机是多核的还是单核的,缓存一致性问题该怎么解决,这些麻烦都由java内存模型解决,我们只需要使用java的volatile、synchronized等命令来控制线程就可以了。

   Java对象模型:Java时面向对象的语言,那么java对象在内存中存储也要遵循一定的模型结构,HotSpot虚拟机中,设计了OOP-Klass Model(面向对象类模型)。

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值