JVM内存结构
JVM内存结构:java代码是运行在虚拟机上,虚拟机会将内存分为不同的区域,每个区域又有不同的作用。
class文件经过类加载器转换后会到达运行数据区,即Runtime Data Area。绿色的(方法区和堆)是线程共享的,黄色的(java栈,本地方法栈和程序计数器)是线程私有的。
- 堆(heap):最大的一块,也占用内存最多。里面主要是new出来的以及其他指令创建的实例对象,并且这些实例对象不再有引用的话会被垃圾回收,包括数组,因为数组也是对象。
- 虚拟机栈(JVM stack):也就是上图里的java栈,保存了各个基本的数据类型,以及对于对象的引用,特点就是在编译的时候就确定了大小,在运行时大小不会改变。
- 方法区(methed):存储的是已经加载static,类信息以及常量信息,还包含着永久引用。普通的引用在虚拟机栈中,但是像有些引用是static对象的引用,也就是永久引用,会放在方法区中。
- 本地方法栈:保存和本地方法相关的信息,本地方法指的是native方法。
- 程序计数器:它所占的区域是最小的,主要保存当前线程执行程序字节码的行号数,也就是在上下文切换时的数据会保存下来,还包括下个程序需要执行的指令、分支、循环等异常处理,这些是依赖程序计数器的。
Java对象模型
Java对象模型:每个对象在JVM中存储是有一定结构的,这个结构模型称之为Java对象模型。
- Java对象模型是Java对象的存储模型。
- JVM会给每个类创建一个instanceKlass,保存在方法区中,用来在JVM层表示该Java类。
- 在Java代码中,使用new创建对象时,在栈中对对象进行赋值,JVM会在堆创建instanceOopDesc对象,这个对象包含了对象头和示例数据。
JMM(Java Memory Model):Java内存模型
1.JMM 是一组规范,需要各个JVM共同来实现遵守JMM规范,以便于开发者可以利用这些规范,更方便的开发多线程程序。
如果没有这样一个JMM内存模型,那么很可能经过不同的JVM的不同规则的重排序,导致不同的虚拟机上运行的结果不一样,这是很大的问题。
2.JMM除了是一种规范,还是工具类和关键字原理,
- volatile、synchronized、Lock等的原理都是JMM。
- 如果没有JMM,那需要我们自己制定什么时候用内存栅栏等(内存栅栏可以简单的认为它是工作内存和主内存之间的拷贝和同步),那是相当麻烦的;因为有了JMM我们只需要用同步工具和关键字就可以开发并发程序。
- 最最重要的三点内容:重排序、可见性、原子性(说起JMM一定要能想到这三点)
重排序:
- 定义:代码实际执行顺序和代码在java文件里的顺序不一致,代码指令并不是严格按照代码语句顺序执行,他们的顺序被改变了,这就是重排序。
- 好处:提高处理速度(重排序会对整个代码进行指令的优化、)
- 重排序的三种情况:编译器优化、CPU指令重排序、内存的“重排序”
/**
* 演示重排序的现象
* 因为不是每次都出现,直到某个条件才停止
*/
public class OutOfOrderExecution {
private static int x = 0,y = 0;
private static int a = 0,b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (; ; ){
i++;
x = 0;y=0;a=0;b=0;
//CountDownLatch 工具类可以起到闸门的作用,后面的数字代表几次倒计时,为了演示下面结果里的第三个,否则就不需要加这个工具类
CountDownLatch latch = new CountDownLatch(1);
Thread one = new Thread(new Runnable() {
@Override
public void run() {
try {
//等待放开闸门,为了能出现两个线程能同时执行第一个赋值语句;
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
try {
//等待放开闸门,为了能出现两个线程能同时执行第一个赋值语句
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
b = 1;
y = a;
}
});
one.start();
two.start();
//放开闸门,
latch.countDown();
one.join();
two.join();
String result = "第"+i+"次 ("+ x + "," + y+")";
if(x == 1 && y == 1){
//下面的if是演示第四种情况,也就是重排序
// if(x == 0 && y == 0){
System.out.println(result);
break;
}else{
System.out.println(result);
}
}
}
}
- 上方代码执行顺序决定了x和y的值,正常情况下按我们分析一共三种情况,正常,那肯定有不正常情况,就是第四种出现重排序
- a=1;x=b(0);b=1;y=a(1);结果x=0,y=1
- b=1;y=a(0);a=1;x=b(1);结果x=1,y=0
- b=1;a=1;x=b(1);y=a(1);结果x=1,y=1(这个结果得多次执行才能出现,或者将代码全放进for循环里,就不用多次运行了)
- 出现了 x=0,y=0,正常意义上是不可能出现的,能出现就说明经过了重排序,线程2的代码的执行逻辑被改变成了,y=a;a=1;x=b;b=1; 我运行时第291539次出现了(……第291538次 (0,1) 第291539次 (0,0))
可见性
/**
* 演示可见性带来的问题
*/
public class FieldVisibility {
int a = 1;
int b = 2;
private void print() {
System.out.println("a = "+a+",b = "+b);
}
private void change() {
a = 3;
b = a;
}
public static void main(String[] args) {
while (true){
FieldVisibility test = new FieldVisibility();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}
}
}
分析结果里的四种情况:
a = 3,b = 2
a = 1,b = 2
a = 3,b = 3
a = 1,b = 3
前三种很好理解,最后一个a = 3,b = 1出现的非常罕见,原因是第一个线程将a和b改成了3,但是两个线程不能直接通讯,需要通过主存,因为主存同步,第一个线程在自己的本地内存里进行的修改,并将b=3同步到主内存中,a=3还没同步过去,线程2就进行了打印。
解决方法:为了避免上面的第四种情况,使用volatile关健字,这个关健字就会强制线程每次读取的都是被修改过的,也就是最新的值。
为什么会出现可见性:
- CPU有多级缓存,导致读的数据过期。
- 高速缓存的容量比主存小,但是速度仅次于寄存器,所以在CPU和主存之间就多了Cache层
- 线程间的对于共享的可见性问题不是由多核引起的,而是由多级缓存引起的。
- 如果所有核心都只有一个缓存,就不存在内存可见性问题了,因为大家看到的都是同一缓存的数据。
- 每一个核心都会将自己需要数据读到独占缓存中(每一个核心都有自己独占的缓存),数据修改后也是写入到自己独占缓存中,然后等待刷入到内存中。所以会导致有些核心读取的值是一个过期的值
我们的主存(RAM)是最大的,但运行速度却是非常的慢,而越往上的缓存越小,但是速度却越快,而CPU的运行速度非常快。如果每次从RAM里拿数据,严重影响CPU效率,所以CPU直接从寄存器(registers)或者L1里面拿,是相对而言比较快的,但是由于不是直接从RAM里获取的数据,就可能出现可见性问题。例如核心4改写了值并且同步到了右边的L2,而左边的L2是从L3获取的,L3此时还不知道右侧的L2变化了,就导致核心1和核心4看到同一个变量在同一时刻的值不一样。
什么是主内存和本地内存?
- JMM定义了一套读写内存数据的规范,我们不需要关心一级缓存二级缓存的问题,但是,JMM抽象了主内存和本地内存的概念。
- 这里的本地内存并不是一块给每个线程分配的内存,而是JMM的一个抽象,是对寄存器、一级缓存、二级缓存等的抽象。
- JMM有以下两个规定
- 所有变量都存储在主内存中,同时每个线程也有自己独立的工作内存,每个工作内存中的变量内容是主内存的拷贝。
- 线程不能直接读取主内存中的变量,而是只能操作自己内存中的变量,然后再同步到主内存中。
主内存是多个线程共享的,但线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成。
- 总结:所有的共享变量存在主内存中,每个线程都有自己的本地内存,而且线程读写共享数据也是通过主内存交换的,所以才导致了可见性问题。
* happens-before
什么是happens-before?
- happends-before规则是用来解决可见性问题的:在时间上,动作A发生在动作B之前,B保证能看到A,这就是happens-before。
- 两个操作可以用happens-before来确定他们的执行顺序:如果一个操作happens-before于另一个操作,那么我们说第一个操作对于第二个操作是可见的。
什么场景符合happends-before
- 单线程原则:一个线程运行一段逻辑,比如上方的change()方法a=3;b=a;,如果a=3先运行,那给b赋值就一定能看到a为3,注意,说的是a=3先运行,因为他可能出现重排序的,导致a=3后运行。happends-before并不影响重排序
- 锁操作(Synchronized和Lock):一个线程解锁了,另一个线程加锁,它一定能看到解锁的线程的操作。
- volatile变量:只要写线程对volatile修饰的变量完成了写操作,读线程就一定能看到。而且它不仅能保证自己的可见性,它还能对它之前的变量操作保证可见性,这就是volatile的近朱者赤原则。
- 线程启动:主线程启动子线程,子线程一定能看到主线程启动它之前所有的操作。
- 线程join:join后面的语句一定能看到刚才我等待的线程所有语句的执行。
- 传递性:如果hb(A,B)而且hb(B,C),那么可以推出hb(A,C).
- 中断:一个线程被其他线程interrupt时,那么检测中断(isInterrupted)或抛出InterruptedException一定能看到。
- 构造方法:对象构造方法的最后一行指令happens-before于finalize()方法的第一行指令。
- 工具类的Happens-Before原则:
- 线程安全的容器get一定能看到在此之前的put等存入动作。
- CountDownLatch:上面用到了,这个工具类可以起到闸门的作用,后面的数字代表几次倒计时。
- Semaphore:信号量,和CountDownLatch一样都是控制线程流程的。它是一个计数信号量,必须由获取它的线程释放。 常用于限制可以访问某些资源的线程数量,例如通过 Semaphore 限流。
- Future:可以后台执行,并且可以拿到线程执行结果的类,它的get方法就是拿到执行的结果。get方法对它执行逻辑是可见的,所以get时不会出现拿到了执行了一半的结果。
- 线程池:我们会给线程池提交很多任务,用submit等方法,而在提交的任务中,每一个任务,它在提交前都能看到提交前的所有执行结果。
- CyclicBarrier:和CountDownLatch一样也是起到一个栅栏的作用,之前也用到过。这个工具类可以让线程根据我们需要在某一个地方等待,直到要等待的人员都就绪,然后一起出发。
* volatile关键字
- volatile是什么?
volatile是一种同步机制,比synchronized或者Lock相关类更轻量,因为使用volatile并不会发生上下文切换等开销很大的行为。
如果一个变量被修饰成volatile,那么JVM就知道这个变量可能会被并发修改,JVM就会进行禁止重排序等操作。
由于开销小,相应能力也小,虽然说volatile是用来同步的保证线程安全的,但是volatile做不到synchronized那样的原子保护,volatile仅在很有限的场景下才能发挥作用。 - volatile的使用场景
- 不适用:a++
- 使用场合1:boolean(布尔值)或者flag(标记位),如果一个共享变量自始至终是只被各个线程直接赋值(赋值不取决于之前的值,比如boolean = !boolean ,这种加volatile也没用),而没有其他读改等操作,那么就可以被volatile来代替synchronized或者代替原子变量,因为赋值自身具有原子性,而volatile又保证了可见性,所以就足以保证线程安全。
- 适用场合2:作为刷新之前变量的触发器
- volatile的作用:可见性、禁止重排序
- 可见性:读一个volatile变量之前,需要先使响应的本地缓存失效,这样就必须到主内存读取最新值,写一个volatile属性会立刻刷入到主内存 。
- 禁止指令重排序优化:解决单例双重锁乱序问题。
- volatile和synchronized的关系
volatile在这方面可以看做是轻量版的synchronized:如果一个共享变量自始至终只被各个线程直接赋值,而没有其他操作,那么就可以被volatile来代替synchronized或者代替原子变量,因为赋值自身具有原子性,而volatile又保证了可见性,所以就足以保证线程安全。 - 用volitile修正重排序问题
/**
* 演示volatile禁止重排序,给上面演示重排序代码OutOfOrderExecution,变量加上volatile,就不会出现x == 0 && y == 0
*/
`public class OutOfOrderExecution {
private volatile static int x = 0,y = 0;
private volatile static int a = 0,b = 0;``
…………
…………
- volatile可以使得long 和 double 的赋值是原子的,因为这两个赋值本身不是原子的,后面会说。
能保证可见性的措施
- 除了volatile可以让变量保证可见性外,synchronized、Lock、并发集合、Thread.join()和Thread.start()等都可以保证可见性。
synchronized
- synchronized不仅保证了原子性,还保证了可见性
- synchronized不仅让被保护的代码安全,还近朱者赤
原子性
- 什么是原子性
一系列操作,要么全部执行成功,要么全部不执行,不会出现执行部分的情况,是不可分割的。
ATM取钱或转账 就是很好的例子
i++ 不是原子性
用synchronized实现原子性 - Java 原子操作有哪些
- 除了long和double之外的基本类型(int,byte,boolean,short,char,float)的赋值操作
- 所有引用reference的赋值操作,不管是32位的机器还是64位的机器
- java.concurrent.Atomic.*包中所有类的原子操作
- long 和 double的原子性
问题描述:官方文档、long和double是64位值,对其写入会视为两个32位单独的写入操作、读取错误、使用volatile解决。
结论:在32位的JVM上,long和double的操作不是原子的,但是在64位的JVM上是原子的。
实际开发中:商用Java虚拟机中不会出现这种问题,因为在32位机器上自动的保证了long 和 double的写入的原子性 - 原子操作+原子操作 != 原子操作
简单的把两个原子操作组合在一起,并不能保证整体依然具有原子性。
全同步的HashMap也不完全安全。单独操作没有多大的问题,但是将多个操作组合起来,可能会出现问题。
上篇:多线程带来的性能问题
下篇:单例模式的8种写法