JAVA并发编程

201 阅读33分钟

本人在实习与秋招面试过程中,多次被问到java并发相关问题,同时自己也零零星星的看了很多视频和技术博客。但苦于这部分对于小白来说,确实理解起来有一定难度,且网上众多资料不够系统,同时很多错误且抄袭严重。 本文尝试系统对java并发相关知识进行系统总结,希望建立起自己的知识体系,同时希望对其他人有所裨益。欢迎大家有问题一起留言讨论。

本文将按照如下思路展开:
第一章,本文将对并发编程和其中涉及的概念进行简要介绍,即为什么要引入并发编程,它的优缺点以及同步,阻塞等重要概念。
第二章,本文将对java内存模型进行介绍,即java中的并发所依赖的内存模型是什么样的。
第三章,本文将对volatile进行介绍,包括volatile的原理和实现。
第四章,本文将对synchronized进行介绍,包括vsynchronized的原理和实现。
第四章,本文将对java并发的载体-线程进行介绍,包括其使用方法和应用实例。
第五章,未完待续。。。。。

一.并发编程概述

1.1 什么是并发

并发指的在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。

并发和并行是十分容易混淆的概念。并发指的是多个任务交替进行,而并行则是指真正意义上的“同时进行”。实际上,如果系统内只有一个CPU,而使用多线程时,那么真实系统环境下不能并行,只能通过切换时间片的方式交替进行,而成为并发执行任务。真正的并行也只能出现在拥有多个CPU的系统中。

并发 & 并行

image.png image.png

1.2 为什么要用到并发(优点)

并发处理的广泛应用是Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类压榨计算机运算能力的最强有力武器。 ——《深入理解java虚拟机 第三版》

我们都知道,计算机包括很多模块,例如运算模块,通信模块,存储模块,这其中运算模块即CPU的能力太强大了,以至于计算机大部分时间都花在磁盘IO,网络通信等事情上。如果不希望CPU大部分时间都在等待其他资源,那么最容易想到的办法就是,让它在等待的时间去做别的事情,即同时处理几项任务。
回到本章刚开始引用的话,我们都知道,按照摩尔定律的预测,我们的计算能力会按照指数级别的速度增长。然而,近几年摩尔定律失效,聪明的硬件工程师为了进一步提升计算速度,不再追求单独的计算单元,而是将多个计算单元整合到了一起,也就是形成了多核CPU。短短十几年的时间,家用型CPU,比如Intel i7就可以达到4核心甚至8核心。因此,摩尔定律似乎在CPU核心扩展上继续得到体验。因此,多核的CPU的背景下,催生了并发编程的趋势,通过并发编程的形式可以将多核CPU的计算能力发挥到极致,性能得到提升
另外,在特殊的业务场景下先天的就适合于并发编程。比如当我们在网上购物时,为了提升响应速度,需要拆分,减库存,生成订单等等这些操作,就可以进行拆分利用多线程的技术完成。面对复杂业务模型,并行程序会比串行程序更适应业务需求,而并发编程更能吻合这种业务拆分
综上:并发编程具有以下优点:

  • 并发编程的形式可以将多核CPU的计算能力发挥到极致,性能得到提升。
  • 面对复杂业务模型,并行程序会比串行程序更适应业务需求,而并发编程更能吻合这种业务拆分。

1.3 并发的缺点和挑战

多线程技术有这么多的好处,但是应用不当,就会适得其反。因此,并发的缺点也可以理解为并发的挑战。

1.3.1 频繁的上下文切换

时间片是CPU分配给各个线程的时间,因为时间非常短,所以CPU不断通过切换线程,让我们觉得多个线程是同时执行的,时间片一般是几十毫秒。而每次切换时,需要保存当前的状态起来,以便能够进行恢复先前状态,而这个切换时非常损耗性能,过于频繁反而无法发挥出多线程编程的优势。通常减少上下文切换可以采用无锁并发编程,CAS算法,使用最少的线程和使用协程。

  • 无锁并发编程:可以参照concurrentHashMap锁分段的思想,不同的线程处理不同段的数据,这样在多线程竞争的条件下,可以减少上下文切换的时间。
  • CAS算法,利用Atomic下使用CAS算法来更新数据,使用了乐观锁,可以有效的减少一部分不必要的锁竞争带来的上下文切换
  • 使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多的线程,这样会造成大量的线程都处于等待状态
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换

