目录
一、基本概念
二、JMM介绍
2.1 JMM结构
1、方法区:
2、堆(Heap):
3、虚拟机栈(Stack) :
4、本地方法栈(Native Method Stack):
5、程序计数器:
2.2 对象定位方式
2.3 JMM内存模型
2.4 硬件内存架构与JMM
三、并发编程
当你看到java内存模型的时候千万不要跟JVM弄混了,JVM包括JMM,JVM是java虚拟机,这个范畴会更大些。我们这一章节主要介绍下Java的内存模型。首先介绍几个概念:
1、什么是程序?
程序 就是代码,完成某些需求的代码序列,是个静态的概念。
2、什么是进程?
进程就是一段程序的执行过程,专业点讲就是:是系统进行资源分配和调度的基本单位。用更通俗点的话讲就是:程序在某些数据上的一次运行。进程是一个动态的概念。
3、什么是线程?
操作系统能够进行运算调度的最小单位。咱们用通俗点的说法就是线程就是占有资源的独立单元。一个进程包含一个或者多个线程。 面试的时候各种多线程问题主要就是指的这个。
4、JVM什么时候启动和关闭?
jvm是在类被调用的时候启动,网上很多资料会说程序运行的时候调用,程序运行也是类的调用。
当然程序关闭的时候,这个实例也就随之消亡了。
JMM的大体结构入下:
JMM主要分为
主要存放类信息、常量(final)、静态变量(static)、即时编译器(JIT)编译后的代码,方法区里面的数据是共享的,各个线程都可以访问。永久代其实并不等价于方法区,只是用永久代来实现方法区而已。这块区域的内存回收目标主要是针对常量池的回收和对类的卸载。常量池是方法区中的一部分(jdk1.6之前,jdk7以后就移到了堆中了)。常量池用于存放编译期生成的各种字节码和符号引用,常量池具有一定的动态性,里面可以存放编译期生成的常量;运行期间的常量也可以添加进入常量池中,比如string的intern()方法。可以用这个去模拟常量池溢出(OutOfMemoryError: PermGen space)。如果加载的类信息很大而且还不是懒加载的,可以尝试调大些内存空间,否则容易造成内存溢出(OOM)。我们一般说的用反射去加载类,就是在这里加载的。
JDK1.6之前字符串常量池位于方法区之中。
JDK1.7字符串常量池已经被挪到堆之中。
jdk1.8中,永久代已经不存在,存储的类信息、编译后的代码数据等已经移动到了元空间(MetaSpace)中,元空间并没有处于堆内存上,而是直接占用的本地内存(NativeMemory)。
元空间并不在虚拟机中,而是使用本地内存。元空间的初始值和最大值都可以去设置。元空间默认是没有大小限制的。当然如果不设置的话JVM会按照一定的策略去自动增加元空间。
是JVM的内存数据区(主要存放对象实例)。线程共享,数据不共享。new一个就是一个全新的对象。这里唯一目的是存放实例对象。虽然说所有的对象实例以及数组都要在堆上分配,但是技术革新太快也不是太绝对,不过我们还是可以先这样理解。同时这里也是垃圾回收管理的主要区域。可以用-Xms(初始java堆大小),-Xmx(最大java堆大小)。堆在物理存储上面不一定是连续的。
2.1 新生代(Young Generation)
类出生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。
新生代分为两部分:伊甸区(Eden space)和幸存者区(Survivor space),所有的类都是在伊甸区被new出来的。幸存区又分为From和To区。当Eden区的空间用完是,程序又需要创建对象,JVM的垃圾回收器将Eden区进行垃圾回收(Minor GC),将Eden区中的不再被其它对象应用的对象进行销毁。然后将Eden区中剩余的对象移到From Survivor区。若From Survivor区也满了,再对该区进行垃圾回收,然后移动到To Survivor区。
2.2 老年代(Old Generation)
新生代经过多次GC仍然存货的对象移动到老年区。若老年代也满了,这时候将发生Major GC(也可以叫Full GC),进行老年区的内存清理。若老年区执行了Full GC之后发现依然无法进行对象的保存,就会抛出OOM(OutOfMemoryError)异常
2.3 元空间(Meta Space)
在JDK1.8之后,元空间替代了永久代,它是对JVM规范中方法区的实现,区别在于元数据区不在虚拟机当中,而是用的本地内存,永久代在虚拟机当中,永久代逻辑结构上也属于堆,但是物理上不属于。
为什么移除了永久代?
参考官方解释http://openjdk.java.net/jeps/122
大概意思是移除永久代是为融合HotSpot与 JRockit而做出的努力,因为JRockit没有永久代,不需要配置永久代。
是JVM的内存指令区(主要存放的都是指针、引用)。数据共享。同一个线程可共享数据。 该空间是线程私有,生命周期与线程相同,执行java方法(字节码)的服务,每个方法执行过程中都会创建一个栈帧(stack里面存的就是栈帧),用于存放局部变量表、操作数栈、动态连接等信息。目前就可以理解为局部变量表的存放空间。局部变量表存放的是8大基础数据类型(byte、char, int ,short,long,float,double,bolean)和对象引用,指令地址。编译时期就可以完成分配,因为每个数据类型长度都是确定的,所以进入一个方法的时候,这些都是可以确定的。
由于Stack的内存管理是顺序分配的,而且定长,因为在分配前就已经定好了,不存在内存回收问题;而Heap 则是随机分配内存,长度不定,存在内存分配和回收的问题;因此在JVM中另有一个GC进程,定期扫描Heap ,它根据Stack中保存的4字节对象地址扫描Heap ,定位Heap 中这些对象,并且假设Heap 中没有扫描到的区域都是空闲的,统统refresh
常见的Java的堆参数配置:
-Xms 设置应用启动时初始堆大小
-Xmx 设置最大堆大小
-Xss 设置线程栈大小
-XX:MinHeapFreeRatio 设置堆空间最大空闲比例,当比例低于这个值时,Jvm会自动扩展堆空间
-XX:MaxHeapFreeRatio 与上面那个作用相反
-XX:NewSize 设置新生代大小
-XX:NewRatio 设置老年代与新生代比例
-XX:SurvivorRatio 新生代中设置eden与survivior的比例
-XX:MaxPermSize 设置永久代大小
-XX:PermSize 设置永久去的初始值
-XX:TargetSurvivorRatio 设置survivior区的可使用率,空该区的空间使用率高于这个数值时,会将对象送入老年代
jvm使用到的本地方法服务,本地方法大部分都是C和C++来实现的。比如Cglib代理的字节码服务实现等。
当前线程执行的字节码的行号指示器,用C语言就是指针。每条线程都有一个独立的程序计数器,各条线程之间相互 不影响。如果执行java方法,PC就是执行虚拟机字节码指令的地址。如果执行本地方法,则值为空。
最后在整理一下stack和heap的区别:
1)Heap是 Stack的一个子集.(扩展—>从内存观点考虑),大家可以结合下文对象定位方式好好考虑下这句话。
2)Stack存取速度仅次于寄存器,存储效率比heap高,可共享存储数据,但是其中数据的大小和生存期必须在运行前确定。
3)Heap是运行时可动态分配的数据区,从速度看比Stack慢,Heap里面的数据不是共享,大小和生存期都可以在运行时再确定。
4)new关键字 是运行时在Heap里面创建对象,每new一次都一定会创建新对象,因为堆数据不共享。这里讲的详细点,让大家都更清楚怎么在栈和堆里创建的。
比如: String str1= new String("abc"); (1)
String str2= "abc"; (2)
str1是在Heap里面创建的对象。
str2是指向Stack里面值为“abc”的引用变量,语句(2)的执行,首先会创建引用变量str2, 再查找Stack里面有没有“abc”,有则将 str2指向 “abc”,没有则在Stack里面创建一个“abc”,再将str2指向“abc”。
由此可总结为在建立一个对象时从两个地方分配内存,在堆中分配的内存实际建立这个对象,而在栈中分配的内存只是一个指向这个堆对象的指针(引用)而已。
1) 句柄访问
堆里面每个对象的信息都存在句柄池中,句柄池中存着对象的实例数据指针、对象的类型数据指针;当在栈里面找到对象引用的时候,会去对里句柄池中找到该对象实例的地址,然后再堆中根据该地址找到实例对象数据。于此同时,对象的类型数据则会根据其地址在方法区中获取到相应的数据类型。(如 int i,String j 等等)
2) 直接指针访问
跟句柄访问类似,只不过这次没有了句柄池,整个对象数据在一起,类型数据在对象数据里面。
对比发现
句柄访问好处是引用中存放句柄地址,对象移动只会改变句柄中指针的指向,具体实例信息不用变动。
指针直接指向的好处就是省略一次指针定位开销。
这一小节我们来站在线程、内存的角度说下java内存模型。
如上图所示,我们将内存模型分为3大部分
1)主内存:存放共享的信息
2)工作内存:存放私有信息的
如果是基本数据类型,直接分配到工作内存,如果是引用类型则引用的地址存放在工作内存,引用的对象存放在主内存中;
每个线程有一个自己的工作空间,只能自己去操作。每个线程使用工作空间的大小根据栈帧来分配的。虚拟机的栈就是在工作空间里面去划分的;
3)工作方式:
1. 线程修改私有数据,直接在工作空间修改;
2. 线程修改共享数据,分为3步。这个也是最重要的一点,理解了就知道为什么多线程操作会不安全了。
A. 把内存数据复制到工作空间
B. 在工作空间修改,修改完毕后,刷新内存中的数据
C. 将工作空间数据写回到内存中
1、硬件架构
如下图所示
分为CPU、Cache、内存。读取速度 寄存器>Cache>内存
这里要注意的问题就是:并发处理不同步时CPU缓存的一致性问题。因为从图中可以看到多CPU的情况下,每个cpu是自己的一套寄存器和Cache。只有内存是公用的。
解决这个问题的方法有以下几个方案:
1)、总线加锁,总线概念就是操作系统层面上的了包括:地址总线、数据总线、控制总线三个。
总线加锁就是同一时间,只能让一个CPU去操作,封锁力度较大,这样会降低cpu的吞吐量。
2)、缓存一致性协议(MESI)
当cpu在cache中操作数据时,如果该数据是共享变量,数据先从cache读到寄存器中,进行修改,然后写回到内存中。 这样大家会有一个疑问,别的cpu中的cache中的数据肯定跟内存不一样了。这样就需要在做一步操作,将Cache LINE 置为无效, 这样其他的cpu则不能从cache中读取数据了,只能从内存中读取数据,当然单核cpu就没这些问题。
2、java线程与硬件处理器关系
java线程到内核cpu的对应关系如图所示。每个任务都会由线程池分配线程去完成,每个java线程会唤起内存线程来处理,内核线程由内核统一调度cpu去执行。这样就是为啥会存在cpu切换线程带来性能的开销的原因了。整体流程可以简单说是:进程->线程->OS->CPU。 大家一定要了解这个就会对并发有更深一层的理解了。
3、java内存模型与硬件内存架构的关系
如图所示
JMM中工作空间里面的私有数据有可能在硬件的寄存器、cache、内存中存在。
同样JMM的内存数据也有可能在硬件的寄存器、cache、内存中存在。
这样就导致了交叉数据的产生,肯定会产生数据不一致问题。最终数据的读取肯定是操作系统层面上的,这样就需要去使用MESI,缓存一致性协议来规避这个问题了。
大家肯定都会问,为啥不用硬件的的内存模型,为啥会产生java的内存模型呢。其实java内存模型的主要作用就是为了规范内存数据和工作空间数据的交互。
并发编程中,java模型有一些规则来保证程序执行的结果正确。
java 内存模型的规则:
1、as-if-seria原则:单线程中重排后不影响执行的结果,多线程也是需要保证重排后不影响执行的结果。
2、happens-before原则:因为jvm会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性。单线程不存在编译器优化,指令重排问题,这些发生在多线程情况下。
该原则有四条规则:
i 程序次序原则:在编译重排后,程序结果不能变
ii 锁定原则 : 后一次加锁必须等前一次解锁
iii Volatile原则:加了该行代码的前后顺序是不允许变化的
iiii 传递原则 : A--B--C => A--C
并发编程的三个重要特性:
1、原子性:不可分割,操作要不同时成功,要不同时失败
2、可见性:线程只能操作自己工作空间中的数据
3、有序性:程序中的顺序不一定就是执行的顺序,因为在编译阶段,cpu会对指令进行重排,提高效率
我们现在举例说下这个三个特性在并发编程中一般怎么去保证
1、原子性
1) X=10 写操作,如果是私有数据,具有原子性,如果是共享数据没原子性(比如别的线程读取了,本线程才写入)
2)Y=X 没有原子性,虽然是2个原子性操作,该操作可以分为2个步骤 先将X读入到工作空间(原子性) 再 将X的值写到Y(原子性)
3)i++ 没有原子性 同样是三步走,先 将i读到工作空间,然后i+1,最后将i刷新到内存
是不是大家都有感觉了到现在,多个原子性操作合在一起就没有原子性了。所以需要用一些手段去保障,常见的有Synchronized 和 Lock
2、可见性
保证可见性一般使用Volatile、Synchronized、lock 这些就是为了保证内存中的数据一致。其中Volatile就是在JMM上实现了MESI协议。我们在后面会着重一一讲解。其实笔者认为所谓的可见型就是 如何保证编译器优化,和指令重排后能够保证执行的顺序而已。
3、有序性
Volatile 来保证有序性的原理是,再加入volatile的代码在编译时,该代码上面部分和下面部分顺序不会变,也就是说,volatile上面的代码绝对不会因为优化到下面来。