并发编程---1、JMM

目录

 

一、基本概念

二、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介绍

2.1 JMM结构

JMM的大体结构入下:

并发编程---1、JMM_第1张图片

 

 

 

 

JMM主要分为

 

1、方法区:

主要存放类信息、常量(final)、静态变量(static)、即时编译器(JIT)编译后的代码,方法区里面的数据是共享的,各个线程都可以访问。永久代其实并不等价于方法区,只是用永久代来实现方法区而已。这块区域的内存回收目标主要是针对常量池的回收和对类的卸载。常量池是方法区中的一部分(jdk1.6之前,jdk7以后就移到了堆中了)。常量池用于存放编译期生成的各种字节码和符号引用,常量池具有一定的动态性,里面可以存放编译期生成的常量;运行期间的常量也可以添加进入常量池中,比如string的intern()方法。可以用这个去模拟常量池溢出(OutOfMemoryError: PermGen space)。如果加载的类信息很大而且还不是懒加载的,可以尝试调大些内存空间,否则容易造成内存溢出(OOM)。我们一般说的用反射去加载类,就是在这里加载的。

JDK1.6之前字符串常量池位于方法区之中。 

JDK1.7字符串常量池已经被挪到堆之中。

jdk1.8中,永久代已经不存在,存储的类信息、编译后的代码数据等已经移动到了元空间(MetaSpace)中,元空间并没有处于堆内存上,而是直接占用的本地内存(NativeMemory)。

元空间并不在虚拟机中,而是使用本地内存。元空间的初始值和最大值都可以去设置。元空间默认是没有大小限制的。当然如果不设置的话JVM会按照一定的策略去自动增加元空间。

 

2、堆(Heap):

是JVM的内存数据区(主要存放对象实例)。线程共享,数据不共享。new一个就是一个全新的对象。这里唯一目的是存放实例对象。虽然说所有的对象实例以及数组都要在堆上分配,但是技术革新太快也不是太绝对,不过我们还是可以先这样理解。同时这里也是垃圾回收管理的主要区域。可以用-Xms(初始java堆大小),-Xmx(最大java堆大小)。堆在物理存储上面不一定是连续的。

  • 堆区是gc的主要区域,通常情况下分为两个区块年轻代和年老代。更细一点年轻代又分为Eden区最要放新创建对象,From survivor 和 To survivor 保存gc后幸存下的对象,默认情况下各自占比 8:1:1。 
  • Heap 的管理很复杂,每次分配不定长的内存空间,专门用来保存对象的实例。在Heap 中分配一定的内存来保存对象实例,实际上也只是保存对象实例的属性值,属性的类型和对象本身的类型标记等,并不保存对象的方法(方法是指令,保存在 Stack中),在Heap 中分配一定的内存保存对象实例和对象的序列化比较类似。而对象实例在Heap 中分配好以后,需要在Stack中保存一个4字节的Heap 内存地址,用来定位该对象实例在Heap 中的位置,便于找到该对象实例。 
  • 当对象无法在该空间申请到内存是将抛出OutOfMemoryError异常

并发编程---1、JMM_第2张图片

 

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没有永久代,不需要配置永久代。

并发编程---1、JMM_第3张图片

 

3、虚拟机栈(Stack) :

是JVM的内存指令区(主要存放的都是指针、引用)。数据共享。同一个线程可共享数据。 该空间是线程私有,生命周期与线程相同,执行java方法(字节码)的服务,每个方法执行过程中都会创建一个栈帧(stack里面存的就是栈帧),用于存放局部变量表、操作数栈、动态连接等信息。目前就可以理解为局部变量表的存放空间。局部变量表存放的是8大基础数据类型(byte、char, int ,short,long,float,double,bolean)和对象引用,指令地址。编译时期就可以完成分配,因为每个数据类型长度都是确定的,所以进入一个方法的时候,这些都是可以确定的。

  • 会有两种异常StackOverFlowError和 OutOfMemoneyError。当线程请求栈深度大于虚拟机所允许的深度就会抛出StackOverFlowError错误;虚拟机栈动态扩展,当扩展无法申请到足够的内存空间时候,抛出OutOfMemoneyError。
  • Stack管理很简单,push一 定长度字节的数据或者指令,Stack指针压栈相应的字节位移;pop一定字节长度数据或者指令,Stack指针弹栈。Stack的速度很快,管理很简 单,并且每次操作的数据或者指令字节长度是已知的。

 

由于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区的可使用率,空该区的空间使用率高于这个数值时,会将对象送入老年代

 

4、本地方法栈(Native Method Stack):

jvm使用到的本地方法服务,本地方法大部分都是C和C++来实现的。比如Cglib代理的字节码服务实现等。