由于上下文切换也是个相对比较耗时的操作,所以在"java并发编程的艺术"一书中有过一个实验,并发累加未必会比串行累加速度要快。 可以使用Lmbench3测量上下文切换的时长 vmstat测量上下文切换次数

1.3.2 线程安全

多线程编程中最难以把握的就是临界区线程安全问题,稍微不注意就会出现死锁的情况,一旦产生死锁就会造成系统功能不可用。

public class Main {
    private static String resource_a = "A";
    private static String resource_b = "B";

    public static void main(String[] args) {
        deadLock();
    }
​
    public static void deadLock() {
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (resource_a) {
                    System.out.println("get resource a");
                    try {
                    //暂停线程,保证线程B也执行到同样的位置
                        Thread.sleep(3000);
                   //线程A等待B资源
                        synchronized (resource_b) {
                            System.out.println("get resource b");
                        }
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
        Thread threadB = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (resource_b) {
                    System.out.println("get resource b");
                    //线程B也执行到这里,请求A资源,但是A现在也在等B,造成死锁。
                    synchronized (resource_a) {
                        System.out.println("get resource a");
                    }
                }
            }
        });
        threadA.start();
        threadB.start();
​
    }
}

在上面的这个demo中,开启了两个线程threadA, threadB,其中threadA占用了resource_a, 并等待被threadB释放的resource _b。threadB占用了resource _b正在等待被threadA释放的resource _a。因此threadA,threadB出现线程安全的问题,形成死锁。我们可以利用jps,jstack工具证明这种推论,使用方法如下:

//查看当前虚拟机开启的进程。
 jps -l

显示如下:

44117 
30838 nutstore.client.gui.NutstoreGUI
45095 org.jetbrains.jps.cmdline.Launcher
45096 com.Main //我们的Main进程,编号为45096
45240 jdk.jcmd/sun.tools.jps.Jps

//查看java堆中的信息
 jstack -l 45096

其中有以下信息:

Found one Java-level deadlock:
=============================
"Thread-0":
  waiting to lock monitor 0x00007ff0c2c07f00 (object 0x0000000787edca78, a java.lang.String),
  which is held by "Thread-1"
"Thread-1":
  waiting to lock monitor 0x00007ff0c2c07e00 (object 0x0000000787edca48, a java.lang.String),
  which is held by "Thread-0"

以上可以看到当前死锁的情况。

那么,通常可以用如下方式避免死锁的情况:

  • 避免一个线程同时获得多个锁;
  • 避免一个线程在锁内部占有多个资源,尽量保证每个锁只占用一个资源;
  • 尝试使用定时锁,使用lock.tryLock(timeOut),当超时等待时当前线程不会阻塞;
  • 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况

当然,以上部分初学者可能会看的云里雾里,这个很正常。如何正确的使用多线程编程技术有很大的学问,比如如何保证线程安全,如何正确理解由于JMM内存模型在原子性,有序性,可见性带来的问题。希望大家读完后面的部分回过头在看这里,就会觉得很简单了。

1.3.3 资源限制的挑战

(1)什么是资源限制
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。 例如,服务器的带宽只有2Mb/s,某个资源的下载速度是1Mb/s每秒,系统启动10个线程下载资 源,下载速度不会变成10Mb/s,所以在进行并发编程时,要考虑这些资源的限制。硬件资源限 制有带宽的上传/下载速度、硬盘读写速度和CPU的处理速度。软件资源限制有数据库的连接 数和socket连接数等。

(2)资源限制引发的问题
在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行, 但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。例如,之前看到一段程序使用多线程在办公网并发地下载和处理数据时,导致CPU利用率达到100%,几个小时都不能运行完成任务,后来修改成单线程,一个小时就执行完成了。

