java基础-jvm内存模型和运行原理

概述:
求职面试的时候,jvm的内容是高级开发以上必不可少的面试内容,而java关键字、语言的使用这些由于太基础也不一定会问到(不过还是希望回顾一下,毕竟都高级开发以上水平了被问java的特性是什么而卡壳也挺丢脸的,ps:我确实丢脸过)。
主要是几方面的内容问的多,一是jvm的内存模型,二是GC回收,三是运行原理。下面说一下内存模型和运行原理我的理解,GC部分在另一篇文章java基础-垃圾回收当中。

一.jvm内存模型:
被问到这个的时候,最好确认一下问的是jvm内存模型还是内存分布,其实大部分面试官问的都是内存分布。也可以不问而直接把这两方面都说一下。

内存模型:
看到文章评论里一句话总结内存模型:保证多线程之间操作共享变量的正确性。很精辟,但是面试不能讲的这么简单,面试官会有无数的问题轰上来,不过可以作为引语,后面详细阐述。
内存模型详细阐述:程序运行cpu核心只会进行计算,而计算需要的命令和数据都是依靠CPU缓存,而现在大多数是多核CPU,每个核心都有自己的独立缓存,并发时会产生缓存和主存数据不一致的情况。jvm主要提供两种手段屏蔽这种不一致。
1.内存屏障,主要是volatile关键字,保证此关键字修饰的变量若被修改则其他CPU缓存的该变量失效,使用时需要从主存中重新load(但是还是保证不了原子性,因为可能多个CPU已经拿到这个缓存值在同时进行计算了),另外使用该变量的代码段不允许代码重排序。
2.CAS写内存,主要是api提供个各种原子类实现的,直接比较并写内存值,而不是使用缓存中的数据。
回答到这里其实很多后面他想问的问题你都给回答了。
而这部分往下追问的问题不是很多,主要就是CAS的源码实现和并发的内容,而这部分内容会在java基础-并发问题里面继续深入

内存分布:
这是个非常非常老生长谈的内容了。如果有白板就给他在画画。注意的问题就是要区分一个界限,就是java8版本,之前和之后有一些区别就是方法区和元数据区。
下面简述内容:jvm内存分布主要分为虚拟机栈、本地方法栈、程序计数器,这些是线程私有的每个线程都会有单独的一部分空间,堆和方法区是所有线程共享的,java8以后的版本把方法区去掉改成了元数据区,主要存储的内容差不多,但是元数据区不在jvm的内存范围内了,还有一部分叫直接内存,是给例如nio的buff直接分配的内存块。

扩展提问:
虚拟机栈了解么,其内部是怎么构成的?
虚拟机栈是线程私有的一部分空间,每个线程创建的时候就会分配这块空间。
虚拟机栈的功能就是给线程运行的方法提供运行时需要的各种参数、地址、临时变量的存储的空间。
栈内部是由栈帧构成,每一个栈帧对应该线程中的一个方法的调用。
一个栈帧提供:局部变量表、操作数栈、动态链接、方法返回地址、附件信息。
局部变量表:一个方法执行过程中的变量存储的内存。内部结构是分配多个槽(slot),一个槽是4字节(32位),除了long和double是占用两个槽,其他类型都占用一个槽,这样定义就直接可以用偏移量直接取内存的值就可以,取值的速度是O(1)的。槽的总数是字节码编译完成的时候就定义好的,所以栈帧创建完毕这个内存空间就直接分配完成,存储变量的位置是可重用的,也就是不一定方法里总共会有10个临时变量这里就有10个变量位置,因为槽可能会被重用。例如:方法内两个int变量a,b相加(后面还会用到ab变量所以这两个槽不会被覆盖重用)则在变量表中会有两个槽分别存储ab两个值,执行到相加结束后后面接着的槽存储结果值。
操作数栈:方法执行过程中cpu操作时直接读取的栈。内部结构就是一个栈结构。栈的深度在字节码遍历完成的时候定义好,栈帧生成的时候也就分配好,字节码指令操作入栈、出栈供CPU计算使用。例如:方法内两个int变量a,b相加,首先将ab值入栈,然后调用加法计算的时候这两个值出栈到CPU进行计算,结果存入局部变量表中。
动态链接:根据名称就可知道,这个是连接其他地方的变量,主要用于连接其他方法、成员变量、常量、类等信息的地址。
方法返回地址:这个就是方法执行结束return的地址(如果有return的话),用于通知调用此方法的栈帧进行恢复执行使用。不同的return类型不同的jvm都有各自的实现。

