Java线程(二)——JMM与线程安全

这一篇将涉及比较多底层知识,代码较少但都是干货。


JMM (Java Memory Model)

    在讲线程安全之前必须提及Java内存模型,因为线程安全是与它息息相关的。
    首先,JMM是一个抽象的规范,它本身不存在,通过JMM,JVM虚拟机可以屏蔽掉各种硬件或者操作系统的差异,规范了JVM如何与计算机内存协同工作的、线程之间共享资源的方式(如一个线程如何看到其他线程修改后的变量,同步访问共享变量等)。
JMM
    如图,Java内存模型规定了所有变量都存储在主内存中(即图中的Heap,主内存有时候也称堆内存),即主内存是任何线程都能访问到的。

  • 存放:所有对象及他们的成员变量、数组元素、静态域
  • 优点:他是运行时的数据区,运行时动态分配内存的,生存期不必实现确定,由Java垃圾回收管理
  • 缺点:存取速度相对较慢
  • Tips:只有获得对象的引用的变量才能被获得该引用的线程所访问

    JVM运行的单位(实体)实际上就是线程,每个线程创建时JVM都会为它分配一个私有的工作内存(即图里的Thread Stack,它有时候也被称为线程栈),不同线程间无法访问,用于存放该线程拥有的变量。

  • 存放:基本类型变量(包括局部变量与方法变量等)以及对象的引用
  • 优点:存取速度十分快,仅次于CPU的寄存器
  • 缺点:生存期与大小必须事先确定
  • Tips:当要一个线程要引用一个变量的时候,实际上是该线程从主内存获得一份私有拷贝到工作内存中去,在写操作时会再写回。

注意:必须搞清楚一件事是Java的内存划分与Java内存模型是不同层次的概念!
关于Java的内存划分我会在本文最后放出相应的图示与解释。
此处JMM定义的主内存可以说是Java内存区域划分中的:堆与方法区
而工作内存则是:程序计数器、虚拟机栈及本地方法栈

同步八操作

图示为Java内存模型的操作:
JMM
如果了解计算机的缓存一致性MESI的人应该能看得出来Java内存模型跟多处理机进行缓存一致性操作有异曲同工之妙,至于那是什么可以去搜一下或者看在本文最后的东西。

lock(锁定):作用于主内存,让主内存变量标示为某一线程的独占的状态。
当进行lock时会使工作内存中的该变量清空,在执行前需重新执行load和assign

unlock(解锁):作用于主内存,让主内存变量的独占状态解除,可以被其他线程锁定。
进行unlock必须先把该变量从之前锁定的线程的工作内存中同步到主内存

read(读取):作用于主内存的变量,把一个变量值从主内存读取到线程的工作内存中,以便于load的操作。
load与read不能单独出现!

load(载入):作用于工作内存的变量,把主内存read到的变量放入工作内存的变量副本中。
一个新变量是只能在主内存中诞生,不允许工作内存中用一个未被初始化的变量,即use和store之前必须有load和assign

use(使用):作用于工作内存的变量,将变量副本取出来到执行引擎中执行。

assign(赋值):作用于工作内存的变量,将执行引擎中收到的值赋予工作内存中的变量。
一个线程assign过的变量必须从工作内存写回主内存

store(存储):作用于工作内存的变量,将工作内存的变量存入主内存中,便于进行write操作。
store与write不能单独出现!

write(写入):作用于主内存的变量,把store的变量传到主内存的变量中。

当在多线程环境进行这同步八操作的时候,就有可能引致线程安全问题。那什么是线程安全?

线程安全的概念

    线程安全是指在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,得到预期想要的效果,不会出现数据污染等意外情况。
    而线程安全主要从三个方面来考察:原子性、可见性、有序性。

  • 原子性:指不可分割性,互斥访问的操作(同一时刻只能有一个线程进行的操作);
  • 可见性:指线程之间,一个线程对主内存修改的结果,另一个线程能马上正确的看到;
  • 有序性:一个线程观察其他线程的指令执行顺序,在必要的执行顺序错误则无序(CPU会为提高速度而进行乱序执行优化)。

原子性

示例

因为前面讲了线程池真™好啊,这里就用线程池做例子了。