(3)如何解决资源限制的问题
对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资源有限制,那么就让 程序在多机上运行。比如使用ODPS、Hadoop或者自己搭建服务器集群,不同的机器处理不同 的数据。可以通过“数据ID%机器数”,计算得到一个机器编号,然后由对应编号的机器处理这 笔数据。
对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket 连接复用,或者在调用对方webservice接口获取数据时,只建立一个连接。

(4)在资源限制情况下进行并发编程
如何在资源限制的情况下,让程序执行得更快呢?方法就是,根据不同的资源限制调整 程序的并发度,比如下载文件程序依赖于两个资源——带宽和硬盘读写速度。有数据库操作 时,涉及数据库连接数,如果SQL语句执行非常快,而线程的数量比数据库连接数大很多,则 某些线程会被阻塞,等待数据库连接。

1.4 基础概念

1.4.1 同步VS异步

同步和异步通常用来形容一次方法调用。同步方法调用一开始,调用者必须等待被调用的方法结束后,调用者后面的代码才能执行。而异步调用,指的是,调用者不用管被调用方法是否完成,都会继续执行后面的代码,当被调用的方法完成后会通知调用者。比如,在超时购物,如果一件物品没了,你得等仓库人员跟你调货,直到仓库人员跟你把货物送过来,你才能继续去收银台付款,这就类似同步调用。而异步调用了,就像网购,你在网上付款下单后,什么事就不用管了,该干嘛就干嘛去了,当货物到达后你收到通知去取就好。

1.4.2 并发与并行

见1.1

1.4.3 阻塞和非阻塞

阻塞和非阻塞通常用来形容多线程间的相互影响,比如一个线程占有了临界区资源,那么其他线程需要这个资源就必须进行等待该资源的释放,会导致等待的线程挂起,这种情况就是阻塞,而非阻塞就恰好相反,它强调没有一个线程可以阻塞其他线程,所有的线程都会尝试地往前运行。

1.4.4 临界区

临界区用来表示一种公共资源或者说是共享数据,可以被多个线程使用。但是每个线程使用时,一旦临界区资源被一个线程占有,那么其他线程必须等待。

1.5 并发的三个性质:原子性、有序性和可见性

  1. 原子性:
    原子是元素能保持其化学性质的最小单位,在化学反应中不可分割。对应软件开发里面的一个完整的不可拆分的逻辑过程如事务,一个事务(transaction)中的所有操作,或者全部完成,或者全部不完成,不会结束在中间某个环节。
    java里面看着简单的操作不见得是原子操作:比如自增++ 编译成字节码后它分为三步:ILOAD(加载)-IINC(自增)-ISTORE(存储)。另一方面更复杂的CAS(Compare And Swap)操作却具有原子性。注意即使编译后只有一条字节码也不能保证这个操作一定具有原子性,因为一条字节码也可能转化成多条机器码。

  2. 有序性
    有序性即程序执行的顺序按照代码的先后顺序执行。

在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。(例如:重排的时候某些赋值会被提前)

在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性

  1. 可见性
    可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

二. java的内存模型

Java线程之间的通信对程序员完全透明,内存可见性问题很容易困扰Java程序员,本章将 揭开Java内存模型神秘的面纱。本章大致分4部分:Java内存模型的基础,主要介绍内存模型相 关的基本概念;Java内存模型中的顺序一致性,主要介绍重排序与顺序一致性内存模型。

2.1 JMM内存结构

2.1.1 并发编程的两个关键问题

在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。

在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。

同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存并发模型 里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对 程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作 机制,很可能会遇到各种奇怪的内存可见性问题。

说起内存模型,我们要学会区分jvm里面的内存区域和内存模型(JMM)。jvm的内存区域就是我们平时说的包含堆、方法区(永久带)、java虚拟机栈、本地方法栈、程序计数器等。内存模型与内存区域不是同一层次的划分,这两者基本上没有关系。

2.1.2 JMM的抽象结构