方法区主要存储那些内容?
方法区是堆结构,存储类信息(字节码信息)、静态变量、常量池。
1.8以后方法区去掉,添加了元数据区,但是存储的内容大致相同。

堆的结构是什么?
这个问题碰到过,其实问的不是很清晰,很多时候回答的也不全面,回过头思考的话应该确认一下面试官想问的是什么问题,或者两方面都回答一下。
1.分代,老年代、新生代,新生代又分为伊甸区和交换区。是随着GC程序而设计出来的分区模式,虽然G1回收器完全可以脱离分代,但是目前仍然保留分代概念。
2.内存结构,就是这一大块地方内部是啥样的,对象在内存中布局是啥样的。默认分配好后是一大块初始化过的空内存(理解为连续的、全部为0的、一大块内存区域)。对象和数组(也理解为一种对象)等信息存储在该区域内。实例化出一个新对象就会在堆中分配一块连续的内存,使用开头的内存地址赋值给引用的变量,每一个对象的内存区域从前往后分别存储:头信息、数据、对齐填充(若前面刚好到8字节就不用补齐),后面接的可能是空的也可能是下一个对象的头数据。也就是对象实例化的时候,要分配的内存是固定的(编译期就已经知道)数组也是根据实例化的时候长度是固定的,对象不需要扩容,而数组是不允许扩容也就更好理解了(咋扩容?扩容了后面的对象给覆盖了,或者后面的信息都往后面移动,这影响就大了去了,所有引用的地址都变了,效率不高也不好实现)。这里可能会被追问对象头里面都有啥信息,这个会在后面对象布局内详细阐述。

jvm启动的常用参数和优化?
启动参数分为三种:
标准参数,-开头,所有jvm虚拟都必须提供,并且向下兼容
非标准参数,-X开头,默认应该实现,但是不保证一定会有实现
非Stable(稳定)参数,-XX开头,各种虚拟机自定义的参数,变动比较大,使用的话就查自己用的虚拟机对应版本的文档。
另外也一般用-D设置系统参数,例如jvm运行时区等信息。
常用参数:-client/server、-classpath、-Xms、-Xmx、-Xss,具体干啥的如果不知道百度一下。
优化:
线上服务器启动肯定要用-server
资源优化,也就是配置内存-Xms、-Xmx,一般设置为一样的,避免扩容。另外最开始就算好要用多大内存,上来就设置的很大也浪费资源,内存资源很贵的。
如果递归调用非常多,并且调用的深度很大,最好提前计算好,设置-Xss参数(设置一个线程分配的栈内存大小,前面提到的栈和栈帧就在这个内存中,递归深度很大就会发生这个内存不够用)。
GC类型优化,-XX:-UseGC和-XX:-UseOldGC,使用的GC类型,要用什么类型的GC这个就根据使用场景来决定。
使用非stable参数配置方法区、老年代和新生代比例;伊甸区和交换区的比例,优化内存使用,具体参数查自己用的jvm文档。
查问题:
打印GC信息,查卡顿问题。
OOM时自动dump内存,查内存溢出。

