其主要的作用是围绕着在并发处理过程中如何处理可见性、原子性、有序性这三个特性而建立的模型。
JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障
Java 线程线程间通信使用共享内存隐式进行,代码中加同步锁等。
1:原子性
解决:
synchronized( 对象 ) {
要作为原子操作代码
}
2:可见性
在 JMM 中提供了 Volatile、final、synchronized 块来保证可见性。
volatile(易变关键字) 它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到 主存中获取它的值,线程操作 volatile 变量都是直接操作主存
3:有序性(Happeen-hefore 原则)
这个概念是相对,如果在本线程内,所有操作都是有序的,如果在另一个线程观察另一个线程,所有的操作 都是无序的。
后句表现为“指令的重排序”和“工作 内存和主存同步延迟”现象。
指令重排:JMM 在执行程序时为了提高性能,编译器和处理器通常会对程序的指令进行重排序,就是因为这些重排序,导致了多线程内存可见性问题。
volatile 修饰的变量,可以禁用指令重排
happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结, 抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变 量的读可见
4;CAS 与原子类
CAS 即 Compare and Swap ,它体现的一种乐观锁的思想
juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、 AtomicBoolean 等,它们底层就是采用 CAS 技术 + volatile 来实现的。
5:synchronized 优化
5.1 轻量级锁
学生(线程 A)用课本占座,上了半节课,出门了(CPU 时间到),回来一看,发现课本没变,说明没 有竞争,继续上他的课。 如果这期间有其它学生(线程 B)来了,会告知(线程 A)有并发访问,线程 A 随即升级为重量级锁,进入重量级锁的流程。 而重量级锁就不是那么用课本占座那么简单了,可以想象线程 A 走之前,把座位用一个铁栅栏围起来
5.2 锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻 量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
5.3 重量锁
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退 出了同步块,释放了锁),这时当前线程就可以避免阻塞。 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能 性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
5.4 偏向锁
5.5 其它优化

  1. 减少上锁时间 同步代码块中尽量短
  2. 减少锁的粒度 将一个锁拆分为多个锁提高并发度
  3. 锁粗化 多次循环进入同步块不如同步块内多次循环 另外 JVM 可能会做如下优化,把多次 append 的加锁操作 粗化为一次(因为都是对同一个对象加锁,没必要重入多次)
  4. 锁消除 JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候 就会被即时编译器忽略掉所有同步操作。
  5. 读写分离 CopyOnWriteArrayList ConyOnWriteSet

    模型

    JMM 是一个抽象的概念,并不是真实的存在,它涵盖了缓冲区,寄存器以及其他硬件和编译器优化。
    四、内存模型 JMM - 图1

主内存 和 工作内存

  • 主内存:就是计算机的内存,也就是经常提到的 8G 内存,16G 内存
  • 工作内存:但我们实例化 new student,那么 age = 25 也是存储在主内存中

即:JMM 内存模型的可见性,指的是当主内存区域中的值被某个线程写入更改后,其它线程会马上知晓更改后的值,并重新得到更改后的值。
为什么这里主线程中某个值被更改后,其它线程能马上知晓呢?其实这里是用到了总线嗅探技术
在说嗅探技术之前,首先谈谈缓存一致性的问题,就是当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一。
为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议主要有 MSI、MESI 等等。
MESI
当 CPU 写数据时,如果发现操作的变量是共享变量,即在其它 CPU 中也存在该变量的副本,会发出信号通知其它 CPU 将该内存变量的缓存行设置为无效,因此当其它 CPU 读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。
总线嗅探
那么是如何发现数据是否失效呢?
这里是用到了总线嗅探技术,就是每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是否过期了,当处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。
总线风暴
总线嗅探技术有哪些缺点?
由于 Volatile 的 MESI 缓存一致性协议,需要不断的从主内存嗅探和 CAS 循环,无效的交互会导致总线带宽达到峰值。因此不要大量使用 volatile 关键字,至于什么时候使用 volatile、什么时候用锁以及 Syschonized 都是需要根据实际场景的。
内存屏障
如何保证 CPU 上述重排序动作不会导致一致性的问题呢:内存屏障(memory barriers):

  • 写屏障(store barrier):在执行屏障之后的指令之前,先执行所有已经在存储缓冲中保存的指令。
  • 读屏障(load barrier):在执行任何的加载指令之前,先应执行所有已经在失效队列中的指令。

有了内存屏障,就可以保证缓存的一致性了。