如前文所述,我们知道CPU的处理速度和主存的读写速度不是一个量级的,为了平衡这种巨大的差距,每个CPU都会有缓存。因此,共享变量会先放在主存中,每个线程都有属于自己的工作内存,并且会把位于主存中的共享变量拷贝到自己的工作内存,之后的读写操作均使用位于工作内存的变量副本,并在某个时刻将工作内存的变量副本写回到主存中去。JMM就从抽象层次定义了这种方式,并且JMM决定了一个线程对共享变量的写入何时对其他线程是可见的。

JMM模型图.png 如图为JMM抽象示意图,线程A和线程B之间要完成通信的话,要经历如下两步:

  1. 线程A从主内存中将共享变量读入线程A的工作内存后并进行操作,之后将数据重新写回到主内存中;
  2. 线程B从主存中读取最新的共享变量

从横向去看看,线程A和线程B就好像通过共享变量在进行隐式通信。这其中有很有意思的问题,如果线程A更新后数据并没有及时写回到主存,而此时线程B读到的是过期的数据,这就出现了“脏读”现象。可以通过同步机制(控制不同线程间操作发生的相对顺序)来解决或者通过volatile关键字使得每次volatile变量都能够强制刷新到主存,从而对每个线程都是可见的,这部分后面会详细介绍。

2.2 重排序

一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标而进行奋斗:在不改变程序执行结果的前提下,尽可能提高并行度。JMM对底层尽量减少约束,使其能够发挥自身优势。因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。一般重排序可以分为如下三种:

重排序.png

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

如图,1属于编译器重排序,而2和3统称为处理器重排序。这些重排序会导致线程安全的问题,一个很经典的例子就是DCL问题,这个在以后的文章中会具体去聊。针对编译器重排序,JMM的编译器重排序规则会禁止一些特定类型的编译器重排序针对处理器重排序,编译器在生成指令序列的时候会通过插入内存屏障指令来禁止某些特殊的处理器重排序

那么什么情况下,不能进行重排序了?下面就来说说数据依赖性。有如下代码:

double pi = 3.14 //A

 double r = 1.0 //B

 double area = pi * r * r //C

这是一个计算圆面积的代码,由于A,B之间没有任何关系,对最终结果也不会存在关系,它们之间执行顺序可以重排序。因此可以执行顺序可以是A->B->C或者B->A->C执行最终结果都是3.14,即A和B之间没有数据依赖性。具体的定义为:如果两个操作访问同一个变量,且这两个操作有一个为写操作,此时这两个操作就存在数据依赖性这里就存在三种情况:1. 读后写;2.写后写;3. 写后读,者三种操作都是存在数据依赖性的,如果重排序会对最终执行结果会存在影响。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序

2.3 as-if-serial

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提供并行度),(单线程)程序的执行结果不能被改变。编译器,runtime和处理器都必须遵守as-if-serial语义。as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。比如上面计算圆面积的代码,在单线程中,会让人感觉代码是一行一行顺序执行上,实际上A,B两行不存在数据依赖性可能会进行重排序,即A,B不是顺序执行的。as-if-serial语义使程序员不必担心单线程中重排序的问题干扰他们,也无需担心内存可见性问题。

2.4 happens-before规则

上面的内容讲述了重排序原则,一会是编译器重排序一会是处理器重排序,如果让程序员再去了解这些底层的实现以及具体规则,那么程序员的负担就太重了,严重影响了并发编程的效率。因此,JMM为程序员在上层提供了六条规则,这样我们就可以根据规则去推论跨线程的内存可见性问题,而不用再去理解底层重排序的规则。

2.4.1 happens-before定义

happens-before被用来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。具体的定义为:

1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

上面的1)是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!

上面的2)是JMM对编译器和处理器重排序的约束原则。正如前面所言,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。

as-if-serial VS happens-before:

  1. as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
  2. as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
  3. as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

2.4.2 具体规则

具体的一共有六项规则:

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  5. start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  7. 程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
  8. 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

下面以一个具体的例子来讲下如何使用这些规则进行推论

