目录
2.5.5 volatile和synchronized的关系
5.6 64位的double和long写入的时候是原子的吗?
1. Java代码到CPU指令
2. JVM内存结构/Java内存模型/Java对象模型
- JVM内存结构,和Java虚拟机的运行时区域有关;
- Java内存模型,和Java的并发编程有关;
- Java对象模型,和Java对象在虚拟机中的表现形式有关。
下图把整个Java虚拟机的运行时区域分为5个部分,这就是JVM内存结构。
Java对象模型:
- Java对象自身的储存模型;
- JVM会给这个类创建一个instanceKlass,保存在方法区,用来在JVM层表示该Java类;
- 当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据。
Java内存模型,就是JMM。JMM实际上是一种规范,需要各个JVM的实现来遵守JMM规范,以便于开发者可以利用这些规范,更方便地开发多线程程序。
如果没有JMM,那么很可能经过了不同JVM(比如OpenJDK,Oracle的JDK)的不同规则的重排序之后,会导致不同的虚拟机上运行的结果不一样。
此外,JMM是工具类和关键字的原理。
- volatile、synchronized、Lock等的原理都是JMM;
- 通过JMM,开发者只需要用同步工具和关键字就可以开发并发程序。
JMM最重要的3点内容分别是:重排序、可见性、原子性。
2.1 JMM之重排序
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 {
Thread one = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
one.start();
two.start();
one.join();
two.join();
System.out.println("x = " + x + ", y = " + y);
}
}
第一次输出:
x = 0, y = 1
稍微换一下线程的启动顺序:
得到结果:
再通过工具类使得两个线程同时开始:
import java.util.concurrent.CountDownLatch;
/** 演示重排序,“直到达到某个条件才停止” */
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 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;
}
});
two.start();
one.start();
latch.countDown();
one.join();
two.join();
String result = "第"+i+"次 (" +x+"," +y+")";
if (x==1&&y==1){
System.out.println(result);
break;
}else {
System.out.println(result);
}
}
}
}
第N次执行时候出现 :
x=1,y=1
所以,什么是重排序:
在线程1内部的两行代码的实际执行顺序和代码在Java文件中的顺序不一致,代码指令并不是严格按照代码语句顺序执行的,它们的顺序被改变了,这就是重排序。这里被颠倒的是y=a和b=1这两行语句。
重排序的好处:提高处理速度
2.2 JMM之可见性
/** 演示可见性问题 */
public class FieldVisibility {
int a= 1; int b=2;
private void change(){
a = 3;
b = a;
}
private void print(){
System.out.println("b="+b+"; a="+a);
}
public static void main(String[] args) {
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();
}
}
会出现 b=3, a=1的情况,
原因是:线程2看了b=3,但是a还没有从线程1同步过来,线程2之看到了初始化时的a=1,这就是可见性问题。
写线程和读线程只能通过共享内存来进行通讯,但可能存在延迟的情况,这就导致了可见性问题的产生。
解决:使用volatile 关键字强迫程序每次都去读取最新的值。
原理:
一旦写线程把x改为1,在读线程读取之前,volatile就会强制行的把x=1更新(flush)到主内存中。
为什么会有可见性问题?
因为从内存到CPU之间有多层缓存,从下到上,容量递减,速度递增,不同层次的缓存要拿到对方最新的数据,是有延迟的。
CPU有多级缓存,导致读的数据过期:
- 告诉缓存的容量比主内存小,但是速度仅次于寄存器,所以在CPU和主内存之间就多了Cache层;
- 线程间的对于共享变量的可见性问题不是直接由多核引起的,而是由多缓存引起的;
- 如果所有的核心只有一个缓存,那就不会出现内存可见性问题;
- 但是每个核心都会讲自己需要的数据读到独占的缓存中,数据修改后也是写入到缓存中,然后等待刷入到主存中,所以会导致有些核心读取的值是一个过期的值。
2.3 主内存和本地内存
不同的工作线程都有属于自己的独立工作内存,这些独立的内存之间是不共通的,它们都过Buffer连接主内存进行通讯。
主内存和本地内存的关系如何?
JMM有以下规定:
- 所有变量都存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝;
- 线程不能直接读写主内存中的变量,而是只能操作自己专有的工作内存中的变量,然后再同步到主内存中;
- 主内存是多个线程共享的,但是线程之间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成。
总结:
所有的共享变量存在与主内存中,每个线程有自己的本地工作内存,而且线程读写共享数据也是通过工作内存交换,但是存在延迟的情况,所以导致可见性问题的出现。
2.4 Happens-Before原则
2.5 volatile关键字
如果对于一个基本变量直接赋值,那就是原子操作。
2.5.1 volatile 不适用于a++
代码演示:
import java.util.concurrent.atomic.AtomicInteger;
/** volatile不适用a++ */
public class NoVolatile implements Runnable{
volatile int a ;
AtomicInteger realA = new AtomicInteger();
@Override
public void run() {
for (int i= 0 ; i <10000; i++){
a++;
realA.incrementAndGet();
}
}
public static void main(String[] args) throws InterruptedException {
Runnable r= new NoVolatile();
Thread thread1 = new Thread(r);
Thread thread2 = new Thread(r);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(((NoVolatile)r).a);
System.out.println(((NoVolatile)r).realA);
}
}
输出:
volatile 修饰的变量或者布尔值,不能依赖以前的状态,否则volatile失效,因为volatile不能做到原子保护。
2.5.2 volatile适用于纯赋值操作
代码演示:
import java.util.concurrent.atomic.AtomicInteger;
/** volatile适用场景1 */
public class UseVolatile1 implements Runnable{
volatile boolean done = false ;
AtomicInteger realA = new AtomicInteger();
@Override
public void run() {
for (int i= 0 ; i <10000; i++){
setDone();
realA.incrementAndGet();
}
}
private void setDone() {
done = true;
}
public static void main(String[] args) throws InterruptedException {
Runnable r= new UseVolatile1();
Thread thread1 = new Thread(r);
Thread thread2 = new Thread(r);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(((UseVolatile1)r).done);
System.out.println(((UseVolatile1)r).realA);
}
}
输出:
小结:
适用场景1:boolean flag,如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来替代synchronized或者替代原子变量,因为赋值自身是有原子性的,而volatile又保证了可见性,所以足以保证线程安全。
2.5.3 volatile适用于刷新之前变量的触发器
2.5.4 volatile的两点作用
2.5.5 volatile和synchronized的关系
volatile 可以看做是轻量版的synchronized:
如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来替代synchronized或者代替原子变量,因为赋值自身是原子性的,而volatile又保证了可见性,所以就足以保证线程安全。
2.5.6 volatile小结
- volatile 修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了次属性,其他线程可以立即得到修改后的值,比如boolean flag;或者作为触发器,实现轻量级同步。
- volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁,所以它是低成本的。
- volatile只能作用于属性,用volatile修饰属性,这样compilers就不会对这个属性做指令重排序。
- volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile修饰的属性不会被线程缓存,始终从主存中读取。
- volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作。
- volatile可以使得long和double的赋值是原子的。
3. 原子性
原子操作+原子操作 != 原子操作,即原子操作的组合不具备原子性。
在32位上的JVM,long和double的操作不是原子的,但是在64位的JVM上是原子的。
4. 单例模式与JMM的联系
单例模式的作用:节省内存和计算,保证结果正确,方便管理。
单例模式适用场景:
4.1 饿汉式写法
优点:写法比较简单,类装载的时候就完成了实例化,
4.1.1 饿汉式静态常量
/** 单例模式:饿汉式写法(静态常量) */
public class Singleton1 {
/** 由于static的缘故,根据JVM的规定,会在加载这个类的时候把INSTANCE实例完毕,
这就避免了线程同步问题
*/
private final static Singleton1 INSTANCE = new Singleton1();
/** 单例模式的构造函数都是私有的,这是希望外界不会来调用 */
private Singleton1(){
}
/** 给外界获得单例模式的方法 */
public static Singleton1 getInstance(){
return INSTANCE;
}
}
4.1.2 饿汉式静态代码块
/** 单例模式:饿汉式(静态代码块) */
public class Singleton2 {
//不做初始化
private final static Singleton2 INSTANCE;
//在代码块中做初始化,同样可以通过JVM保证线程安全
static {
INSTANCE = new Singleton2();
}
//单例模式的构造函数都是私有的,这是希望外界不会来调用
private Singleton2(){
}
//给外界获得单例模式的方法
public static Singleton2 getInstance(){
return INSTANCE;
}
}
4.2 不可用的线程不安全的懒汉式
/** 懒汉式写法,线程不安全 */
public class Singleton3 {
private static Singleton3 instance;
private Singleton3(){
}
public static Singleton3 getInstance(){
/** 要用的时候才初始化。但是如果两个线程同时运行到达这一句,都判断是null,
这时线程1执行 instance = new Singleton3(),线程2也同时 instance = new Singleton3()
并把实例返回。
这种情况下就会多次创建实例,不再符合单例的要求,所以这种写法不可用,线程不安全。
* */
if (instance == null){
instance = new Singleton3();
}
return instance;
}
}
4.3 线程安全的同步代码块的懒汉式
加上synchronized同步方法,但是不推荐使用,虽然安全,但是效率太低。
/** 单例模式:线程安全,但是不推荐 */
public class Singleton4 {
private static Singleton4 instance;
private Singleton4(){
}
//synchronized虽然安全,但是效率太低,很多线程不能同时进入这个方法
public synchronized static Singleton4 getInstance(){
if (instance == null){
instance = new Singleton4();
}
return instance;
}
}
4.4 线程不安全的同步代码块的懒汉式
也不推荐使用。
public class Singleton5 {
private static Singleton5 instance;
private Singleton5(){
}
public static Singleton5 getInstance(){
if (instance == null){
/** 意图把需要保护的地方用synchronized同步,但是一旦多个线程来到这一步,
就无法阻止多个线程创建多个实例。
线程1获得锁运行完之后释放锁,线程2获得锁再赋值一次,这就创建了多个实例,
所以线程不安全。
*/
synchronized (Singleton5.class){
instance = new Singleton5();
}
}
return instance;
}
}
4.5 推荐使用:双重检查
有点:线程安全,延迟加载,效率高,使用了volatile。
/** 单例模式:双重检查 */
public class Singleton6 {
/** 这是使用了volatile,真正保证了安全 */
private volatile static Singleton6 instance;
private Singleton6(){
}
public static Singleton6 getInstance(){
if (instance == null){
synchronized (Singleton6.class){
if (instance == null){
instance = new Singleton6();
}
}
}
return instance;
}
}
为什么要double-check(双重锁)?
答:线程安全。
为什么这里要用volatile修饰?
答:Java中新建对象不是原子操作,新建对象实际上有3个步骤。第一,先创建空的对象,第二,调用构造方法,可能会有复杂的计算(访问数据库之类的),第三,把创建好的实例赋值给等号左边的引用,这样其他线程就能用这个引用了。
但是,对于CPU和编译器而言,它们是有重排序功能的,我们不知道它们是否会把这个3个步骤进行重排序,因为它们完全可以将其顺序颠倒。如下图所示就是一个颠倒之后的效果,比如先创建一个空的对象,然后直接赋值给左边的引用,最后才去调构造方法去做一些初始化工作。
假设CPU做了这样的重排序,线程1进来,执行完语句之后,左边的引用rs已经不是空了,但本身这个对象虽然不是空的,可对象里面的各种属性可能是没有经过计算和赋值。如果此时线程2进来,检查左边的引用rs是否是空,那么线程2只能看到左边的引用不是空的,rs虽然不是空的,但其内部还没有完全准备完毕,此时线程2会直接跳过新建的过程,直接返回rs,随后就会产生空指针问题(NPE)。
所以要使用volatile修饰,以防止重排序出现。
4.6 推荐使用:静态内部类写法
懒汉式,静态内部类写法,可用,满足同时保证了线程安全,和懒加载的优点,效率也不错。
/** 单例模式:懒汉式,静态内部类写法,可用,效率也不错 */
public class Singleton7 {
private Singleton7(){
}
//静态内部类
private static class SingletonInstance{
/**只有当真正休要调用getInstance(),才会初始化这个实例。
由于JVM内的加载性质,保证了即便多个线程同时去访问“Singleton7 INSTANCE”这个对象,
也不会创建多个实例了,这样就同时保证了线程安全,和懒加载的优点。
*/
private static final Singleton7 INSTANCE = new Singleton7();
}
public static Singleton7 getInstance(){
return SingletonInstance.INSTANCE;
}
}
4.7 推荐使用:枚举写法
枚举写法实际上是生产实践中最佳的单例模式的写法!!!
/** 单例模式最佳写法:枚举,枚举本质还是一个类 */
public enum Singleton8 {
INSTANCE;
public void whatever(){
/** whatever()代表这个类中的一些方法 */
}
}
4.8 单例模式总结
哪种单例的实现方案最好?
5. 面试常见问题
5.1 什么是Java内存模型?
答:
- C语言没有内存模型,但是没有会造成很多的问题和混乱,会导致在不同的处理器上程序执行的结果不一样,无法保证并发安全,所以需要一个规范来定义标准,让多线程程序的运行结果真正做到可预期;
- 所以Java内存模型,也就是JMM它是一组规范,用来帮助CPU、JVM以及开发者之间进行很好的合作,能够更好的避免线程安全问题,开发者利用JMM规范可以更快更方便地开发多线程程序;
- 如果没有JMM作为规范,那么很可能经过了不同地JVM的不同规则的重排序之后,导致不同的虚拟机上运行的结果不一样;
- 并且,JMM还是volatile、synchronized、Lock等的原理基础,有了JMM,开发者就只需要用同步工具和关键字就可以开发;
- 此外,JMM最重要的3点内容就是重排序、内存可见性、和原子性;
- 重排序是指代码实际执行顺序和代码在Java文件中的顺序不一致;重排序有其优点,比如提高处理速度;重排序有3种情况,分别是编译器优化,指令重排序,内存的“重排序”(强调有双引号);
- 其中,指令重排序是CPU的优化行为,和编译器优化很相似,是通过乱序执行的技术,来提高执行效率;所以就算编译器不发生重排序,CPU也可能对指令进行重排序,所以开发中要考虑到重排序带来的问题和后果;
- 实际上内存系统不存在重排序,但是内存会带来看上去和重排序很像的效果;因为由于内存有缓存的存在,这在JMM表现位主存和(线程运行专属的)本地内存,由于主存和本地内存的不一致,会使得程序表现出乱序的行为;
- 比如:如果发现内存缓存不一致,比如有个变量a=0,线程1修改了a的值,但是修改后没有及时写回主存,所以线程2看不到刚刚线程1对a的修改,认为a还是等于0;同样道理,线程2修改了b的值,线程1也看不到线程2的修改;由此引出内存可见性问题;
- 为什么会有可见性问题呢,因为CPU为了加快执行速度,在内存和CPU之间,加了很多缓存层;这些缓存层分别会把下面一级的缓存的部分内容取过来,随着层次的逐渐上升,逐渐接近CPU,速度也越快,容量也越小;正是因为多个CPU之间,它们不同时共享高速缓存,所以就导致了可见性问题;这就引出了JMM对于整个内存的抽象;
Java作为高级语言,屏蔽了很多底层细节,并且用JMM定义了一套读写内存数据的规范,抽象了主内存和本地内存的概念;主要抽象为两个部分,最主要的是主内存部分,所有的变量都是存储在主内存中,而每一个线程都会有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝;线程与线程之间不能直接进行通讯,要通过主内存中转来完成,所以线程读写共享数据也是通过这样的方式完成,就导致了可见性问题;
- 简单来说,线程间对于共享变量的可见性问题是由多级缓存引起,如果所有的核心都只用一个缓存,就不会出现可见性问题;
- 解决可见性问题,需要一个happens-before原则,也就是在连续的时间上,动作A发生在动作B之前,B能保证看见A,这就是happens-before原则;
- 原子性
5.2 volatile和synchronized的异同?
volatile 可以看做是轻量版的synchronized:
如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来替代synchronized或者代替原子变量,因为赋值自身是原子性的,而volatile又保证了可见性,所以就足以保证线程安全。
- volatile 修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了次属性,其他线程可以立即得到修改后的值,比如boolean flag;或者作为触发器,实现轻量级同步。
- volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁,所以它是低成本的。
- volatile只能作用于属性,用volatile修饰属性,这样compilers就不会对这个属性做指令重排序。
- volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile修饰的属性不会被线程缓存,始终从主存中读取。
- volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作。
- volatile可以使得long和double的赋值是原子的。
5.3 什么是原子操作?Java中有哪些原子操作?
5.4 生成对象的过程是不是原子操作?
生成对象的过程:
1)新建一个空的Person对象;
2)把这个对象的地址指向P;
3)执行Person的构造函数。
这三个过程不能保证它们的原子性。
5.5 为什么会有内存可见性问题?
多层缓存会造成数据同步延迟的问题。
5.6 64位的double和long写入的时候是原子的吗?
答:Java并没有规定它们一定是原子的,因为它们是64位的,写入的时候,可能会出现前32位和后32位错位的情况。但是在实际的商用JDK中,这个问题已经被考虑过并且解决了。
5.7 synchronized对于可见性问题的作用
synchronized也可以达到happens-before的效果。synchronized不仅防止了一个线程在操作某对象时受到其他线程的干扰,同时还保证了修改之后,可以立即被其他线程所看到,因为如果其他线程看不到,那也会有线程安全问题。