通信

如果两个线程之间要进行通信的话:
四、内存模型 JMM - 图2

是不要以为线程之间的通信就是这么简单的,其实在 Java 中 JMM 内存模型定义了八种操作来实现同步的细节。

  • read 读取,作用于主内存把变量从主内存中读取到本本地内存。
  • load 加载,主要作用本地内存,把从主内存中读取的变量加载到本地内存的变量副本中
  • use 使用,主要作用本地内存,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。、
  • assign 赋值 作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store 存储 作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的 write 的操作。
  • write 写入 作用于主内存的变量,它把 store 操作从工作内存中一个变量的值传送到主内存的变量中。
  • lock 锁定 :作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock 解锁:作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

同时在 Java 内存模型中明确规定了要执行这些操作需要满足以下规则:

  • 不允许 read 和 load、store 和 write 的操作单独出现。
  • 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
  • 一个变量在同一时刻只允许一条线程对其进行 lock 操作,lock 和 unlock 必须成对出现
  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值
  • 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。

    对象内存存储布局

    由于 Java 面向对象的思想,在 JVM 中需要大量存储对象,存储时为了实现一些额外的功能,需要在对象中添加一些标记字段用于增强对象功能,这些标记字段组成了对象头。
    普通对象 new XX()
    markword 标记字,class pointer 类型指针,instance data 实例对象,padding 对齐
    其中 markword 和 class point 一起称为对象头
    如果前三个一个没有满足 8 个字节,用 padding 补齐
    数组
    int[] a = new int[4]
    T[] a = new T[5]
    markword,class poniter,length(数组长度 4 字节) ,instance data 实例对象,padding

    markWord 标记字

    markWord的位长度为 JVM 的一个 Word 大小,也就是说 32 位 JVM 的Mark word为 32 位,64 位 JVM 为 64 位。
    为了让一个字大小存储更多的信息,JVM 将字的最低两个位设置为标记位,不同标记位下的 Mark Word 示意如下:
    lock:2 位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了 lock 标记。该标记的值不同,整个 mark word 表示的含义不同。
    四、内存模型 JMM - 图3

biased_lock:对象是否启用偏向锁标记,只占 1 个二进制位。为 1 时表示对象启用偏向锁,为 0 时表示对象没有偏向锁。
age:4 位的 Java 对象年龄。在 GC 中,如果对象在 Survivor 区复制一次,年龄增加 1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行 GC 的年龄阈值为 15,并发 GC 的年龄阈值为 6。由于 age 只有 4 位,所以最大值为 15,这就是-XX:MaxTenuringThreshold选项最大值为 15 的原因。
identity_hashcode:25 位的对象标识 Hash 码,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程 Monitor 中。
thread:持有偏向锁的线程 ID。
epoch:偏向时间戳。
ptr_to_lock_record:指向栈中锁记录的指针。
ptr_to_heavyweight_monitor:指向管程 Monitor 的指针。

ClassPoint 类型指针

指针的位长度为 JVM 的一个字大小,即 32 位的 JVM 为 32 位,64 位的 JVM 为 64 位。
如果应用的对象过多,使用 64 位的指针将浪费大量内存,统计而言,64 位的 JVM 将会比 32 位的 JVM 多耗费 50%的内存。为了节约内存可以使用选项+UseCompressedOops开启指针压缩,

ArrayLength

如果对象是一个数组,那么对象头中还需要有额外的空间存储数组的长度。

Instance Data 实例数据

它是对象真正存储的有效信息,包括程序代码中定义的各种字段类型(包括从父类继承下来的和自己本身拥有的字段),注意这里有一些规则:相同宽度的字段总是被分配在一起,父类中定义的变量会出现在子类之前,因为父类的加载是优先于子类加载的

对象的访问方式

句柄方式
直接指针

TLAB