依旧以上面计算圆面积的进行描述。利用程序顺序规则(规则1)存在三个happens-before关系:1. A happens-before B;2. B happens-before C;3. A happens-before C。这里的第三个关系是利用传递性进行推论的。A happens-before B,定义1要求A执行结果对B可见,并且A操作的执行顺序在B操作之前,但与此同时利用定义中的第二条,A,B操作彼此不存在数据依赖性,两个操作的执行顺序对最终结果都不会产生影响,在不改变最终结果的前提下,允许A,B两个操作重排序,即happens-before关系并不代表了最终的执行顺序。

三. 深入理解volatile

volatile是java虚拟机提供的最轻量级的同步机制, volatile 变量可以被看作是一种 “轻量级synchronized”。与 synchronized 块相比,volatile 变量所需的编码较少,并且运行时开销也较少,但是它所能实现的功能也仅是 synchronized 的一部分。volatile可以保证有序性和可见性。

3.1特性

一个变量定义为volatile之后就具备了两层语义:

  • 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(可见性)
  • 禁止进行指令重排序。(有序性)
    值得之一的是,volatile只是一种同步机制,并不是线程安全的,因为java里面的运算符不是原子的。

3.2原理与实现机制

volatile各种特性是通过内存屏障实现的

3.2.1.可见性

被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。
volatile是怎样实现了?例如一段单例模式的代码:

private static volatile Singleton instance;
instance = new Instancce();

在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令,Lock前缀的指令在多核处理器下主要有这两个方面的影响:

  • 将当前处理器缓存行的数据写回系统内存;
  • 这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。因此,经过分析我们可以得出如下结论:

  1. Lock前缀的指令会引起处理器缓存写回内存;
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
  3. 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。

请判断以下说法的正确与否:volatile对所有线程是立即可见的,对它所有的写操作都可以立即反映到其它线程中。换句话说,volatile在各个线程内部是一致的,因此所有基于volatile的变量在多线程下都是线程安全的
这个说法中,前面都是正确的,但是推论是不正确的.因为java中的操作不是原子的。代码演示如下:

public class Main {
    public static volatile int count = 0;
    public static void increase() {
        count++;
    }
    private static final int THREAD_COUNTS = 20;

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[THREAD_COUNTS];
        for (int i = 0; i < THREAD_COUNTS; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 500; j++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        Thread.sleep(1000);
        System.out.println(count);


    }

}

可以看到,最后的结果不等于10000,这是因为count++操作不是原子的(这点从字节码就能看出)。想想一种情况,线程1把count的值取到栈顶时,volatile关键字保证了其正确性,然而,将其拷贝回去的时候,其它线程中可能已经+2了,而线程1只+1,这个时候,其对应的值就会变小。
不是原子的,可能线程1先+,然后刷新到线程2中,这个时候线程2已经加完了。这个时候就少加了1。
假如没加volatile也不可,可能分别加,少了一个刷新到线程2中,但最后还是少加了1. image.png

3.2.2 原子性(不能保证)

正如我们前文所述,原子性代表这个操作是不可分割的,完整的,中间不可以被加塞或者分割。 volatile不可保证原子性,上文中已经验证过,本节对以上例子进行详细说明:

public static volatile int count = 0;
public void test() {
    count++;
}

以上方法的字节码指令如下: image.png 可以看到count++被拆成了很多条指令,因此不是原子的。

image.png

3.2.3 有序性

volatile通过假如内存屏障来组织重排序,从而保障有序性。

volatile 实现了禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。

先了解一个概念,内存屏障(Memory Barrier) 又称为内存栅栏,是一个CPU 指令,它有两个作用:

1、保证特定操作的执行顺序。

2、保证某些变量的内存可见性(利用该特征实现 volatile 的内存可见性,如3.2.1)

由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条 Memmory Barrier 则会告诉编译器和 CPU ,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排优化。内存屏障另外一个作用是强制刷出各种 CPU 的缓存数据,因此任何 CPU 上的吓成都能读取到这些数据是最新版本

3.3 应用

适用于一个线程写,多个线程读
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:

1)对变量的写操作不依赖于当前值

2)该变量没有包含在具有其他变量的不变式中

3.3.1boolean类型变量