二.运行原理
jvm启动过程,启动的时候加载了些什么?OOM错误类何时加载?
jvm启动过程:
1.加载jvm环境。查找jvm程序并且加载。
2.解析jvm配置并根据配置进行初始化,比如内存配置,启动模式等参数。
3.设置线程栈大小,没有配置就找默认值。
4.加载基础运行类,前面准备工作都做好了,创建引导类加载器(bootstrap classloader),加载基础运行的类(jre库包)
5.运行main方法,创建一个线程,根据启动的时候传入的启动类加载该类并查找main方法进行执行。
启动的时候加载的内容通过上面启动过程可以看到加载了jvm运行环境、引导类加载器(c写的,java环境内获取不到这个对象)、jre基础类、主方法所在类,并且创建了一个执行main方法的线程(其实还有其他线程会被创建,比如gc线程)。
前面说到的加载jre基础类可以通过java -verbose查看。这个加载过程中就可以看到加载OOM错误类,而OOM错误是加载的时候就被实例化好了各种OOM错误的对象,由于不是出现错误的时候实例化的,所以OOM错误堆栈信息不是发生错误时的堆栈信息,之所以这么做个人认为两点原因,一个是OOM的错误并不是在当时分配内存的时候的错,而是其他的对象过多而导致的错误,所以新创建对象很小概率是问题的根源。之所以在启动jvm就实例化好这个对象首先异常堆栈可以不是实时发生的,另外一个既然OOM发生了,也就没有内存可以用了,到时候就没办法实例化了。

类的实例化过程是怎样的?对象是如何布局的?什么是对齐填充?
Car myCar = new Car();这一行代码都干了些个啥,有一半的考官问过这个。而这里面有一半的我都没回答好,所以人家基本就不会问更深的(等着pass就行了)。
实例化其实会考到很多考点,初级开发前一般能回答初始化过程就可以了。但是高级开发以上可不成,要回答到类的加载、内存的分配、对象的内存结构、并发处理,最后才是对象初始化过程。下面分别阐述一下。
a.类加载。首先看方法区有没有加载过这个类,如果有了,直接跳过,如果没有就进行加载。
类的加载两步走,第一步查找类文件并读取解析到方法区中,并且实例化对应的Class对象;第二步链接,这一步主要进行验证class字节码的合法性、解析初始化静态成员和常量池的符号化引用(常量池的符号化引用的处理要是引用到没有加载过的类就会触发其他类的加载)。
类通过类加载器进行加载。这两步也都是通过类加载器做的。jvm类加载器主要分四类(有的可能不这么分,比如Tomcat的类加载器就有点区别):
启动加载器(bootstrap class loader)C语言写的,java内无法直接操作这个类,用于启动的时候基础类的加载例如 rt包、sun包
扩展加载器(ext class loader)java实现的,主要用于加载jre扩展下的包
应用加载器(application class loader)java实现的,这个就是导入的包和我们写的类的默认加载的地方。
自定义加载器,我们自己用java来实现的,需要一些特殊加载的时候例如远程url加载或者热更新加载就可以自己实现一个。
上面这四层加载器由下到上是委托关系,当某一层要加载一个类的时候(比如应用加载器要加载一个类),则它会委托给上一级去加载(先委托给扩展加载器、扩展加载器委托给启动加载器),最顶级能加载就加载完成了,加载不到就让下一级进行加载,一直到腰加载这个类的加载器也加载不成功那就报告ClassNotFound异常。这个就是类加载的双亲委派机制。该机制就可以做到类加载的安全性,例如:有人重写的java.lang.String类里面做了很多坏事,但是这样的加载机制就只能由启动加载器加载java核心包的String类,而不会加载做坏事的String类。
b.内存分配。前面堆的结构是什么问题里面已经说过一些。这里再补充一些。内存分配指的就是在堆上分配这个对象的内存(当然是新生代的伊甸区),对象或者数组占用的内存长度是固定的,所以找一个连续的内存块即可。这个查找可用内存的方式是由GC的方式决定的,主要根据内存是不是连续的分两种方法,一种是内存是连续的(复制、或者标记压缩算法)那就使用指针碰撞,就是一个指针指向已使用和未使用的内存交界,直接在这个指针位置开始内存的分配,并且移动指针;另一种是内存不连续(标记清除算法)那就在空闲列表里面找到一块大小满足要求的内存块进行分配,并在空闲列表中修改使用的内存。
c.对象的内存结构,也有问做对象的内存布局。接着上面内存分配问下来的,这一块内存中存了啥?不同的虚拟机实现可能不同,这里说常用的HotSpot虚拟机实现分三段:对象头、数据、对齐。
对象头分两块运行时数据和类型指针(如果是数组的话还有一个数组长度),运行时数据是定长的一块区域,根据最后两位标识位和倒数第三位偏向锁标识的不同前面存储的内容也会有差别,主要内容为:synchronized锁实现信息[锁标识、是否是偏向锁、是否是轻度量级锁、是否是重量级锁、重量级锁对象指针、持有锁的线程id、加锁时间]、垃圾回收分代年龄。
对象数据这个就是类内所有成员的数据,所有成员就是指包括父类的。
对其填充就是为了把这快内存填充成8的倍数字节,这个不用多解释。
d.并发处理。这部分考虑的就是对象创建在整个运行期是非常频繁的,那就有可能有很多并发,就是两个对象分配到同一块内存上。主要两种解决方法:同步,一般用CAS的方式操作内存;TLAB,就是每个线程都会在堆上分配一个连续的大内存块,分配内存的时候就在本线程独占的这个内存块上进行分配。
e.对象初始化,前面内存分配完成,对象的所有成员都是默认值(这就是为啥要把这一大块内存都刷成0,java所有类型默认值都是内存为0的状态),执行顺序初级都学过:先父类后子类,先成员后块再构造函数。