5、程序计数器:

当前线程执行的字节码的行号指示器,用C语言就是指针。每条线程都有一个独立的程序计数器,各条线程之间相互  不影响。如果执行java方法,PC就是执行虚拟机字节码指令的地址。如果执行本地方法,则值为空。

  • 当前线程所执行的行号指示器。通过改变计数器的值来确定下一条指令,比如循环,分支,跳转,异常处理,线程恢复等都是依赖计数器来完成。
  • Java虚拟机多线程是通过线程轮流切换并分配处理器执行时间的方式实现的。为了线程切换能恢复到正确的位置,每条线程都需要一个独立的程序计数器,所以它是线程私有的。
  • 唯一一块Java虚拟机没有规定任何OutofMemoryError的区块

 

最后在整理一下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”。

由此可总结为在建立一个对象时从两个地方分配内存,在堆中分配的内存实际建立这个对象,而在栈中分配的内存只是一个指向这个堆对象的指针(引用)而已。

 

2.2 对象定位方式

1) 句柄访问

堆里面每个对象的信息都存在句柄池中,句柄池中存着对象的实例数据指针、对象的类型数据指针;当在栈里面找到对象引用的时候,会去对里句柄池中找到该对象实例的地址,然后再堆中根据该地址找到实例对象数据。于此同时,对象的类型数据则会根据其地址在方法区中获取到相应的数据类型。(如 int i,String j 等等)

并发编程---1、JMM_第4张图片

 

2) 直接指针访问

跟句柄访问类似,只不过这次没有了句柄池,整个对象数据在一起,类型数据在对象数据里面。

并发编程---1、JMM_第5张图片

 

对比发现

句柄访问好处是引用中存放句柄地址,对象移动只会改变句柄中指针的指向,具体实例信息不用变动。

指针直接指向的好处就是省略一次指针定位开销。

 

2.3 JMM内存模型

这一小节我们来站在线程、内存的角度说下java内存模型。

并发编程---1、JMM_第6张图片

 

如上图所示,我们将内存模型分为3大部分

1)主内存:存放共享的信息

2)工作内存:存放私有信息的

如果是基本数据类型,直接分配到工作内存,如果是引用类型则引用的地址存放在工作内存,引用的对象存放在主内存中;

每个线程有一个自己的工作空间,只能自己去操作。每个线程使用工作空间的大小根据栈帧来分配的。虚拟机的栈就是在工作空间里面去划分的;

3)工作方式

1. 线程修改私有数据,直接在工作空间修改;

2. 线程修改共享数据,分为3步。这个也是最重要的一点,理解了就知道为什么多线程操作会不安全了。

A. 把内存数据复制到工作空间

B. 在工作空间修改,修改完毕后,刷新内存中的数据

C. 将工作空间数据写回到内存中

 

2.4 硬件内存架构与JMM

1、硬件架构

如下图所示

分为CPU、Cache、内存。读取速度 寄存器>Cache>内存

并发编程---1、JMM_第7张图片

 

这里要注意的问题就是:并发处理不同步时CPU缓存的一致性问题。因为从图中可以看到多CPU的情况下,每个cpu是自己的一套寄存器和Cache。只有内存是公用的。

解决这个问题的方法有以下几个方案:

1)、总线加锁,总线概念就是操作系统层面上的了包括:地址总线、数据总线、控制总线三个。

总线加锁就是同一时间,只能让一个CPU去操作,封锁力度较大,这样会降低cpu的吞吐量。

2)、缓存一致性协议(MESI)

当cpu在cache中操作数据时,如果该数据是共享变量,数据先从cache读到寄存器中,进行修改,然后写回到内存中。 这样大家会有一个疑问,别的cpu中的cache中的数据肯定跟内存不一样了。这样就需要在做一步操作,将Cache LINE 置为无效, 这样其他的cpu则不能从cache中读取数据了,只能从内存中读取数据,当然单核cpu就没这些问题。

 

2、java线程与硬件处理器关系

并发编程---1、JMM_第8张图片

 

java线程到内核cpu的对应关系如图所示。每个任务都会由线程池分配线程去完成,每个java线程会唤起内存线程来处理,内核线程由内核统一调度cpu去执行。这样就是为啥会存在cpu切换线程带来性能的开销的原因了。整体流程可以简单说是:进程->线程->OS->CPU。 大家一定要了解这个就会对并发有更深一层的理解了。

 

3、java内存模型与硬件内存架构的关系

并发编程---1、JMM_第9张图片

 

如图所示

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上面的代码绝对不会因为优化到下面来。

 

你可能感兴趣的:(并发编程深入原理的文章,面试,并发编程,java内存模型)