看这种情况,while内的代码被注释掉,业务线程是不会停下来的,因为变量的值没有刷到业务线程的缓存中。调用的时候println方法如下:

image.png

synchronized的内存语义同volatile一样,会强制刷到业务线程的缓存中。

static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(new Runnable() {
        @Override
        public void run() {
            while(flag) {
                //System.out.println(flag);
            }
        }
    });
    t1.start();
    Thread.sleep(1000);
    flag = false;

}

而像如下这种情况就很适合利用volatile变量来控制并发,当shutdown方法被调用,可以保证业务代码停下来。

volatile boolean shutdownRequested;
public void shutdown() {
    shutdownRequested = true;
}
public void doWork() {
    while (!shutdownRequested) {
        //业务逻辑
    }
}

上述代码确保了,只要一个线程调用了shutdow方法,那么由于shutdownRequested变量是对所有线程立即可见的,所以用volatile控制并发非常合适。大概原理如下: 第一:使用volatile关键字会强制将修改的值立即写入主存; 第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效); 第三:由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取

3.3.2 单例模式DCL

DCL(double check lock)双锁检测单例模式,其中对于单例对象加入了volatile修饰。

public class SingletonDemo {

    private static volatile SingletonDemo singletonDemo;


    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t  invoke SingletonDemo()");
    }

    public static SingletonDemo getSingleton() {
        if (singletonDemo == null) {
            // 同步代码块加锁
            synchronized (SingletonDemo.class) {
                if (singletonDemo == null) {
                //这条语句非原子
                    singletonDemo = new SingletonDemo();
                }
            }
        }
        return singletonDemo;
    }
}

那么为什么要这么做呢?

创建单例的语句如下:
instance = new SingletonDemo() 
    
    
memory = allocate() //1. 分配内存空间
instance(memory)    //2. 初始化对象
instance = memory   //3.设置 instance 执行刚才分配的内存地址,此时 instance!= null
    

步骤 2 和步骤 3 不存在数据依赖关系,而且无论重排前还是重排后的执行结果在但线程中并没有发生改变,因此这种重排优化是优化是允许的

instance = new SingletonDemo() 
    
    
memory = allocate() //1. 分配内存空间
instance = memory   //3.设置 instance 执行刚才分配的内存地址,此时 instance!= null 但是对象还没有被初始化完成!
instance(memory)    //2. 初始化对象

但是指令重排只会保证串行语句的执行的一致性(单线程),但是并不会关心多线程间的语义一致性。

所以当一条线程访问 instnce 不为 null 时,由于 instance 实例尾部已经初始化完成,也就造成了线程安全问题。

四.synchronized

4.1synchronized简介

在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重了。本文详细介绍synchronized的用法,原理和优化。

public class SynchronizedDemo implements Runnable {
    private static int count = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(new SynchronizedDemo());
            thread.start();
        }
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("result: " + count);
    }

    @Override
    public void run() {
        for (int i = 0; i < 500; i++)
            count++;
    }
}

还是一个累加的问题,我们知道因为线程安全问题,以上count肯定是小于10*500的,且上一章的volatile并没有解决这个问题。那么如何解决他呢?很自然而然的想法就是每一个线程依次去读写这个共享变量,这样就不会有任何数据安全的问题,因为每个线程所操作的都是当前最新的版本数据。那么,在java关键字synchronized就具有使每个线程依次排队操作共享变量的功能。下面将对他进行介绍。

4.2 synchronized用法和原理

4.2.1用法

使用位置作用范围被锁定对象代码示例
方法实例方法当前实例对象public synchronized void method() { .......}
静态方法类对象class对象public static synchronized void method1() { .......}
代码块实例对象Synchonized括号里配置的对象synchronized (this) { .......}
class对象类对象synchronized (SynchronizedScopeDemo.class) { .......}
任意object对象实例对象objectfinal String lock = "";synchronized (lock) { .......}

如表所示synchronized可以用在方法上也可以使用在代码块中,方法是实例方法和静态方法分别锁的是该类的实例对象和该类的对象。而使用在代码块中根据锁的目标对象