public class ConcurrencyExample {

    //请求总数
    public static int clientTotal = 1000;

    //线程池大小
    public static int threadTotal = 50;

    //计数器
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0 ; i < clientTotal ; i++){
            executorService.execute(() -> add());
        }
        executorService.shutdown();
        while(!executorService.isTerminated()) { Thread.sleep(100);}    //由于main可能先比线程池的任务执行的快,所以在这等待,让所有队列中的线程完成后再结束
        System.out.println("count (should be 1000) : " + count);
    }

    private static void add(){
        count++;
    }
}

很明显,这串代码就是为了用一千个任务(任务就是每个任务都给一个共享的count加1)得到一个一千的值,所以如果输出count (should be 1000) : 1000的话就是我们想看到的。那么就执行这代码几次试试:

count (should be 1000) : 979
count (should be 1000) : 956
count (should be 1000) : 982
count (should be 1000) : 965
count (should be 1000) : 973

没有一次是达到目的的,每次都比1000少那么点,那问题出在哪里了呢?

相信是个人都知道x++这种自增符的用处相当于x=x+1,即先取出x的值,计算x+1,并把这个值传入到x里。
这里既有读操作也有写操作,写操作依赖于读操作,而这整个x++并非是一个原子性的操作,所以很有可能会产生这样的情况:线程1读完x=1后线程1准备写x=2的时候,线程2还在读之前的x=1,然后线程2写的时候也是写x=2,那么这两个线程本应该加到3也只能加到2。

那么这样就很清楚了为什么会发生这样的线程不安全问题了。

关于原子性,有几个常见的解决方法:

synchronized 关键字

public class ConcurrencyExample {

    //请求总数
    public static int clientTotal = 1000;

    //允许同时并发执行数
    public static int threadTotal = 50;

    //计数器
    private static Integer count = 0;

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0 ; i < clientTotal ; i++){
            executorService.execute(() -> add());
        }
        executorService.shutdown();
        while(!executorService.isTerminated()) { Thread.sleep(100);}
        System.out.println("count (should be 1000) : " + count);
    }

    private synchronized static void add(){
        count++;
    }

    //      这个也行:
    //      private static void add(){
    //      synchronized (ConcurrencyExample.class) {
    //        count++;
    //      }
    //  }

    //      但这个不行:
    //      private static void add(){
    //      synchronized (count) {
    //        count++;      //每次执行这个,count都默认指向一个新的变量,那么锁就失效了
    //      }
    //  }

}

count (should be 1000) : 1000
count (should be 1000) : 1000
count (should be 1000) : 1000
count (should be 1000) : 1000
count (should be 1000) : 1000

通过synchronized关键字,使任何线程进入代码块之前获得同步监视器锁,其他线程将不能获得同步锁而只能等待直到锁的释放。这里方法同步,对调用该方法的实例上锁,每次只能由一个线程获得锁来访问add()方法,那么效果便就如我们所愿了。
但是synchronized有时并不能满足我们的需求,首先sync只有完成了锁定的代码段才会解锁(或者其他如异常抛出或者wait()),不可中,,而且他是一种基于JVM的隐式锁,若里面代码片段过于复杂的时候就会资源占用,在竞争激烈的时候会导致性能下降,那么反而失去了多线程的必要性。

Lock

Java SE5提供了一种显锁式锁:ReentrantLock,通过显示的调用lock与unlock的方式进行锁定

public class ConcurrencyExample {

    //请求总数
    public static int clientTotal = 1000;

    //允许同时并发执行数
    public static int threadTotal = 50;

    //计数器
    private static int count = 0;

    private static final ReentrantLock lock  = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0 ; i < clientTotal ; i++){
            executorService.execute(() -> add());
        }
        executorService.shutdown();
        while(!executorService.isTerminated()) { Thread.sleep(100);}
        System.out.println("count (should be 1000) : " + count);
    }

    private static void add(){
        lock.lock();
        count++;
        lock.unlock();
    }
}

实际上与synchronized没太多区别,主要是Lock有一个比较特别的多一个tryLock(),能让其他线程如果获得不到锁去做别的事,而能更灵活的使用锁。

Atomic类