Thread Local Allocation Buffer 即线程本地分配缓存区,这是一个线程专用的内存分配区域。
如果设置了虚拟机参数 -XX:UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。
TLAB 空间的内存非常小,缺省情况下仅占有整个 Eden 空间的 1%,也可以通过选项-XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小。
TLAB 的本质其实是三个指针管理的区域:start,top 和 end,每个线程都会从 Eden 分配一块空间,例如说 100KB,作为自己的 TLAB,其中 start 和 end 是占位用的,标识出 eden 里被这个 TLAB 所管理的区域,卡住 eden 里的一块空间不让其它线程来这里分配。
事务总不是完美的,TLAB 也又自己的缺点。因为 TLAB 通常很小,所以放不下大对象。设置最大浪费空间,当剩余的空间小于最大浪费空间,那该 TLAB 属于的线程在重新向 Eden 区申请一个 TLAB 空间。进行对象创建,还是空间不够,那你这个对象太大了,去 Eden 区直接创建吧!但又会造成新的病垢。Eden 空间够的时候,你再次申请 TLAB 没问题,我不够了,Heap 的 Eden 区要开始 GC,TLAB 允许浪费空间,导致 Eden 区空间不连续,积少成多。以后还要人帮忙打理。

内存泄露与溢出

1:简介

1、内存泄漏 memory leak 是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄漏似乎不会有大的影响,但内存泄漏堆积后的后果就是内存溢出。
2、内存溢出 out of memory 指程序申请内存时,没有足够的内存供申请者使用,或者说,给了你一块存储 int 类型数据的存储空间,但是你却存储 long 类型的数据,那么结果就是内存不够用,此时就会报错 OOM,即所谓的内存溢出。

2:为什么会发生内存泄露?

对象 A 引用对象 B,A 的生命周期(t1-t4)比 B 的生命周期(t2-t3)要长,当 B 在程序中不再被使用的时候,A 仍然引用着 B。在这种情况下,垃圾回收器是不会回收 B 对象的,这就可能造成了内存不足问题,因为 A 可能不止引用着 B 对象,还可能引用其它生命周期比 A 短的对象,这就造成了大量无用对象不能被回收,且占据了昂贵的内存资源。
四、内存模型 JMM - 图4

如何检查?
由于是发生在堆内存中,不可见,需要借助 MAT,LeakCanary 等工具检测

3:常见的内存泄露及解决方法:

1:单例引起的内存泄露:静态实例存在的生命周期和应用一样长
2:资源未关闭引起的内存泄露
怎么阻止内存泄露?
1.使用 List、Map 等集合时,在使用完成后赋值为 null
2.使用大对象时,在用完后赋值为 null
3.目前已知的 jdk1.6 的 substring()方法会导致内存泄露
4.避免一些死循环等重复创建或对集合添加元素,撑爆内存
5.简洁数据结构、少用静态集合等
6.及时的关闭打开的文件,socket 句柄等
7.多关注事件监听(listeners)和回调(callbacks),比如注册了一个 listener,当它不再被使用的时候,忘了注销该 listener,可能就会产生内存泄露

4:内存溢出的解决方案

1、内存泄漏 memory leak 是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄漏似乎不会有大的影响,但内存泄漏堆积后的后果就是内存溢出。
2、内存溢出 out of memory 指程序申请内存时,没有足够的内存供申请者使用,或者说,给了你一块存储 int 类型数据的存储空间,但是你却存储 long 类型的数据,那么结果就是内存不够用,此时就会报错 OOM,即所谓的内存溢出。
重点排查以下几点:
1.检查对数据库查询中,是否有一次获得全部数据的查询。一般来说,如果一次取十万条记录到内存,就可能引起内存溢出。这个问题比较隐蔽,在上线前,数据库中数据较少,不容易出问题,上线后,数据库中数据多了,一次查询就有可能引起内存溢出。因此对于数据库查询尽量采用分页的方式查询。
2.检查代码中是否有死循环或递归调用。
3.检查是否有大循环重复产生新对象实体。
4.检查对数据库查询中,是否有一次获得全部数据的查询。一般来说,如果一次取十万条记录到内存,就可能引起内存溢出。这个问题比较隐蔽,在上线前,数据库中数据较少,不容易出问题,上线后,数据库中数据多了,一次查询就有可能引起内存溢出。因此对于数据库查询尽量采用分页的方式查询。
5.检查 List、MAP 等集合对象是否有使用完后,未清除的问题。List、MAP 等集合对象会始终存有对对象的引用,使得这些对象不能被 GC 回收。
第四步,使用内存查看工具动态查看内存使用情况

JVM 内存溢出