对象如何访问定位?
要访问一个对象就是获取对象的实例数据(堆内数据)和类型数据(方法区内数据),索引这两种的数据有两种方式:
1.句柄访问,就是有个句柄映射表,存储了一个对象实例数据指针和类型数据指针,对该对象的引用直接指向句柄映射表的指针,访问对象数据先获取到这两个指针,然后再分别去对应位置找内存数据,这样访问一个对象就需要三步走。好处就是实例的数据指针变化(从内存中移动到另一个地方也就是GC操作)不用修改引用位置的指针。
2.直接指针,对该对象的引用直接记录堆内存的地址,然后对象类型的指针在堆数据中固定位置读取再去方法区获取。前面说过对象的布局,说到对象头中含有对象类型的指针,其实就是这种实现方式。这种实现方式少了访问对象的时候两步就可以,但是对象由于GC被移动后所有引用该对象的指针都需要修改。

方法是怎样执行的?
jvm内部任何的过程细说都是很复杂的,哪怕是一行指令的执行,从硬盘到内存到CPU缓存到CPU计算都是超复杂的,所以面试过程中尽量不要说:这个其实很简单的是怎么怎么实现的,要是碰到较真的面试官只能可以追问到你怀疑人生。而应该说这个过程很复杂,简单描述应该是怎样怎样的。
一个方法的执行也比较复杂,一整篇文章图文解释读也得几十分钟,而口述的话就更难,如果面试官不懂这块肯定不会问,因为你说了半天他也跟不上,要是问了他也就想听个大概过程,因为他烂熟于心,其实主要是考察虚拟机栈的运行过程。
简述:方法的执行主要靠虚拟机栈实现,大致过程如下:
1.创建栈帧,初始化栈帧的局部变量表、操作数栈和方法返回地址。
2.执行方法的字节码指令,指令主要操作就是读取局部变量表的数据到操作数栈,使用操作数栈数据到CPU运算完毕存回操作数栈,从操作数栈存到局部变量表中,根据局部表量表中的引用调用其他数据或者方法。
3.方法字节码指令都执行完毕后,根据方法返回地址的类型调用方法返回操作,不同的类型返回操作实现不同。将栈帧弹出。

以后碰到的新问题和网友们遇到的新问题欢迎提出来,我再补充。

参考资料
深入理解JVM-内存模型(jmm)和GC 看得出是一个很详实的学习笔记,总结的很到位,里面的很多资料也值得参考。
什么是Java内存模型
【深入理解JVM】:Java对象的创建、内存布局、访问定位
Java内存区域之程序计数器--《深入理解Java虚拟机》学习笔记及个人理解(一)
Java虚拟机运行时栈帧结构--《深入理解Java虚拟机》学习笔记及个人理解(二)
深入理解Java之jvm启动流程
jvm启动加载类的全过程,全网最全一篇,告诉你什么是双亲委派机制

posted @ 2021-01-12 15:12  Q-JayLee  阅读(517)  评论(0)    收藏  举报