CRUD工程师——自动内存管理机制

首先内存模型是和内存结构是不一样的。内存模型是类似CPU内存结构的。
CPU的内存是有一级二级三级的,如果每一次都是要去内存中处理。实在是太慢了。
如果有一级二级三级这种层次那么就会有一个新的问题就是内存的一致性问题。
这个问题就像互联网技术一样。需要各种各样的协议。用协议来控制对应的
由于存在着缓存。这样虽然速度快了很多,但是也会造成一个问题就是可能读取不到最新的。
然后就引入了名词“内存屏障”,因为cpu执行指令可能是无序的,内存屏障有两个比较重要的作用 。
1.阻止屏障两侧指令重排序 
2.强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。
使用内存屏障的解决:
1.在不同CPU执行的不同线程对同一个变量的缓存值不同,为了解决这个问题。
2.用volatile可以解决上面的问题,不同硬件对内存屏障的实现方式不一样。Java屏蔽掉这些差异,通过jvm生成内存屏障的指令。 对于读屏障:在指令前插入读屏障,可以让高速缓存中的数据失效,强制从主内存取。

volatile 关键字(修饰变量)
是一种比 sychronized 关键字更轻量级的同步机制,访问 volitile 变量时,不会执行加锁操作
volatile 是一个类型修饰符(type specifier)。volatile 的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值
主要保证了2个需求:
1.可见性

新值能立即同步到主内存,以及每次使用前立即从主内存刷新。

  • volatile 修饰的变量,是直接拿的主内存的值,就是说这个值永远是最新的,对其他线程是可见的。
  • 而访问非 volatile 变量时,每个线程都会从系统内存(主内存)拷贝变量到工作内存中,然后修改工作内存中的变量值,操控的变量可能不同。
2.禁止重排序优化
  • volatile 通过设置 Java 内存屏障禁止重排序优化。

在一个变量被 volatile 修饰后,JVM 会为我们做两件事:

  • 在每个 volatile 写操作前插入 StoreStore 屏障,在写操作后插入 StoreLoad 屏障。(StoreStore-写-StoreLoad)
  • 在每个 volatile 读操作前插入 LoadLoad 屏障,在读操作后插入LoadStore屏障。(LoadLoad-读-LoadStore)
但是volatile也有一个问题,就是它并不是安全的,虽然它有类似加锁的作用。它可以做到可见性和顺序性,但是做不到原子性。

首先需要了解的是,Java中只有对基本类型变量的赋值和读取是原子操作,如i = 1的赋值操作,但是像j = i或者i++这样的操作都不是原子操作,因为他们都进行了多次原子操作,比如先读取i的值,再将i的值赋值给j,两个原子操作加起来就不是原子操作了。所以,如果一个变量被volatile修饰了,那么肯定可以保证每次读取这个变量值的时候得到的值是最新的,但是一旦需要对变量进行自增这样的非原子操作,就不会保证这个变量的原子性了。

至于volatile底层是怎么实现保证不同线程可见性的,这里涉及到的就是硬件上的,被volatile修饰的变量在进行写操作时,会生成一个特殊的汇编指令,该指令会触发mesi协议,会存在一个总线嗅探机制的东西,简单来说就是这个cpu会不停检测总线中该变量的变化,如果该变量一旦变化了,由于这个嗅探机制,其它cpu会立马将该变量的cpu缓存数据清空掉,重新的去从主内存拿到这个数据。简单画了个图。
然后要说JVM的内存模型了,其实计算机这个东西已经是很完善了,相对而言目前的结构也是最适合当前文明水平的。所以JVM内存模型其实也是类似着计算机的模型的。
也是有这各自的工作内存,其实在开发的时候我们通过看任务管理器也能看得出。
缓存一致性:每条线程都有自己的工作内存,里面保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,相似的,Java虚拟机也定义了一套内存访问协议来保证内存一致性;

指令重排序:对应于处理器乱序执行,Java虚拟机的即时编译器中也有着类似的优化,同样只保证最终结果的一致性。(其实这个东西有好也有坏,看个人理解了)

内存一致性:每条线程都有自己的工作内存,里面保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,相似的,Java虚拟机也定义了一套内存访问协议来保证内存一致性;

