以Java内存模型的角度看并发

Java的内存模型:

堆区:

  1. 存储的全部是对象,每个对象都包含一个与之对应的class的信息。(class的目的是得到操作指令)。
  2. JVM的堆区(heap)被所有线程共享(相对于栈区,栈区的数据不共享),堆中不存放基本类型和对象引用,只存放对象本身。

栈区:

  1. 每个线程包含一个栈区,栈中只保存基础数据类型的对象和自定义对象的引用(不是对象),对象都存放在堆区中。
  2. 每个栈中的数据(原始类型和对象引用)都是私有的,其他栈不能访问。
  3. 栈分为3个部分:基本类型变量区、执行环境上下文、操作指令区(存放操作指令)。

方法区:

  1. 又叫静态区,跟堆一样,被所有的线程共享。方法区包含所有的class和static变量。
  2. 方法区中包含的都是在整个程序中永远唯一的元素,如class,static变量。

方法调用栈:

在Java虚拟机进程中,每个线程都会拥有一个方法调用栈,用来跟踪线程运行中一系列的方法调用过程,栈中的每一个元素就被称为栈帧,每当线程调用一个方法的时候就会向方法栈压入一个新帧。这里的帧用来存储方法的参数、局部变量和运算过程中的临时数据。

每个线程都有自己的栈内存,用于存储本地变量,方法参数和栈调用,一个线程中存储的变量对其它线程是不可见的。而堆是所有线程共享的一片公用内存区域。对象都在堆里创建,为了提升效率线程会从堆中弄一个缓存到自己的栈,如果多个线程使用该变量就可能引发问题,这时 volatile 变量就可以发挥作用了,它要求线程从主存中读取变量的值。

Java中堆和栈有什么不同?

为什么把这个问题归类在多线程和并发面试题里?因为栈是一块和线程紧密相关的内存区域。每个线程都有自己的栈内存,用于存储本地变量,方法参数和栈调用,一个线程中存储的变量对其它线程是不可见的。而堆是所有线程共享的一片公用内存区域。对象都在堆里创建,为了提升效率线程会从堆中弄一个缓存到自己的栈,如果多个线程使用该变量就可能引发问题,这时volatile 变量就可以发挥作用了,它要求线程从主存中读取变量的值。

线程封闭技术

如果在单线程内访问数据,就不需要同步。这种技术被称为线程封闭。
Java提供了一些机制来维持线程封闭性,例如局部变量和ThreadLocal类。

1)局部变量:
因为每个线程都有自己的方法调用栈,并且是私有的,所以访问方法局部变量无需同步(其他线程并发执行同一个方法,得到的局部变量也不是同一个)

2)ThreadLocal类:

ThreadLocal是Java里一种特殊的变量。每个线程都有一个ThreadLocal就是每个线程都拥有了自己独立的一个变量,竞争条件被彻底消除了。它是为创建代价高昂的对象获取线程安全的好方法,比如你可以用ThreadLocal让SimpleDateFormat变成线程安全的,因为那个类创建代价高昂且每次调用都需要创建不同的实例所以不值得在局部范围使用它,如果为每个线程提供一个自己独有的变量拷贝,将大大提高效率。首先,通过复用减少了代价高昂的对象的创建个数。其次,你在没有使用高代价的同步或者不变性的情况下获得了线程安全。线程局部变量的另一个不错的例子是ThreadLocalRandom类,它在多线程环境中减少了创建代价高昂的Random对象的个数。

DCL模式实现单例为什么并不能保证线程安全

DCL模式实现单例,如下所示:

private static UserSingleton sInstance;

private UserSingleton () {
    
}

public static UserSingleton getInstance() {
    if (sInstance == null) {
        synchornized(UserSingleton.class){
            if (sInstance == null) {
                 sInstance = new UserSingleton();
            }
        }
    }
    return sInstance;
}

因为在实例化对象这个地方new UserSingleton();这里是实际上在JVM中进行三步指令操作(1、分配内存 2、执行构造函数并执行实例化变量 3、分配引用),而JVM对代码编译时会进行性能优化而对指令进行重排,因此123可能被优化成132,因此如果单例变量不是volatile修饰的,那么可能在并发获取单例的情况下,一个对象进入同步代码块,进行实例化,走到指令132步骤中的3时,此时指令2并未执行,但sInstance已经不为null了(已经分配了引用),另外一个线程查询到sInstance已经不为null返回单例给客户端,客户端使用未初始化完成的单例进行操作,这时候会出现变量初始值不对引起的问题了。

解决办法
单例变量使用volatile关键字修饰,即:

private static volatile UserSingleton sInstance;

volatile关键字可以保证变量的可见性,因为对volatile的操作都在Main Memory中,而Main Memory是被所有线程所共享的,这里的代价就是牺牲了性能,无法利用寄存器或Cache,因为它们都不是全局的,无法保证可见性,可能产生脏读。
volatile还有一个作用就是局部阻止重排序的发生,对volatile变量的操作指令都不会被重排序,因为如果重排序,又可能产生可见性问题。
在保证可见性方面,锁(包括显式锁、对象锁)以及对原子变量的读写都可以确保变量的可见性。但是实现方式略有不同,例如同步锁保证得到锁时从内存里重新读入数据刷新缓存,释放锁时将数据写回内存以保数据可见,而volatile变量干脆都是读写内存。

竞态条件为什么不能通过volatile消除

volatile只保证可见性,不保证原子性,原子变量&加锁机制可以保证可见性和原子性。

volatile与各类原子类区别:
volatile关键字只保证可见性,不保证原子性,它保证多个线程对volatile修饰的变量的写操作会排在读操作之前,但并不保证它修饰的变量操作具有原子性,例如volatile的count++依然不是原子操作,多个线程并发依然会出现竞态条件,但AtomInteger可以使用getAndIncrement保证为原子操作,因此如果需要实现一个支持并发的计数器,不能使用以下代码

private volatile int count;

count++;

而是要使用:

private AtomInteger mCount = new AtomInteger();

mCount.getAndIncrement();

参考来源:

JVM 内存初学 堆(heap)、栈(stack)和方法区(method)

Java线程面试题

JVM之指令重排分析

《Java并发编程实践》

你可能感兴趣的:(以Java内存模型的角度看并发)