1、堆内存溢出
堆内存中主要存放对象、数组等,只要不断地创建这些对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾收集回收机制清除这些对象,当这些对象所占空间超过最大堆容量时,就会产生 OutOfMemoryError 的异常。
新产生的对象最初分配在新生代,新生代满后会进行一次 Minor GC,如果 Minor GC 后空间不足会把该对象和新生代满足条件的对象放入老年代,老年代空间不足时会进行 Full GC,之后如果空间还不足以存放新对象则抛出 OutOfMemoryError 异常。
常见原因:内存中加载的数据过多如一次从数据库中取出过多数据;集合对对象引用过多且使用完后没有清空;代码中存在死循环或循环产生过多重复对象;堆内存分配不合理;网络连接问题、数据库问题等。
2、虚拟机栈/本地方法栈溢出
(1)StackOverflowError:当线程请求的栈的深度大于虚拟机所允许的最大深度,则抛出 StackOverflowError,简单理解就是虚拟机栈中的栈帧数量过多(一个线程嵌套调用的方法数量过多)时,就会抛出 StackOverflowError 异常。最常见的场景就是方法无限递归调用,
(2)OutOfMemoryError:如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError.
虚拟机中可以供栈占用的空间 ≈ 可用物理内存 - 最大堆内存 - 最大方法区内存,比如一台机器内存为 4G,系统和其他应用占用 2G,虚拟机可用的物理内存为 2G,最大堆内存为 1G,最大方法区内存为 512M,那可供栈占有的内存大约就是 512M,假如我们设置每个线程栈的大小为 1M,那虚拟机中最多可以创建 512 个线程,超过 512 个线程再创建就没有空间可以给栈了,就报 OutOfMemoryError 异常了。
事例:
/
设置每个线程的栈大小:-Xss2m
运行时,不断创建新的线程(且每个线程持续执行),每个线程对一个一个栈,最终没有多余的空间来为新的线程分配,导致OutOfMemoryError
*/
public class StackOOM {
private static int threadNum = 0;
public void doSomething() {
try {
Thread.sleep(100000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
final StackOOM stackOOM = new StackOOM();
try {
while (true) {
threadNum++;
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
stackOOM.doSomething();
}
});
thread.start();
}
} catch (Throwable e) {
System.out.println(“目前活动线程数量:” + threadNum);
throw e;
}
}
}
上述代码运行后会报异常,在堆栈信息中可以看到 java.lang.OutOfMemoryError: unable to create new native thread 的信息,无法创建新的线程,说明是在扩展栈的时候产生的内存溢出异常。
总结:在线程较少的时候,某个线程请求深度过大,会报 StackOverflow 异常,解决这种问题可以适当加大栈的深度(增加栈空间大小),也就是把-Xss 的值设置大一些,但一般情况下是代码问题的可能性较大;在虚拟机产生线程时,无法为该线程申请栈空间了,会报 OutOfMemoryError 异常,解决这种问题可以适当减小栈的深度,也就是把-Xss 的值设置小一些,每个线程占用的空间小了,总空间一定就能容纳更多的线程,但是操作系统对一个进程的线程数有限制,经验值在 3000~5000 左右。在 jdk1.5 之前-Xss 默认是 256k,jdk1.5 之后默认是 1M,这个选项对系统硬性还是蛮大的,设置时要根据实际情况,谨慎操作。
3、方法区溢出
方法区主要用于存储虚拟机加载的类信息、常量、静态变量,以及编译器编译后的代码等数据,所以方法区溢出的原因就是没有足够的内存来存放这些数据。
由于在 jdk1.6 之前字符串常量池是存在于方法区中的,所以基于 jdk1.6 之前的虚拟机,可以通过不断产生不一致的字符串(同时要保证和 GC Roots 之间保证有可达路径)来模拟方法区的 OutOfMemoryError 异常;但方法区还存储加载的类信息,所以基于 jdk1.7 的虚拟机,可以通过动态不断创建大量的类来模拟方法区溢出。
/

设置方法区最大、最小空间:-XX:PermSize=10m -XX:MaxPermSize=10m
运行时,通过cglib不断创建JavaMethodAreaOOM的子类,方法区中类信息越来越多,最终没有可以为新的类分配的内存导致内存溢出
*/
public class JavaMethodAreaOOM {
public static void main(final String[] args){
try {
while (true){
Enhancer enhancer=new Enhancer();
enhancer.setSuperclass(JavaMethodAreaOOM.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invokeSuper(o,objects);
}
});
enhancer.create();
}
}catch (Throwable t){
t.printStackTrace();
}
}
}
上述代码运行后会报“java.lang.OutOfMemoryError: PermGen space”的异常,说明是在方法区出现了内存溢出的错误。

转载 https://www.yuque.com/jykss/jykss/qaomdf