程序计数器:是一块很小的内存空间,它可以看作当前线程所执行的字节码的行号指示器,通过改变这个计数器的值来选取下一条所需要执行的字节码,分支,循环,跳转,异常处理等都需要依赖这个计数器。
另外在JVM中多线程是通过线程轮动切换并分配处理器来实现的,也就是说其实在某一个时刻,一个内核(可能有多个内核)只会执行一条指令。因此为了影响别的程序计时器,每一个程序计时器都是私有的。
Java虚拟机栈和上面这个计时器一样也是线程私有的。java虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的时候都会创建一个栈帧,用来存局部变量表,操作数栈,动态链接,方法出口等信息,一个方法在执行到执行完毕的过程,就对应着一个栈帧从入栈到出栈的过程。这个栈也可以理解为最常说的栈内存,局部变量表存放的是我们在编译期间就可以知道的基本数据类型,以及对象引用的地址,returnAddress地址(指向一条指令码的地址)。当进入一个方法的时候,里面的局部变量表中的空间是确定的。
本地方法栈:和Java虚拟栈其实是差不多的,区别在于一个是为了Java方法服务,一个是为了Navite方法服务(有一些Jar包其实是用C,py等写的,在看jar包源码的时候可以看到对应的注解@Navite)
Java堆:是Java内存中最大的一块,在虚拟机启动的时候创建,唯一的目的就是存放对象,就好比你new了一个对象,这个对象就是放在这个地方的。当然随着技术的进步,有一些技术例如栈上分配。所有对象都分配在Java堆上,这一句话已经变得错误了。Java堆也是垃圾收集器管理的主要区域,很多对象可能只会使用到一次,所以这个地方也被称为GC堆,然后这个区域会有很多的细分,例如“新生”和“老年”,也可能分配出多个线程私有的缓冲区。也就是说其实这个分法会不同,但是目的都是为了更加好的提升效率。Java堆可以在物理上不连续的内存空间中,只要逻辑上连续就可以,
方法区:方法区和Java堆一样,是各个线程中共享的区域,用来存储JVM加载的类信息,常量,静态变量等数据,这个地方也被很多人叫做永久代,当然这个只在hotspot虚拟机中可以这么叫,因为这个设计的原本思想是为了让GC可以像管理Java堆一样来对方法区进行管理。方法区=永久代=Bug,因为很少有GC回收,所以很造成内存泄露等情况。
  • 运行时常量池:这是方法区的一部分,这边存放的是类信息,常量,静态变量等数据中的字面量以及符号的引用,在类加载后这部分内容会存放在运行时常量池中。

对象的创建

(1)通过关键字new,首先先去检查这个指令的参数是否能在常量池定位到一个类的引用。并且检查这个符号引用代表的类是否已经被加载,解析,初始化过。如果没有就会执行对应的类加载过程。
(2)通过对类的加载检查之后,虚拟机开始对新生对象分配内存。对象所需内存的大小在类加载完成的时候就能确定。
(3)内存分配完成之后就会将这一个将要分配的内存设置为零值,对对象开始进行必要的设置(这个对象是属于哪一个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息)这些信息会放在对象的对象头中,根据虚拟机的运行状态不同,是否使用偏向锁等情况会对对象头有不同的设置。
(4)对对象进行初始化(把一直值进行赋值)
对象的内存布局
在JVM中对象的内存布局基本可以分成三部分:对象头,实例数据,对齐填充
对象头:
  • 对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、 GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

  • 第二部分是类型指针(对象指向它的类元数据的指针,虚拟机通过这个指针来确定它是哪一个类的实例),注意点不是所有的指针都有这个指针
实例数据:
  • 这一部分包括了对象真正存储的信息,无论是父类的还是自己的都会保存下来
对齐填充:
  • 这一部分是为了让这个整体是8的整数倍,如果不是8的整数倍会进行填充
对象的访问定位
通过栈上的reference数据来操作堆上的具体对象,由于reference类型在Java虚拟机规范中规定了一个指向对象的引用。所有目前主流的访问方法有两种
  • 使用句柄:Java堆会划分出一个区域来作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据和类型数据各自的具体地址信息

  • 直接指针访问:Java堆对象的布局就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址

 

 

 

 

 

 


posted @ 2020-06-24 14:46  smartcat994  阅读(172)  评论(0编辑  收藏  举报