[笔记]Java多线程基础——内存

因为处理器主频在硬件发展上的瓶颈,摩尔定律基本失效,现在真正起作用的是并行处理的Amdahl定律,毕竟,现在计算机的瓶颈在于存储和通信,而不是运算本身,并行运算可以更充分地发挥运算的能力,也是提升计算机性能。

一致性的问题

并行运算通过让多个处理器并行来提升性能,但是多处理器并行就会有内存上的一致性问题。

  1. 硬件上的缓存一致性
    运算不是处理器自己的事情,处理器必须与内存交互(读参数,写结果),但是处理器和内存在速度上相差几个数量级,硬件上的一个解决办法是在处理器和内存之间插一组高速缓存(Cache),每个处理器都有一块儿Cache,Cache的速度相对更接近运算器,这能提高运算速度,但是,多个处理器的Cache同时与内存交互时,就有可能发生冲突,这就是缓存一致性的问题。
  2. 指令上的乱序执行和指令重排序
    为了充分利用处理器的运算能力,一段代码中的各个语句可能被分配到不同的处理器共同处理,最后再把处理结果重组,这样处理速度更快,但是各语句执行的先后顺序就不一定是代码的先后顺序了,这就是乱序执行。
    JVM在编译时,也会对生成的指令做类似的操作,叫做指令重排序,指令重排序能通过多处理器并行来提升性能,但是语句的执行顺序会被重排,这可能会破坏代码中的逻辑顺序。
  3. JVM的主内存和工作内存
    Java不像C/C++,不会让程序直接操作硬件和OS上的内存,而是提供了JVM的虚拟机内存。JVM内存分为主内存和线程上的工作内存。
    主内存
    对应物理内存,存放对象、静态对象、常量等。(可以理解为堆,但其实不是一个维度)
    工作内存
    对应Cache甚至处理器的寄存器,每个线程都有自己的工作内存,存放线程私有的方法参数和局部变量。(可以理解为虚拟机栈的栈帧中的局部变量表,但其实不是一个维度)
    线程不能直接操作主内存的数据,只能在工作内存中操作目标对象的拷贝副本(一般只拷贝对象的reference和线程要使用的字段),最后再把拷贝副本写回主线程;线程之间也不能直接传递对象,要通过主内存中转。

JVM的主内存和工作内存需要频繁交互,为了确保数据一致性,定义了8种操作(并没有开放给用户),确保以主内存为准:

  1. lock,一个线程独占,其他线程不可用
    同一个线程可以lock多次,但是就需要unlock同样次
    会清空本地内存数据,重新从主内存load或assign(确保lock后的数据与主内存一致)
  2. unlock,线程不再占用,其他线程可以使用
    必须是lock过的对象,而且是本线程lock过的对象
    前面必须先write+store(unlock前必须写回主内存)
  3. read,从主内存读出
    后面必须load
  4. load,复制到工作内存
    必须先read
  5. use,在工作内存中使用该对象
    必须是load或assign过的(工作内存不能增加对象,对象必须来自主内存)
  6. assign,在工作内存中为该对象赋值
    后面必须做write(修改过的对象必须写回主内存)
  7. write,从工作内存读出
    前面必须assign过(没修改过的对象不需要写回主内存)
    后面必须store
  8. store,写到主内存
    前面必须先write

原子性、可见性、有序性

实现并发操作的一致性,核心就是原子性、可见性和有序性。

  • 原子性
    原子性就是操作不可被分割(避免被分到不同处理器并行)。
    基本类型的读写是原子性的。
    对象和代码段可以用synchronized块实现原子性,synchronized反映在字节码上,就是monitorenter和monitorexit这对内存屏障,屏蔽指令重排,从而实现原子性。
  • 可见性
    可见性针对的是变量值的修改的可见性,一个线程做了修改,其他线程能立即得知。
    Java以主内存为核心实现可见性,变量修改后写回到主内存,变量读取前从主内存刷新。
    volatile、synchronized和final都能实现可见性。
    volatile能把修改的值立即写回到主内存,每次使用前还会立即从主内存刷新(因此,每次使用的值都可能变化,所以叫volatile易变的)。
    synchronized是在unlock之前,把变量同步写回主内存,因为synchronized每次只允许一个线程lock,所以只要先在unlock时同步数据,在后面lock时就能确保可见性。
    final只要完成初始化就不能修改,也就不需要通知修改,所以能满足可见性,但是如果还没有完成初始化时,其他线程就能看见final字段,就打破了可见性(也就是发生了this逃逸)。
  • 有序性
    有序性指的是按照程序代码规定的顺序执行指令,它针对的是指令重排序。
    在线程内的操作,天然是有序的。
    对于线程间的有序性,可以用volatile和synchronized实现。
    volatile禁止指令重排序。
    synchronized同一时刻只允许一个线程lock,变成线程内的操作。

volatile、synchronized和ReentrantLock同步机制