也可以分为三种,具体的可以看表数据。这里的需要注意的是如果锁的是类对象的话,尽管new多个实例对象,依然会被锁住。synchronized的使用起来很简单,那么背后的原理以及实现机制是怎样的呢?

4.2.2原理

1.对象锁(monitor)机制 现在来进一步分析synchronized的具体底层实现,有如下一个简单的示例代码:

public static void main(String[] args) {
    synchronized (Main.class) {
        System.out.println("hello");
    }

}

将java代码进行编译之后通过javap -v Main.class来查看对应的main方法字节码如下:

 public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // class com/Main
       2: dup
       3: astore_1
       4: monitorenter
       5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       8: ldc           #4                  // String hello
      10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      13: aload_1
      14: monitorexit
      15: goto          23
      18: astore_2
      19: aload_1
      20: monitorexit
      21: aload_2
      22: athrow
      23: return
    Exception table:
       from    to  target type
           5    15    18   any
          18    21    18   any

需要通过monitorenter指令获取到对象的monitor(也通常称之为对象锁)后才能往下进行执行,在处理完对应的方法内部逻辑之后通过monitorexit指令来释放所持有的monitor,以供其他并发实体进行获取。代码后续执行到第15行goto语句进而继续到第23行return指令,方法成功执行退出。另外当方法异常的情况下,如果monitor不进行释放,对其他阻塞对待的并发实体来说就一直没有机会获取到了,系统会形成死锁状态很显然这样是不合理。

因此针对异常的情况,会执行到第20行指令通过monitorexit释放monitor锁,进一步通过第22行字节码athrow抛出对应的异常。从字节码指令分析也可以看出在使用synchronized是具备隐式加锁和释放锁的操作便利性的,并且针对异常情况也做了释放锁的处理。

每个对象都存在一个与之关联的monitor,线程对monitor持有的方式以及持有时机决定了synchronized的锁状态以及synchronized的状态升级方式。monitor是通过C++中ObjectMonitor实现,代码可以通过openjdk hotspot链接进行下载openjdk中hotspot版本的源码,具体源码为:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

主要是要关注上面的两个容器waitSet和EntryList用来保存当前由于某种原因正在等待的线程,_owner指向了持有当前monitor的线程。等待获取锁以及获取锁出队的示意图如下图所示:

image.png

当多个线程进行获取锁的时候,首先都会进行_EntryList队列,其中一个线程获取到对象的monitor后,对monitor而言就会将_owner变量设置为当前线程,并且monitor维护的计数器就会加1。如果当前线程执行完逻辑并退出后,monitor中_owner变量就会清空并且计数器减1,这样就能让其他线程能够竞争到monitor。另外,如果调用了wait()方法后,当前线程就会进入到_WaitSet中等待被唤醒,如果被唤醒并且执行退出后,也会对状态量进行重置,也便于其他线程能够获取到monitor。

同步方法的原理

 public static synchronized void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String 111
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return

方法级同步是隐式执行的。当调用了设置ACC_SYNCHRONIZED标记的方法时,执行线程将要获取monitor,获取成功之后才能执行方法体,方法执行完毕之后释放monitoir。

也就是说在本质上,Synchronized锁住的同步方法和同步代码块都是靠monior来实现的,只不过同步方法只是一种隐式的执行。

4.3 synchronized优化

那么,针对synchronized的优化是怎样做的呢?在进一步分析之前,需要先了解这两个概念:1. CAS操作;2.Java对象头。

4.3.1 CAS操作

什么是CAS?

使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。而CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用**CAS(compare and swap)**又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。

4.3.1.1 CAS的操作过程

CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量是,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程。

// 需要不断尝试 
    while(true) {
        int 旧值 = 共享变量 ; // 比如拿到了当前值 0,共享变量需要volatile修饰,保证变量的可见性。
        int 结果 = 旧值 +1;// 在旧值 0 的基础上增加 1 ,正确结果是 1
/*
这时候如果别的线程把共享变量改成了 5,本线程的正确结果 1 就作废了,这时候 compareAndSwap 返回 false,重新尝试,直到:
compareAndSwap 返回 true,表示我本线程做修改的同时,别的线程没有干扰
*/
        if( compareAndSwap ( 旧值, 结果 )) { // 成功,退出循环
        } 
    }