J.U.C(Java.util.concurrency)包内提供了一些十分有用的原子类如:AtomicInteger、AtomicBoolean、AtomicLong、AtomicReference、LongAdder等
这里简单演示其中一个:

public class ConcurrencyExample {

    //请求总数
    public static int clientTotal = 1000;

    //允许同时并发执行数
    public static int threadTotal = 50;

    //计数器
    private static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0 ; i < clientTotal ; i++){
            executorService.execute(() -> add());
        }
        executorService.shutdown();
        while(!executorService.isTerminated()) { Thread.sleep(100);}
        System.out.println("count (should be 1000) : " + count.get());
    }

    private static void add(){
        count.incrementAndGet();        //相当于自增
    }
}

count (should be 1000) : 1000
count (should be 1000) : 1000
count (should be 1000) : 1000
count (should be 1000) : 1000
count (should be 1000) : 1000

在此AtomicInteger的原子类是线程安全的,这些原子类的实现都是基于一个CAS算法,我们可以看看那个AtomicInteger的内部:
CAS?
然后再进去看看:
,var1是传入的对象,var2是对象当前的值,var4为加数,var5是通过getIntVolatile()方法获得对象的底层值,通过在死循环里不停使用compareAndSwapInt(就是CAS)比较对象的当前值与其底层值,这里的compareAndSwapInt是Unsafe类中的一个native本地内部方法,若不相同则返回false,若相同则将其值更新为var5+var4。
CAS
原子类都是通过这种CAS算法来进行安全的修改。

顺带讲一种用法,
懒汉单例模式

 public class LazySingletonExample {

    private LazySingletonExample(){}

    private static LazySingletonExample instance = null;

    public static synchronized LazySingletonExample getInstance(){
        if(instance == null){
            instance = new LazySingletonExample();
        }
        return instance;
    }
}

这是懒汉单例模式的一种写法,而在这里通过给getInstance()工厂方法加上synchronized关键字才保证了线程安全,否则会可能导致返回错误值。
但是前面也提到了使用方法同步的方式实际上大大降低性能,因为同一时间只能有一个线程能访问对象,所以这种单例模式虽然是线程安全,但是并不推荐,那么我们可以在其之上进行改进,
双重同步锁单例模式:

public class DoubleLazySingletonExample {

    private DoubleLazySingletonExample(){}

    private static DoubleLazySingletonExample instance = null;

    public static synchronized DoubleLazySingletonExample getInstance(){
        if(instance == null){                                           //**Mark1**
            synchronized (DoubleLazySingletonExample.class) {
                if(instance == null) {
                    instance = new DoubleLazySingletonExample();        //**Mark2**
                }
            }
        }
        return instance;
    }
}

通过在里面使用双重锁定来保证了性能的提高,在已经有对象的时候直接不进入sync代码块,不至于让线程一直等待而无法同时使用对象。但是这里仍然有缺陷,假设有这样的情况:
首先对象初始化的过程是这样的:

  1. 分配对象内存空间
  2. 初始化对象
  3. 使对象指向内存
    1和(2,3)是不会发生指令重排的,但若2.3.发生指令重排,Thread1完成了3,Thread在Mark1位置判断instance时,因为不为空返回了值,但实际上还是没有初始化,所以非线程安全。所以这时需要给instance加上volatile关键字就解决重排序的问题了,这时这个单例模式便是线程安全的且性能较高:

    public class DoubleLazySingletonExample {
    
    private DoubleLazySingletonExample(){}
    
    //volatile+双重同步锁
    private volatile static DoubleLazySingletonExample instance = null;
    
    public static synchronized DoubleLazySingletonExample getInstance(){
        if(instance == null){                                           //Mark1-Thread2
            synchronized (DoubleLazySingletonExample.class) {
                if(instance == null) {
                    instance = new DoubleLazySingletonExample();        //Mark2-Thread1
                }
            }
        }
        return instance;
    }
    }
    

可见性

对可见性可以举一个例子:

    当线程1修改工作内存中的值的时候,必须要写回主内存才能被其他线程看到,但若没有写回就被线程2读的话,就会导致不可见。

    由此易知,导致不可见的原因:

  • 线程的交叉执行
  • 重排序结合线程交叉执行
  • 共享变量更新后的值没有在工作内存与主内存间及时更新