前面提到,JVM中有volatile和synchronized两个同步机制,其中volatile是轻量级的,synchronized是重量级的,JVM还提供了ReentrantLock重入锁。

  • volatile
    volatile的核心就是修改立即通知+使用前立即刷新,反应在8种内存操作上,有3个原则:
    1.use和load绑定,use之前必须read+load。
    2.assign和store绑定,assign之后必须write+store。
    3.同一线程对两个volatile变量测操作中,要use/assign的那个变量,需要先read/write。
    在性能和安全上,volatile有这么几个特点:
    1.volatile是JVM中最轻量级的同步机制
    volatile的读操作几乎等同于普通变量,volatile的写操作相对较慢(需要内存屏障避免乱序执行),
    2.volatile满足可见性和有序性,但不满足原子性,所以不是并发安全的。
    比如,让多个线程对一个volatile数值做自增运算,虽然代码上看起来每次取到的数据都是最新的,但是非原子性意味着代码上的一行会被解析成多行字节码甚至更多的机器码,在字节码或者机器码中,获取数据时是最新的,到操作数据时,其他线程可能又做了修改,当前数据就不是最新的了。
    在不需要考虑原子性的场景下,volatile性能占优,比如在单一线程中,或者仅用一个volatile变量作为约束条件:
volatile boolean flag=false;
public void shutdown(){
      flag=true;
}
public void dowork(){
      while(!flag){...} //仅利用volatile的可见性
}
  • synchronized
    synchronized是线程级别的处理,相对只是读写数据的volatile来说,绝对是重量级的操作,因为synchronized的核心是阻塞同步。
    编译
    synchronized编译后,会成对地生成monitorenter和monitorexit内存屏障,这两个字节码需要对reference加锁和解锁,需要对象参数的reference,如果没有在代码中明确指定对象,就会根据所在方法是类方法还是实例方法,去获取所在的类或者实例对象的reference。
    阻塞
    线程在进入monitorenter时,如果当前线程没有这个锁,而且锁计数器不为0,说明有其他线程正在工作,当前线程必须阻塞等待,直至其他线程释放锁后,才能被唤醒。可见,阻塞会浪费大量的资源。
    系统消耗
    线程的阻塞和唤醒,需要操作系统来处理,这会从用户态转换为核心态,这种转换要消耗大量的处理器资源(往往超过用户代码的消耗)。
    串行执行
    synchronized下,一个变量同一时刻只能有一个线程进行lock,因此,持有同一个lock的两个synchronized块,只能串行地进入(如果在同一个线程里就是天然串行,如果在不同线程里,就必须等lock被释放,事实上也是串行)。这会影响程序的处理速度。
    所以synchronized是非常重量级的操作,我们应该仅在必要的时候使用该操作,虚拟机自己也会做一些自旋等待,尽量避免线程阻塞和核心态切换。
  • ReentrantLock
    ReentrantLock是java.util.concurrent包使用的同步机制,ReentrantLock和synchronized一样,是支持线程重入的互斥锁,不同的是ReentrantLock是API层面的,synchronized是原生语法层面的。
    另外,ReentrantLock有三个高级功能。
    1.等待可中断,synchronized中的线程只能阻塞。
    2.时间顺序公平(公平锁),多个线程按申请锁的时间顺序排队,synchronized中的线程做不到这一点。
    3.绑定多个条件,一个ReentrantLock对象可以同时绑定多个condition对象,synchronize要增加关联条件,只能增加锁。

先行发生

在Java中解决有序性的问题,并不是全都需要同步器,有一些是天然就能确保先后关系的规律:(先行发生仅指操作上的先后顺序,不是时间上的先后顺序)
1.程序次序,同一线程内,是按代码逻辑顺序。
2.管程锁定,同一个锁对象,时间上一定是先unlock,然后才能lock
3.volatile,一个volatile变量,时间上一定是先写,然后才能读
4.线程启动,Thread一定是先start
5.线程终止,Thread一定是最后执行终止(Thread.join())
6.线程中断,一定是先执行了Thread.interrupt(),然后线程的代码才能检测到中断事件
7.对象终结,对象的初始化一定在finalize()之前
8.传递性,操作的先后顺序是可传递的,A在B前,B在C前,则A一定在C前。
先行发生与时间先后没有关系,因为线程在时间上先发生的操作,与并发执行时的顺序并没有太大关系,操作上的先发生不代表时间上的先发生,时间上的先发生也不代表操作上是先发生的。

对双锁检测单例模式的解读

我们看一个双锁检索的DCL单例

public class Singleton{
    private volatile static Singleton instance; //避免指令重排序
    private Singleton(){}
    public static Singleton getInstance(){
        if(instance == null){ //volatile确保可见性,且读操作性能较高
             //用synchronized确保原子性、可见性和有序性
             synchronized(Singleton.class){ //这是一个静态类方法,所以要用class类做全局同步锁
                 if(instance == null){ //volatile在使用时刷新,再检查一次
                     instance=new Singleton();
                 }
             }
        }
        return instance;
    }
}

引用

《深入理解Java虚拟机》
synchronized(this)与synchronized(class)
Java集合及concurrent并发包总结(转)
深入理解java虚拟机 精华总结(面试)

你可能感兴趣的:([笔记]Java多线程基础——内存)