硬件内存模型:
Java内存模型:
每个线程都有一个工作内存,线程只可以修改自己工作内存中的数据,然后再同步回主内存,主内存由多个内存共享。
下面 8 个操作都是原子的,不可再分的:
1) lock:作用于主内存的变量,它把一个变量标识为一个线程独占的状态。
2) unlock:作用于主内存的变量,他把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
3) read:作用于主内存变量,他把一个变量的值从主内存传输到线程的工作内存,以便随后的 load 操作使用。
4) load:作用于工作内存的变量,他把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
5) use:作用于工作内存的变量,他把工作内存中一个变量的值传递给执行引擎,每 当虚拟机遇到一个需要使用到变量的值得字节码指令时将会执行这个操作。
6) assign:作用于工作内存的变量,他把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
7) store:作用于工作内存的变量,他把工作内存中一个变量的值传送到主内存中,以便随后 write 使用。
8) write:作用于主内存的变量,他把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
JVM对Java内存模型的实现
在JVM内部,Java内存模型把内存分成了两部分:线程栈区和堆区,下图展示了Java内存模型在JVM中的逻辑视图:
JVM中运行的每个线程都拥有自己的线程栈,线程栈包含了当前线程执行的方法调用相关信息,我们也把它称作调用栈。随着代码的不断执行,调用栈会不断变化。
线程栈还包含了当前方法的所有本地变量信息。一个线程只能读取自己的线程栈,也就是说,线程中的本地变量对其它线程是不可见的。
堆区包含了Java应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如Byte、Integer、Long等等)。不管对象是属于一个成员变量还是方法中的本地变量,它都会被存储在堆区。
下图展示了调用栈和本地变量都存储在栈区,对象都存储在堆区:
一个本地变量如果是原始类型,那么它会被完全存储到栈区。
一个本地变量也有可能是一个对象的引用,这种情况下,这个本地引用会被存储到栈中,但是对象本身仍然存储在堆区。
对于一个对象的成员方法,这些方法中包含本地变量,仍需要存储在栈区,即使它们所属的对象在堆区。
对于一个对象的成员变量,不管它是原始类型还是包装类型,都会被存储到堆区。
Static类型的变量以及类本身相关信息都会随着类本身存储在堆区。
堆中的对象可以被多线程共享。如果一个线程获得一个对象的应用,它便可访问这个对象的成员变量。如果两个线程同时调用了同一个对象的同一个方法,那么这两个线程便可同时访问这个对象的成员变量,但是对于本地变量,每个线程都会拷贝一份到自己的线程栈中。
支撑Java内存模型的基础原理
指令重排序
在执行程序时,为了提高性能,编译器和处理器会对指令做重排序。但是,JMM确保在不同的编译器和不同的处理器平台之上,通过插入特定类型的Memory Barrier
来禁止特定类型的编译器重排序和处理器重排序,为上层提供一致的内存可见性保证。
- 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序:如果不存l在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序:处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
数据依赖性
如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。 编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。
as-if-serial
不管怎么重排序,单线程下的执行结果不能被改变,编译器、runtime和处理器都必须遵守as-if-serial语义。
内存屏障(Memory Barrier )
内存屏障是一组处理器指令,作用:
1.先于这个内存屏障的指令必须先执行,后于这个内存屏障的指令必须后执行。
2.使得内存可见性。
volatile是基于Memory Barrier实现的。
如果一个变量是volatile
修饰的,JMM会在写入这个字段之后插进一个Write-Barrier
指令,并在读这个字段之前插入一个Read-Barrier
指令。这意味着,如果写入一个volatile
变量,就可以保证:
- 一个线程写入变量a后,任何线程访问该变量都会拿到最新值。
- 在写入变量a之前的写入操作,其更新的数据对于其他线程也是可见的。因为Memory Barrier会刷出cache中的所有先前的写入。
volatile 禁止指令重排序有两层意思:1)当程序执行到 volatile 变量的读操作或者写操作时,在其前面操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没执行。2)在进行指令优化时,不能将在对 volatile 变量访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。
happens-before
在JMM中,如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,这个的两个操作既可以在同一个线程,也可以在不同的两个线程中。
与程序员密切相关的happens-before规则如下:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中任意的后续操作。
- 监视器锁规则:对一个锁的解锁操作,happens-before于随后对这个锁的加锁操作。
- volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。
- 传递性规则:如果 A happens-before B,且 B happens-before C,那么A happens-before C。
注意:两个操作之间具有happens-before关系,并不意味前一个操作必须要在后一个操作之前执行!仅仅要求前一个操作的执行结果,对于后一个操作是可见的,且前一个操作按顺序排在后一个操作之前。
synchronized关键字【具有锁重入特性,不具有继承性】:
1)在两个线程访问同一个对象中的同步方法时,一定是线程安全的。什么是两个线程访问同一个对象呢?例子如下:
1 public static void main(String[] args) { 2 HasSelfPrivateNum numRef = new HasSelfPrivateNum(); 3 ThreadA athread = new ThreadA(numRef); 4 athread.start(); 5 ThreadB bthread = new ThreadB(numRef); 6 bthread.start(); 7 }
2)在两个线程分别访问同一个类的两个不同实例的相同名称的同步方法时,是以异步的方式执行的,所以synchronized在这里似乎没什么用。两个线程分别访问同一个类的两个不同实例?例子如下:
1 public static void main(String[] args) { 2 HasSelfPrivateNum numRef1 = new HasSelfPrivateNum(); 3 HasSelfPrivateNum numRef2 = new HasSelfPrivateNum(); 4 ThreadA athread = new ThreadA(numRef1); 5 athread.start(); 6 ThreadB bthread = new ThreadB(numRef2); 7 bthread.start(); 8 }
总结【都是针对1)的情况,即多个线程访问同一对象的情况】:
(1)synchronized取得的锁是对象锁,而不是把一段代码或方法当作锁,所以在1)中,哪个线程先执行带synchronized关键字的方法,哪个线程就持有该方法所属对象的锁lock,那么其他线程只能呈等待状态,前提是多个线程访问的是同一个对象。而如果多个线程访问多个对象,则JVM会创建多个锁。
(2)对于同步方法,线程间只能同步调用,即同一时间只有一个线程可以执行同步代码块中的代码,且对当前类中的其他同步代码块具有阻塞作用;对于非同步方法,线程可以以异步方式调用,尽管可能这个对象锁已经被占用。
(3)由于字符串常量池的特殊性,一般不对字符串常量进行加锁,即类似synchronized("aa")这种。
对于2)的情况的解决办法是:将synchronized关键字从方法范围上,拿到代码块中,细化同步范围,且进行全局锁,就是对类加锁而不是对象,即synchronized (Sync.class),或者利用synchronized static加在方法上。
synchronized的锁可重入特性:
当线程请求一个由其它线程持有的对象锁时,该线程会阻塞,而当线程请求由自己持有的对象锁时,如果该锁是重入锁,请求就会成功,否则阻塞.
我们来看看synchronized,它拥有强制原子性的内置锁机制,是一个重入锁,所以在使用synchronized时,当一个线程请求得到一个对象锁后再次请求此对象锁,可以再次得到该对象锁,就是说在一个synchronized方法/块的内部调用本类的其他synchronized方法/块时,是永远可以拿到锁,如下:
1 public class Child extends Father { 2 public static void main(String[] args) { 3 Child child = new Child(); 4 child.doSomething(); 5 } 6 7 public synchronized void doSomething() { 8 System.out.println("child.doSomething()"); 9 doAnotherThing(); // 调用自己类中其他的synchronized方法 10 } 11 12 private synchronized void doAnotherThing() { 13 super.doSomething(); // 调用父类的synchronized方法 14 System.out.println("child.doAnotherThing()"); 15 } 16 } 17 18 class Father { 19 public synchronized void doSomething() { 20 System.out.println("father.doSomething()"); 21 } 22 }
运行结果:
child.doSomething()
father.doSomething()
child.doAnotherThing()
这里的对象锁只有一个,就是child对象的锁,当执行child.doSomething时,该线程获得child对象的锁,在doSomething方法内执行doAnotherThing时再次请求child对象的锁,因为synchronized是重入锁,所以可以得到该锁,继续在doAnotherThing里执行父类的doSomething方法时第三次请求child对象的锁,同理可得到,如果不是重入锁的话,那这后面这两次请求锁将会被一直阻塞,从而导致死锁。
所以在java内部,同一线程在调用自己类中其他synchronized方法/块或调用父类的synchronized方法/块都不会阻碍该线程的执行,就是说同一线程对同一个对象锁是可重入的,而且同一个线程可以获取同一把锁多次,也就是可以多次重入。因为java线程是基于“每线程(per-thread)”,而不是基于“每调用(per-invocation)”的(java中线程获得对象锁的操作是以每线程为粒度的,per-invocation互斥体获得对象锁的操作是以每调用作为粒度的)
我们再来看看重入锁是怎么实现可重入性的,其实现方法是为每个锁关联一个线程持有者和计数器,当计数器为0时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为0,则释放该锁。
volatile和synchronized的区别
- volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
- volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
- volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
- volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
- volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化
解决办法:
1.使用synchronized同步锁。
缺点:同步锁对程序性能的影响太大了。
2.使用volatile关键字。
优点:轻量级。保证用volatile修饰的变量对所有线程的可见性(当一个线程修改了变量的值,新的值会立刻同步到主内存当中,其他线程读取这个变量的时候,会从主内存中拉取最新的变量值)。
缺点:不能保证数据的原子性,比如有i++操作时还是有可能出现错误。