synchronized/Lock

如何用锁保证可见性应该是显而易见的了,但还是在这里顺便说一下锁的原理:

  • 每次获得锁之前,会将所有工作内存中该变量的值清空
  • 每次释放锁之前,必须把工作内存中变量最新值写回主内存
    通过这种方式便能保证其他线程读到的是线程改动后的最新值。

volatile

volatile实际上是通过内存屏障禁止重排序来实现可见性的:

  • 对volatile变量进行write操作的时候,写后加入一条store屏障,将本地内存中共享变量刷到主内存去,那么store指令会在写操作后把最新的值强制刷新到主内存中。同时还会禁止cpu对代码进行重排序优化。这样就保证了值在主内存中是最新的。
  • 对volatile变量进行read操作的时候,加入一条load屏障,load指令会在读操作前把内存缓存中的值清空后,再从主内存中读取最新的值。
    实际上可以说是相当于volatile变量的操作是在主内存上进行操作的,因而时刻保证其最新值。
    volatile write
    volatile read
    适合用到volatile的场景:
  1. 对volatile写不依赖于当前的值(如自增自减)
  2. 该变量不被包含在具有其他变量的式子中
    所以可见他比较适合做标记量(如IO操作的完成标记,如下图:)
    volatile use

有序性

在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。从Java源代码到最终实际执行的指令序列,会依次经过这三种重排序:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

对于某些Happen-before原则的操作,是无须额外操作便能达到有序性的:

  1. 程序次序规则:一个线程内,按照代码执行,书写在前面的操作必定先行发生于书写在后面的操作。
  2. 锁定规则:一个unlock操作必定先行发生于后面对同一个锁的lock操作
  3. volatile变量规则:对一个变量的写操作先行必定发生于后面对这个变量的读操作
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A必定先行发生于操作C
  5. 线程启动原则:Thread对象的start()方法必定先行发生于此线程的每一个动作
  6. 线程中断规则:对线程interrupt()方法的调用必定先行发生于被中断线程的代码检测到中断事件的发生
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()方法返回值手段检测到线程已经终止执行
  8. 对象终结规则:一个对象的初始化完成必定先行发生于他的finalize()方法的开始

附录

Java的内存划分

JVM Memory
    左边和右边有时也被称为堆区和栈区,相对Java内存模型来看其实差不多,跟我们平时所说的堆和栈,都可以运用。虽然这个清不清楚不会太影响这里解释Java内存模型,对于JVM内存划分想要详情了解的可以参考别的大佬的博客:Java内存区域划分

程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完。

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

从上面的介绍中我们知道程序计数器主要有两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

注意:程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

Java 虚拟机栈 (栈)

与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型。

Java 内存可以粗糙的区分为堆内存(Heap)和栈内存(Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)

局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError: 若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。
  • OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常。

Java 虚拟机栈也是线程私有的,每个线程都有各自的Java虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。

本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:再细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

HotSpot 虚拟机中方法区也常被称为 “永久代”,本质上两者并不等价。仅仅是因为 HotSpot 虚拟机设计团队用永久代来实现方法区而已,这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了。但是这并不是一个好主意,因为这样更容易遇到内存溢出问题。

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。

常量池

虽然没有在图上显示,但其实JVM在堆和方法区里里分别开辟了一部分区域用来放常量池。

img

比较麻烦的是字符串常量:在编译阶段就把所有的字符串文字放到一个常量池中。

那么对于以下代码:

     String str1 = "abcd";
     String str2 = new String("abcd");
     System.out.println(str1==str2);//false

解释就很清晰,第一行直接从常量池中取出对象,而第二行是在堆中创建了新的对象。

计算机硬件架构的图示

CPU
    由于计算机的存储设备与处理器的运算能力之间有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中没这样处理器就无需等待缓慢的内存读写了。但这里就会有一个问题,多个处理器进行处理如果发生同步的操作的时候就可能会出现数据错误,这时就需要有MESI这类缓存一致性协议了。具体可参考:维基百科对缓存一致性的介绍


上一篇
下一篇