获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。结合 CAS 和 volatile 可以实现无 锁并发,适用于竞争不激烈、多核 CPU 的场景下。
CAS的实现需要硬件指令集的支撑,在JDK1.5后虚拟机才可以使用处理器提供的CMPXCHG指令实现。

Synchronized VS CAS

元老级的Synchronized(未优化前)最主要的问题是:在存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。而CAS并不是武断的间线程挂起,当CAS操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒的操作,因此也叫做非阻塞同步。这是两者主要的区别。

4.3.1.2 CAS的应用场景

在J.U.C包中利用CAS实现类有很多,可以说是支撑起整个concurrency包的实现,在Lock实现中会有CAS改变state变量,在atomic包中的实现类也几乎都是用CAS实现,关于这些具体的实现场景在之后会详细聊聊,现在有个印象就好了(微笑脸)。

4.3.1.3 CAS的问题

1. ABA问题 因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。java这么优秀的语言,当然在java 1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,解决思路就是这样的。

2. 自旋时间过长

使用CAS时非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。如果JVM能支持处理器提供的pause指令,那么在效率上会有一定的提升。

3. 只能保证一个共享变量的原子操作

当对一个共享变量执行操作时CAS能保证其原子性,如果对多个共享变量进行操作,CAS就不能保证其原子性。有一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。然后将这个对象做CAS操作就可以保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性。

4.3.2 java对象头

我们知道,对象是存放在堆中的,那么对象在堆中是怎么存放的?

对象在堆内存的存储布局可分为对象头,实例数据和对齐填充。对象头(Header)占 12B,包括对象标记(Mark Word)和类指针(class pointer)以及数组长度。
对象头里的Mark Word里默认的存放的对象的Hashcode,分代年龄和锁标记位。如下: image.png

image.png HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

4.3.3偏向锁

偏向锁的获取

当一个线程访问同步块并获取锁时,会在对象头栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。Java 6 中引入了偏向锁 来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS.

偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。 总之:

  • 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)

  • 访问对象的 hashCode 也会撤销偏向锁

  • 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2, 重偏向会重置对象的 Thread ID

  • 撤销偏向和重偏向都是批量进行的,以类为单位

  • 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的

  • 可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁

假设有两个方法同步块,利用同一个对象加锁

image.png

4.3.4轻量级锁

如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻

量级锁来优化。这就好比:

学生(线程 A)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明没 有竞争,继续上他的课。

如果这期间有其它学生(线程 B)来了,会告知(线程A)有并发访问,线程 A 随即升级为重量级锁, 进入重量级锁的流程。

而重量级锁就不是那么用课本占座那么简单了,可以想象线程 A 走之前,把座位用一个铁栅栏围起来。
假设有两个方法同步块,利用同一个对象加锁:

static Object obj = new Object();
public static void method1() {
    synchronized( obj ) {
        // 同步块 A
        method2(); }
}
public static void method2() {
    synchronized( obj ) {
        // 同步块 B
    }
}

image.png

如上所述,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word

4.4.5锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻 量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

image.png

4.4.6重量级锁

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退

出了同步块,释放了锁),这时当前线程就可以避免阻塞。

在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能 性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。 好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等 待时间长了划算)
  • Java 7 之后不能控制是否开启自旋功能

image.png

image.png

4.4.7其他优化

4.4.7.1 减少上锁时间

同步代码块中代码尽可能短

4.4.7.2 减少锁的粒度

一个锁拆分为多个锁

4.4.7.3 锁粗化

多次循环进入同步块不如同步块内多次循环。 另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁, 没必要重入多次)

new StringBuffer().append("a").append("b").append("c");

4.4.7.4 锁消除

JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候

就会被即时编译器忽略掉所有同步操作。

4.4.7.5 读写分离

  1. juejin.cn/post/706163…
  2. juejin.cn/post/684490…
  3. juejin.cn/post/706163…
  4. juejin.cn/post/684490…