Java学习_多线程编程(上)(很全,上篇四万多字)

不定期补充、修正、更新;欢迎大家讨论和指正
本文只涉及到线程基本概念和线程安全问题,因为字数过多,篇幅太长,阅读不易,关于线程活性故障、线程通信、线程池的知识点会在下篇涉及

  • [ JAVA学习_多线程编程(下)] 链接

参考资料
以下均为视频,参考的文章会在摘要后贴上链接
黑马【多线程】知识
黑马程序员全面深入学习java并发编程,java基础进阶必学教程
Java多线程实战精讲-带你一次搞明白Java多线程高并发
B站最详细JAVA高并发多线程VIP课程–圣思园
尚硅谷_Java零基础教程-java入门必备-适合初学者的全套完整版教程(宋红康主讲)

目录

  • 前言
    • 线程概述
    • 串行、并行、并发
    • 线程生命周期
    • 常用API
  • 多线程编程
  • 线程安全
      • ⚽synchronized
        • 底层原理
        • 锁优化和升级
      • ⚽Lock实现类
        • AQS
        • ReentrantReadWriteLock
        • StampedLock
      • ⚽总结
    • volatile
      • ⚽JMM
      • ⚽指令重排序
      • ⚽不保障原子性
    • Atomic类
      • ⚽CAS
        • ABA问题
    • ThreadLocal

前言

线程概述

如果学习过操作系统这门课对进程线程应该不陌生,这里简单理解下概念就行

进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础,打开任务管理器就可以看到一个一个的进程;进程可以认为是运行的程序,程序是放在磁盘的数据,是死的;程序放在内存让CPU处理后就变成了进程。

线程(thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
以浏览器为例,浏览器自身是一个进程,标签页就是一个线程(实际上目前大多浏览器标签页也是进程来实现)

为什么使用线程,我们知道OS给进程分配资源不是进程结束才释放资源,而是这个进程用一会,然后又将资源给另一个进程,即利用时间轮片算法进行并发,这样可以很好地提高资源利用率(比如A处理花3秒,等待2秒,B处理2秒,等待1秒,如果是串行总共要花费3+2+2+1=8秒,而如果在A等待这段时间把资源给B,这样最理想就只用花费3+1=4秒,)
但是进程切换时资源开销还是比较大的(需要保存当前的状态起来,以便能够进行恢复先前状态)于是就发明出线程,线程本质上就是一个函数/方法,但是有自己的线程栈,可以视为弱化的进程,我们编程都知道函数之间调用并不会花费什么资源。

OS中,电脑刚启动会创建一个主进程,它会创建子进程,子进程自己又创建子进程,子子孙孙无穷匮也,什么桌面、任务管理器、QQ等应用程序都是其直接或间接产生的。Java中,JVM启动会创建主线程,即运行main方法的线程。

串行、并行、并发

  • 串行(sequential):煮完饭后,饭能开锅才洗菜,总耗时A+B
  • 并发(concurrent):煮饭时洗菜(这里的煮饭是饭在锅里煮着),总耗时<=A+B
  • 并行(parallel):煮饭和洗菜同时进行(这里煮饭包括淘米到放到锅里煮一系列流程),总耗时max(A,B),想要实现并行就要多个人/多核同时工作。

由于计算机中CPU的高速运算,并行和并发容易混淆,单核的情况下,我们以为听歌和上网是同时运行,其实是OS采用时间轮片算法进行并发,让我们以为宏观上是同时运行,微观上是A进程用一会资源马上就将资源给B进程

并行是严格意义上的同一时刻同时进行,这就要求CPU是多核或多线程

如下图
Java学习_多线程编程(上)(很全,上篇四万多字)_第1张图片

线程生命周期

在OS层面,当线程(进程)被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。在线程的生命周期中,它要经过新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Dead)5种状态。尤其是当线程启动以后,它不可能一直"霸占"着CPU独自运行,所以CPU需要在多条线程之间切换,于是线程状态也会多次在运行、阻塞之间切换。(进程也同理)

  • 新建状态(New):当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread();
  • 就绪状态(Runnable):当调用线程对象的start()方法,线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了start()此线程立即就会执行;
  • 运行状态(Running):当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;
  • 阻塞状态(Blocked):处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到>- 就绪状态,才 有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:
    1.等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;
    2.同步阻塞 – 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态;
    3.其他阻塞 – 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
  • 死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
    Java学习_多线程编程(上)(很全,上篇四万多字)_第2张图片
    摘自线程的生命周期及五种基本状态
    实际上Java线程的状态和以上略有点出入,Thread类的状态枚举类如下
    Java学习_多线程编程(上)(很全,上篇四万多字)_第3张图片
  • 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
  • 运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
    线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。
  • 阻塞(BLOCKED):表示线程阻塞于锁。
  • 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
  • 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
  • 终止(TERMINATED):表示该线程已经执行完毕。
    Java学习_多线程编程(上)(很全,上篇四万多字)_第4张图片

常用API

在Java创建线程有三种方式:继承Thread类、实现Runable接口、实现Callable接口配合使用FutureTask类
创建Thread线程的三种方式、代码、使用场景及比较

下面为常用API,没什么难度就不演示了

  • start(),请求JVM运行相应的线程,但不意味线程会马上执行,什么时候运行由线程调度器决定。多个线程时其启动顺序也不一定跟调用start的顺序有关,线程只能start一次。另外注意调用的是线程的start()方法而不是run()方法,因为start()方法包含两步:1.启动该线程 2.调用该线程的run()方法,如果直接调用run()方法跟main调用方法没什么区别,就没线程什么事了。
  • currentThread(),获取当前线程
  • isAlive(),判断线程是否处于活动状态
  • sleep(),当前线程休眠,单位ms
  • yield(),主动放弃CPU资源
  • stop(),停止该线程,该线程生命周期结束,已过时
  • join(),将资源让给执行方法的线程,比如A线程中有B线程的join()方法,那么A线程就会将资源让给B,A进入阻塞直到B处理完
  • setPriority(),设置线程优先级,取值为1~10,10最高,默认为5,当然到底怎么调度并不是严格根据优先级设置来决定的。优先级设置不当或滥用可能会导致某些线程永远得不到资源,即产生线程饥饿。
  • interrupt(),中断当前线程

多线程编程

实际开发中对线程的应用大多都是多线程编程,多线程编程有诸多好处

  • 提高系统的吞吐率。多线程编程使得一个进程中可以有多个并发(即同时进行)的操作。例如,当一个线程因为I/O操作而处于等待时,其他线程仍然可以执行其操作。
  • 提高响应性。在使用多线程编程情况下,对于GUI软件(如桌面应用程序)而言,一个慢的操作(比如从服务器上下载一个大的文件)并不会导致软件的界面出现被“冻住”的现象而无法响应用户其他的操作;对与Web应用程序而言,一个请求慢了并不会影响其他请求处理。
  • 充分利用多核处理器资源。如今的多核处理器越来越普及,就算是手机这样的消费类设备也普遍使用多核处理器。恰当的多线程编程有助于我们充分利用多核处理器资源,从而避免资源浪费。
  • 最小化对系统资源的使用。一个进程中的多个线程可以共享其所在进程所申请的资源(如内存空间),因此使用多个线程相比于使用多个进程进行编程来说,节约了对系统资源的使用。
  • 简化程序的结构。线程可以简化复杂应用程序的结构。

A coin Has two sides,多线程编程的风险也很明显,以下面卖票为例,共享变量用static修饰),
Java学习_多线程编程(上)(很全,上篇四万多字)_第5张图片
很容易找到数据出错的问题,原因就在于线程资源切换时而没有作出相应保护
Java学习_多线程编程(上)(很全,上篇四万多字)_第6张图片

Java学习_多线程编程(上)(很全,上篇四万多字)_第7张图片

以上仅仅是线程安全问题,多线程编程总的来说有以下问题

  • 线程安全问题(⭐):多个线程共享数据的时候,如果没有采取相对应的并发访问控制措施,那么就可能产生数据一致性问题,如读取脏数据(过期的数据)、丢失更新(某些线程所做的更新被其他线程所做的更新覆盖过)等。
  • 线程活性故障(⭐):线程活性故障 是由于资源稀缺性或者程序自身的问题导致线程一直处于非 Runnable 状态,或者线程虽然处于 Runnable 状态但是其要执行的任务一直无法取得进展的一种故障现象,常见的有死锁、活锁和饥饿。
    • 死锁(Deadlock):所谓死锁,是指多个线程在运行过程中因争夺资源而造成的一种僵局,当线程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。 例如,线程T1拥有锁L1,并试图去获得锁L2,而此时线程T2拥有锁L2而试图去获得锁L1,这就导致线程T1和T2一直处于等待对方释放锁而一直又得不到锁的状态。
    • 活锁(Livelock):活锁指的是任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败,这就好比小猫一直追着自己的尾巴咬却一直咬不到的情形。 活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,所谓的“活”, 而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。
    • 饥饿(Starvation):即某些线程永远无法获取处理器执行的机会而永远处于就绪态。比如设置线程优先级不合理,优先级高的线程一直占用资源,而优先级低的抢不到资源。
  • 上下文切换(Context Switch)处理器从执行一个线程转向执行另外一个线程的时候操作系统所需要做的一个动作被称为上下文切换。由于处理器资源的稀缺性,因此上下文切换可以被看作多线程编程的必然产物,它增加了系统的消耗,不利于系统的吞吐率。
  • 可靠性:多线程编程一方面可以有利于可靠性,例如某个线程意外提前终止了,但这并不影响其他线程继续其处理。另一方面,线程是进程的一个组件,它总是存在特定的进程中,如果这个进程由于某种原因意外提前终止,比如某个Java进程由于内存泄漏导致Java虚拟机崩溃而意外终止,那么该进程中所有的线程也就随之无法继续运行。因此,从提高软件可靠性的角度来看,某些情况下可能要考虑多进程多线程的编程方式,而非简单的单进程多线程方式。

本文主要关心的是线程安全问题和线程活性故障

线程安全

多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。
多线程编程有三要素,满足这三个要素就可以保证线程安全,分别是原子性、可见性、有序性

  • 原子性(Atomicity):跟事务的原子性一样,即一个操作要么全部执行,要么全部都不执行。对于保证原子性一般通过同步机制和Atomic类来完成。
  • 可见性(Visibility):当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。对于可见性可以使用锁,volatile关键字保证
  • 有序性(Ordering):程序执行顺序和代码编写顺序一致,有同学会觉得这不是很正常吗,其实由于指令重排序存在,代码书写的顺序与实际执行的顺序不同,指令重排序是编译器或处理器为了提高程序性能而做的优化(有些代码翻译成机器指令之后,如果进行一个重排序,那可能重排序之后的顺序更加符合CPU执行的特点,这样就可以最大限度发挥CPU的性能)

从广义上说,Java平台提供的线程同步机制包括锁、volatile关键字、final关键字、static关键字、和一些相关的API
我们着重关注锁,因为锁可以直接保证线程安全三要素,volatile关键字虽然也可用于同步机制,但其不保证原子性,只保证可见性和有序性,之后对它单独另讲,static关键字、final关键字不用多说了。

Java提供的锁机制有synchronized关键字和JUC(JUC是 Java 5.0 提供的 java.util.concurrent包,在此包中增加了在并发编程中很常用的工具类)提供的Lock接口及实现类

关于锁机制还有许多概念需要了解,稍安勿躁

  1. 内置锁和显示锁:即根据JVM对锁的实现方法进行划分,内置锁由synchronized关键字实现, 显式锁是通过 java.concurrent.locks.Lock接口的实现类来实现,这也是我们后续所要学习的知识。
  2. 类锁和对象锁:内部锁synchronized需要同步监视器,这个监视器可以由任意对象或者类担任。例如object是对象锁,Object.class为类锁。一般来说静态方法使用类锁,实例方法使用对象锁。
  3. 临界区:指的是一个访问共用资源(比如共享变量)的程序片段,而这些共用资源又无法同时被多个线程访问的特性,简单来说被锁保护的代码块就是临界区
  4. 公平锁:公平锁是指多个线程在等待同一个锁时,必须按照申请锁的先后顺序来一次获得锁。公平锁的好处是等待锁的线程不会饿死,但是整体效率相对低一些。大多数锁都是非公平锁,后续所讲的ReentrantLock可以在构造器中声明为true让其为公平锁。
  5. 可重入锁( ReentrantLock):也叫递归锁,即线程在持有该锁的情况下是否还能请求该锁;比如线程调用A方法时已经获得L锁,这时执行到A方法内的B方法,B方法内部也用L锁锁起来了,这时如果还能再次获得L锁就是则L锁是可重入锁,反之亦然。在Java环境下 ReentrantLock 和synchronized 都是可重入锁。可重入锁最大的作用是避免死锁。
  6. 重量锁、轻量锁、偏向锁:Java中每个对象都可作为锁,锁有四种级别,按照量级从轻到重分为:无锁、偏向锁、轻量级锁、重量级锁。并且锁只能升级不能降级。该内容会在synchronized锁优化和升级中详细讲解
  7. 自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
  8. 粒度:即锁保护的共享数据的数据量大小。粗粒度保护的范围大但是效率低,反之亦然
  9. 锁粗化:原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制的尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁禁止,那等待的线程也能尽快拿到锁。大部分情况下,这些都是正确的。但是,如果一系列的连续操作都是同一个对象反复加上和解锁,甚至加锁操作是出现在循环体中的,那么即使没有线程竞争,频繁地进行互斥同步操作也导致不必要的性能损耗。这时就需要将锁的范围扩大即锁粗化。
  10. 悲观锁(Pessimistic Lock):注意悲观锁/乐观锁不是指具体类型的锁,而是看待并发的角度。对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
  11. 乐观锁(Optimistic Locking):与悲观锁相对应,乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。乐观锁在Java中是通过无锁编程来实现的,最常使用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。乐观锁适用于读操作多的场景,这样可以提高程序的吞吐量。
  12. 无锁:字面意思,保证线程安全,并不是一定就要进行同步,只要满足三要素就行保证。同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的。
  13. 独享锁/共享锁:独享锁是指该锁一次只能被一个线程所持有,如ReentrantLock、 Synchronized;共享锁是指该锁可被多个线程所持有 如ReadWriteLock
  14. 互斥锁/读写锁:独享锁/共享锁这是广义上的说法,互斥锁/读写锁就分别对应具体的实现。在Java中如ReentrantLock就是互斥锁(独享锁), ReadWriteLock就是读写锁(共享锁)。 独享锁与共享锁也是通过AQS来实现的
  15. 闭锁、死锁、活锁: 闭锁会在线程通信的CountDownLatch讲到,死锁活锁会在线程活性故障讲到。

参考文章:
Java中的锁
java 锁(二):乐观锁VS悲观锁
Java中常用的锁机制
关于内部锁与显示锁
认真的讲一讲:自旋锁到底是什么

⚽synchronized

synchronized是Java提供最早的锁机制,它运行在JVM层面上,当使用synchronized 关键字后,代表这个方法加锁,相当于不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。它包括两种用法:synchronized 方法和 synchronized 块。

synchronized的用法很简单,还是以卖票为例,将需要同步的代码放到synchronized块中
synchronized块需要传入同步监视器,也就是俗称的锁,任何一个类的对象都可以充当锁,也可以使用类来充当锁如Object.class,但是线程间必须共用一把锁
Java学习_多线程编程(上)(很全,上篇四万多字)_第8张图片
自行试验,试验时可能会遇到只有一个线程输出的情况,这是因为在一个时间轮片内就执行完了,可以把数据弄大些或者加上sleep()方法。同时这里也可以看出synchronized是非公平锁
Java学习_多线程编程(上)(很全,上篇四万多字)_第9张图片
另一种是直接在需要同步的方法上加上synchronized关键字,其同步监视器是隐式声明,默认是this,显然的这种形式的粒度大,比较少用
Java学习_多线程编程(上)(很全,上篇四万多字)_第10张图片
注意如果线程是以继承Thread的方式创建的话,这种形式是不起同步作用的,因为两个线程都是new出来的,this也就不一样。如果非要使用就将同步的方法设为静态static的
在这里插入图片描述
如果是以Runnable接口创建线程的话的话,两个线程共用ticket2对象,而this都是ticket2,所以就能够成功同步
实际开发大多情况下也是使用Runnable接口来创建线程,一是可以数据共享,不用像继承那样给数据加static,二就是避免现在这种情况
Java学习_多线程编程(上)(很全,上篇四万多字)_第11张图片

以上试验了synchronized的原子性,关于可见性和有序性将会volatile中演示,下面演示synchronized另一个特性——可重入性,即一个线程能否多次获得同一个锁的能力

Java学习_多线程编程(上)(很全,上篇四万多字)_第12张图片
Java学习_多线程编程(上)(很全,上篇四万多字)_第13张图片
如果是不可重入锁,显然当run()方法拿到锁后,m1()是无法拿到锁的,就会进入阻塞状态,既然拿到了就是可重入锁了
注意可重入是针对一个线程来讲的,多个线程时锁肯定时互斥的
Java学习_多线程编程(上)(很全,上篇四万多字)_第14张图片

底层原理

synchronized是Java关键字,关键字的功能只能由底层来实现,在使用过程中我们并没有看到显示加锁和解锁的过程,所以我们有必要查看字节码文件,我们创建一个简单的方法
Java学习_多线程编程(上)(很全,上篇四万多字)_第15张图片
在终端下使用 [javap -v 类名] 查看该类的字节码文件(路径是生成target文件下,而不是类源码所在的路径下),可以看到synchronized在JVM层面使用的是monitor字样的指令,加锁的过程是monitorenter,解锁则是monitorexit
Java学习_多线程编程(上)(很全,上篇四万多字)_第16张图片
这里出现了两次monitorexit,主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁。
往下看就是其维护的异常表,异常表中第一行监控着4-14行的指令,也就是同步的起始位置到锁的释放,如果此时出现异常就会跳转到17行的指令;第二行监控的是17到20行的指令,主要监控的就是因为异常尝试第二次解锁的指令,如果还出现异常依然跳转到17行的指令,总之解锁不了就循环往复,保证锁能得到释放

在这里插入图片描述

如果synchronized作用于方法上,那它的字节码是怎么样的呢,可以看到方法多了ACC_SYNCHRONIZED标志,该标记表明线程进入该方法时,需要monitorenter,退出该方法时需要monitorexit
Java学习_多线程编程(上)(很全,上篇四万多字)_第17张图片
使用synchronized时需要传入同步监视器,字节码里也看到了monitor,那么这个monitor究竟是什么东西?
monitor,即可以翻译为监视器,而也可以叫做管程,本身是操作系统的一个机制,是将共享变量及对共享变量能够进行的所有操作集中在一个模块中,简单来说就是放入同步代码块中。
在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现的,其主要数据结构如下:

ObjectMonitor() {
     
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor中有几个关键属性:

_owner:指向持有ObjectMonitor对象的线程
_WaitSet:存放处于wait状态的线程队列
_EntryList:存放处于等待锁block状态的线程队列
_recursions:锁的重入次数
_count:用来记录该线程获取锁的次数

当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1。即获得对象锁。

若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示

Java学习_多线程编程(上)(很全,上篇四万多字)_第18张图片
monitor的基本工作流程就是这样,现在仍然有个问题,ObjectMonitor只是一个类,那么真正的锁在哪?还记得我们在synchronized中可以传入任意对象作为锁吗,所以这些对象既是一个对象也是一个锁。
因为在Java中任意对象都可以用作锁,因此必定要有一个映射关系,存储该对象以及其对应的锁信息(即上面ObjectMonitor的结构)。
一种很直观的方法是,用一个全局map,来存储这个映射关系,但这样会有一些问题:需要对map做线程安全保障,不同的synchronized之间会相互影响,性能差;另外当同步对象较多时,该map可能会占用比较多的内存。所以最好的办法是将这个映射关系存储在对象头中,因为对象头本身也有一些hashcode、GC相关的数据,所以如果能将锁信息与这些信息共存在对象头中就好了。
对象头属于JVM的知识,我们需要了解一下。

我们知道对象创建后存储在JVM的堆空间内,在HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)(其中对象头又分为Mark Word和Klass Word两个字段)、实例数据(Instance Data)和对齐填充(Padding),如果对象是数组类型对象头还会有Length字段

  • 对象头中的Mark Word(标记字)主要用来表示对象的线程锁状态,另外还可以用来配合GC、存放该对象的hashCode;
  • 对象头中的Klass Word是一个指向方法区中Class信息的指针,意味着该对象可随时知道自己是哪个Class的实例;
  • 数组长度也是占用64位(8字节)的空间,这是可选的,只有当本对象是一个数组对象时才会有这个部分;
  • 对象体是用于保存对象属性和值的主体部分,占用内存空间取决于对象的属性数量和类型;
  • 对齐字是为了确保整个对象所占的内存空间是8的整数倍,不到的用0补齐。
    Java学习_多线程编程(上)(很全,上篇四万多字)_第19张图片

Java学习_多线程编程(上)(很全,上篇四万多字)_第20张图片
Java学习_多线程编程(上)(很全,上篇四万多字)_第21张图片
Java学习_多线程编程(上)(很全,上篇四万多字)_第22张图片

Mark Word主要用来表示对象的线程锁状态,这里以32位虚拟机的存储结构为例,对象作为不同锁时Mark Word具体的含义也不同,如下
Java学习_多线程编程(上)(很全,上篇四万多字)_第23张图片
我们着重关注无锁和重量级锁的字段含义(其他锁很快就会讲到)
无锁也就是对象正常的情况,其锁标志位为01
当这个对象被当作锁(此时这个对象既是对象也是个锁,其HashCode会存储在另一个地方),前30位设置为指向重量级锁的指针,锁标志位也设置为10

关于底层原理了解的不深,所以这块可能讲得不太好,如果有错误和疏漏请指正

参考资料
java对象在堆内存中的结构
jvm-monitor原理

锁优化和升级

在jdk1.6之前,只有重量锁,synchronized底层是由c++提供的ObjectMonitor来维护。ObjectMonitor 帮synchronized封装了阻塞队列、同步队列,加锁,释放锁等复杂流程,其更底层调用操作系统的函数来实现线程同步,以及线程切换等操作。同时在一些并发不高,或者甚至没有并发的场景下,这些操作很浪费系统资源。所以,JVM对此进行了优化,出现了偏向锁和轻量级锁。
这部分可能稍微难懂些,建议大家先看看下面的视频
黑马程序员全面深入学习java并发编程,java基础进阶必学教程——P78

目前来说,锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁,,按照量级从轻到重分则是:无锁、偏向锁、轻量级锁、重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。
JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking来禁用偏向锁。

锁的状态在对象头的Mark Word字段中有着不同的表示,如下
Java学习_多线程编程(上)(很全,上篇四万多字)_第24张图片
轻量级锁
如果一个临界区虽然有很多线程访问,但多线程访问的时间大多是相互错开的,也就是锁的竞争压力低,如果使用重量级锁无疑是浪费资源和效率的,这时我们可以使用轻量级锁来优化。轻量级锁和后面讲的偏向锁的使用方法和之前并无差别,一样是synchronized的用法,对用户是透明的,只不过底层实现有区别。

以下为轻量级锁加锁和解锁具体流程

  1. 在线程进入同步块的时候,如果同步对象锁状态为偏向状态(就是锁标志位为“01”状态,是否为偏向锁标志位为“1”,因为轻量级锁是由偏向锁升级而来的),JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。官方称之为 Displaced Mark Word(所以这里我们认为Lock Record和 Displaced Mark Word其实是同一个概念)。这时候线程堆栈与对象头的状态如图所示:
    Java学习_多线程编程(上)(很全,上篇四万多字)_第25张图片

  2. 拷贝成功后,JVM将使用CAS操作(volatile中会详细讲)尝试将对象头的Mark Word更新为指向该线程Lock Record地址的指针,并将Lock Record里的owner指针指向对象头的Mark Word。如果更新成功,则执行步骤(3),否则执行步骤(4)。

  3. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下所示:
    Java学习_多线程编程(上)(很全,上篇四万多字)_第26张图片

  4. 如果这个更新操作失败了,这时会有两种情况,一种是锁重入了,另一种是确实有线程在竞争锁。JVM首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,现在是重入状态,那么设置Lock Record第一部分为null,起到了一个重入计数器的作用。下图为重入三次时的Lock Record示意图,左边为锁对象,右边为当前线程的栈帧,重入之后然后结束。接着就可以直接进入同步块继续执行。
    Java学习_多线程编程(上)(很全,上篇四万多字)_第27张图片
    如果不是说明这个锁对象已经被其他线程抢占了,说明此时有多个线程竞争锁,那么它就会自旋等待锁,一定次数后仍未获得锁对象,说明发生了竞争,需要膨胀为重量级锁。

  5. 自旋等待仍得不到锁的线程会为锁对象申请Monitor锁,让锁对象指向重量锁的地址,而自己进入Monitor中的EntryList阻塞(得不到就毁掉[doge])

  6. 此时获得锁的线程结束任务,需要解锁,该线程会通过CAS操作尝试把线程中复制的Lock Record对象替换当前的Mark Word,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),因为锁对象的Mark Word已经指向重量级锁地址了嘛,这时该线程就会通过地址找到Monitor,在那里释放锁的同时,唤醒被挂起的线程。

完整的流程图如下:
Java学习_多线程编程(上)(很全,上篇四万多字)_第28张图片
轻量级锁涉及到一个自旋的问题,而自旋操作是会消耗CPU资源的。为了避免无用的自旋,当锁资源存在线程竞争时,轻量级锁就会升级为重量级锁来避免其他线程无用的自旋操作。所以这就引出了轻量级锁的一个缺点:如果始终无法获得锁资源,线程就会自旋消耗CPU资源。
但是轻量级锁相对于重量级锁的一个有点就是:因为线程在竞争资源时采用的是自旋,而不是阻塞,也就避免了线程的切换带来的时间消耗,提高了程序的响应速度。

偏向锁
当一个线程反复的去获取或释放一个锁,如果这个锁是轻量级锁或者重量级锁,不断的加解锁显然是没有必要的,造成了资源的浪费。于是引入了偏向锁。
偏向锁顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁

偏向锁获取过程如下:

  1. 访问Mark Word中偏向锁标志位是否设置成1,锁标志位是否为01——确认为可偏向状态。
  2. 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行步骤4。
  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
  5. 执行同步代码。

偏向锁的释放
偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。
偏向锁的撤销,需要等待全局安全点safepoint,它会首先暂停拥有偏向锁的线程A,然后判断这个线程A,此时有两种情况:

  • A 线程已经退出了同步代码块,或者是已经不在存活了,如果是上面两种情况之一的,此时就会直接,撤销偏向锁,变成无锁状态。
  • A 线程还在同步代码块中,此时将 A 线程的偏向锁升级为轻量级锁。

批量重偏向
为什么有批量重偏向
当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。这个过程是要消耗一定的成本的,所以如果说运行时的场景本身存在多线程竞争的,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。因此,JVM中增加了一种批量重偏向/撤销的机制。

批量重偏向的原理

  1. 首先引入一个概念epoch,其本质是一个时间戳,代表了偏向锁的有效性,epoch存储在可偏向对象的MarkWord中。除了对象中的epoch,对象所属的类class信息中,也会保存一个epoch值。
  2. 每当遇到一个全局安全点时(这里的意思是说批量重偏向没有完全替代了全局安全点,全局安全点是一直存在的),比如要对class C 进行批量再偏向,则首先对 class C中保存的epoch进行增加操作,得到一个新的epoch_new
  3. 然后扫描所有持有 class C 实例的线程栈,根据线程栈的信息判断出该线程是否锁定了该对象,仅将epoch_new的值赋给被锁定的对象中,也就是现在偏向锁还在被使用的对象才会被赋值epoch_new。
  4. 退出安全点后,当有线程需要尝试获取偏向锁时,直接检查 class C 中存储的 epoch 值是否与目标对象中存储的 epoch 值相等, 如果不相等,则说明该对象的偏向锁已经无效了(因为(3)步骤里面已经说了只有偏向锁还在被使用的对象才会有epoch_new,这里不相等的原因是class C里面的epoch值是epoch_new,而当前对象的epoch里面的值还是epoch),此时竞争线程可以尝试对此对象重新进行偏向操作。

最后总结:

  1. 当一个对象没有被当成锁时,就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。
  2. 当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。
  3. 当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,虽然线程A一般不会自动释放偏向锁,但还是有机会获得锁的。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则升级为轻量锁。
  4. 偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,即锁记录(Lock Record)。线程会先将锁对象的Mark Work拷贝一份到锁记录中。拷贝成功后进行CAS操作尝试将锁对象的Mark Word改为指向线程中锁记录的指针,如果修改成功,那么该线程就拥有了该锁,锁对象的Mark Work前30bit会指向线程锁记录得地址,偏向锁位为0,锁标志为00。
  5. 如果修改失败,就说有线程竞争,此时抢锁失败,线程会进行自旋等待,不断尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败表示竞争压力大,则继续升级。
  6. 自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。

参考文章:
彻底搞懂Java中的偏向锁,轻量级锁,重量级锁
Java的对象头和对象组成详解
轻量级锁加解锁过程详解

⚽Lock实现类

synchronized虽然很好保证了线程安全,但作为Java早期的锁机制难免有局限性和缺点,不适合后面高并发的场景。

  1. 使用synchronized,其他线程只能等待直到持有锁的线程执行完释放锁(只有执行完了代码块或有异常才释放锁,事实上后者是优点,防止了死锁)
  2. synchronized是非公平锁,非公平锁就可能会造成线程活性故障——饥饿问题
  3. 对于多线程读写操作,读写、写写都会造成线程安全问题,但是读读操作并不会,然而synchronized并不理会这些,当一个线程进行读操作,那么其他线程只能等待。这对于读比较多的操作比如数据库查询synchronized无疑是效率低下的
  4. synchronized是作用在JVM层面,所以我们没办法做出太多操作,功能受限

于是就诞生了另一种锁机制——Lock,Lock接口及实现类是Java 5之后的 java.util.concurrent包所提供的工具,弥补了synchronized的一些缺点(synchronized是6之后才进行优化的)

  1. 有限等待:需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断),这个是synchronized无法办到,Lock可以办到,由tryLock(时间参数)实现;
  2. 可中断:使用synchronized时,等待的线程会一直阻塞,一直等待下去,不能够响应中断,而Lock锁机制可以让等待锁的线程响应中断,用lockInterruptibly()实现
  3. 有返回值:需要一种机制可以知道线程有没有成功获得到锁,这个是synchronized无法办到,Lock可以使用tryLock()方式实现;
  4. 公平锁:synchronized中的锁是非公平锁,ReentrantLock默认情况下也是非公平锁,但可以通过构造方法ReentrantLock(true)来要求使用公平锁(底层由Condition的等待队列实现)。
  5. 读写锁:提高多个线程读操作并发效率:需要一种机制来使得多个线程都只是进行读操作时,线程之间不会发生冲突

参考文章:synchronized的缺陷,Lock的诞生

locks包下的类结构图如下
Java学习_多线程编程(上)(很全,上篇四万多字)_第29张图片
上面的结构图中,ReentrantLock是Lock接口的唯一实现类,但实际并不是,IDEA中Ctrl+H查看类实现树,Lock接口还有几个实现类,只不过这些实现类是作为其他类的内部类实现接口的。从这也看出了ReentrantLock的特殊,我们主要使用的也是ReentrantLock。
Java学习_多线程编程(上)(很全,上篇四万多字)_第30张图片
Lock接口提供的方法如下,这些功能足够我们使用了,ReentrantLock的方法就不看了
Java学习_多线程编程(上)(很全,上篇四万多字)_第31张图片

ReentrantLock使用也很简单,在需要同步的代码前调用lock()加锁,后加上unlock()解锁即可。同样的,锁也要是同一把,这里将锁设为static
为了确保不会因为异常、提前返回等原因没有解锁,建议将lock()放在try代码块中,unlock()方法放到finally块中。
Java学习_多线程编程(上)(很全,上篇四万多字)_第32张图片

成功同步,这里还可以看到输出结果是两线程交替执行,这是因为前面构造器中传入参数true后使用公平锁的原因,默认是false即非公平锁
Java学习_多线程编程(上)(很全,上篇四万多字)_第33张图片
非公平锁的情况,公平锁的目的时为了解决线程饥饿问题,但这样会使得并发的效率变低,实际上较少使用公平锁
Java学习_多线程编程(上)(很全,上篇四万多字)_第34张图片

AQS

  • 占坑

ReentrantReadWriteLock

  • 占坑

StampedLock

  • 占坑

⚽总结

锁机制可以保证线程安全三要素,所以synchronized和Lock都可以很好地保证线程安全

  1. 保障原子性:由于锁具有互斥性,就是说,一次只能够被一个线程持有,一个线程持有时,其他线程无法持有,因此能够保障原子性。
  2. 保障可见性:是通过写线程冲刷处理器缓存和读线程刷新处理器缓存来实现的,在java中,锁的获得隐含着刷新处理器这个动作,能够使得读线程在执行临界区代码之前(锁获得之后)可以将写线程对共享变量所作的更新同步到该线程执行处理器的高速缓存中,而锁的释放隐含着冲刷处理器缓存的动作,使得写线程对共享变量所做的更新能够被推送到执行处理器的高速缓存中,从而对线程同步。
  3. 保障有序性:对于对读线程来说,当写线程对共享变量操作并加锁时,由于锁具有原子性,加锁后,临界区的代码执行是一个原子性操作,对于读线程来说,由于具备可见性,因此可以说,对读线程来说,执行下来是有序的,所以保障有序性。但是在临界区内的代码执行中,不保障有序性,指令不保证不会重排序。

synchronized和Lock(主要是ReentrantLock)共同点:

  • 都可以保障线程安全的原子性、可见性、有序性
  • syn和ReentrantLock都是可重入锁
  • 看待并发的角度都是悲观锁

不同点:

  • syn是非公平锁,而ReentrantLock可以是公平锁也可以是非公平锁
  • Lock是一个接口,ReentrantLock是其实现类,即显示锁;而synchronized是Java的关键字,作用于JVM层面,即内置锁
  • 两者遇到异常时释放锁的机制不同,synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unlock()去释放锁,则很可能造成死锁现象,因此使用lock()时需要在finally块中释放锁
  • Lock可以判断锁的状态,而synchronized却无法办到
  • Lock多线程读读操作时不会冲突,而syn会

在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而竞争资源非常激烈是(既有大量线程同时竞争),此时lock的性能要远远优于synchronized,实际生产看需求使用。

volatile

volatile关键字(adj. 易变的;无定性的;)是Java语言提供了一种轻量的同步机制(其实早在上世纪70年代就被C语言用来处理MMIO(Memory-mapped I/O)带来的问题,不是Java首创的)用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

我们先来看多线程中没有任何保障产生的可见性问题
设置共享变量flag为false,在重写的run()内将flag设为true,为了让试验更加顺利在前面睡眠1秒再改变flag
Java学习_多线程编程(上)(很全,上篇四万多字)_第35张图片
创建一个子线程执行run()方法,主线程则在死循环中判断flag的值,如果flag为true就循环输出信息
Java学习_多线程编程(上)(很全,上篇四万多字)_第36张图片
结果如下,尽管子线程在后面将共享变量flag变为true,但是因为不可见性,主线程得不到子线程修改后的值,于是什么都没输出,至于为什么会发生这样的问题,我们需要先了解JMM
Java学习_多线程编程(上)(很全,上篇四万多字)_第37张图片

⚽JMM

JMM(Java Memory Model,Java内存模型)是Java用于屏蔽掉各种硬件和操作系统的内存访问差异和规定内存访问规则,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

不想看文字的可以看看下面的视频
Java高并发编程精髓Java内存模型JMM详解全集

特别注意:JVM内存模型和Java内存模型不是一回事,我们知道JVM的内存模型比如堆栈、方法区、常量池等等,
JMM的目的是为了解决Java多线程对共享数据的读写一致性问题,通过Happens-Before语义(后面讲)定义了Java程序对数据的访问规则,修正之前由于读写冲突导致的Cache数据不一致的问题。具体到Hotspot VM的实现,主要是由OrderAccess类定义的一些列的读写屏障来实现JMM的语义。
JMM大概的结构如下
Java学习_多线程编程(上)(很全,上篇四万多字)_第38张图片

JMM定义了8种操作(原子操作),虚拟机实现时保证这8中操作均为原子操作,以下为8中操作的介绍以及执行顺序:

  • lock(锁定):作用于主内存的变量,把一个变量标志为一个线程占有状态(锁定)
  • read(读取):作用于主内存的变量,将变量从主内存读取到线程的工作空间,以便后续load操作使用
  • load(载入):作用于工作空间的变量,将load操作从主内存得到的变量放入工作内存变量副本中
  • use(使用):作用于工作空间的变量,将工作空间中的变量值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的字节码指令时将执行这个操作。
  • assign(赋值):作用于工作内存的变量,把一个从执行引擎接收的值赋给工作空间的变量
  • store(存储):作用于工作内存的变量,把工作空间的一个变量传到主内存,以便后续write操作使用
  • write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量值放入主内存的变量中
  • unlock(解锁):作用于主内存的变量,把一个变量从一个线程的锁定状态解除,以便其他线程锁定
    Java学习_多线程编程(上)(很全,上篇四万多字)_第39张图片

Java内存模型还规定了执行上述8种基本操作时必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现(即不允许一个变量从主存读取了但是工作内存不接受,或者从工作内存发起会写了但是主存不接受的情况),以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。

  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。

  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。

  • 一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。

  • 一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。

  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。

  • 如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。

  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)。

参考文章:
Java内存模型(JMM)总结
Java内存结构(JVM)、Java内存模型(JMM)、Java对象模型区别

了解JMM后,我们就知道为什么会有这样的问题(画图太麻烦,这里找到一张差不多的图)

  1. flag(initFlag)的初始值为false,于是主线程(线程B)和子线程都从主内存中读取flag到各自的工作内存中,并创建flag副本,还记得子线程先睡眠1秒吗,这样就可以确保主线程拿的是flag的初始值
  2. 接着子线程将flag的值修改为true,主线程依然在死循环
  3. 子线程将修改后的值保存回主内存,问题就出在这里,两线程是不能直接通讯的,所以子线程修改完值也没法告诉主线程,而主线程工作内存中的flag副本一直在好好用着,也不会向主存中获取新值,所以产生了可见性问题
    Java学习_多线程编程(上)(很全,上篇四万多字)_第40张图片
    解决可见性问题一种方法可以加锁,锁机制保证了三要素,毫无疑问是可以解决的
    Java学习_多线程编程(上)(很全,上篇四万多字)_第41张图片
    Java学习_多线程编程(上)(很全,上篇四万多字)_第42张图片

两种锁都让主线程成功读取到修改后的flag并输出内容
Java学习_多线程编程(上)(很全,上篇四万多字)_第43张图片
另一种方法就是volatile关键字,为共享变量加上volatile关键字后,同样解决了不可见性(记得把锁去掉再试验)
Java学习_多线程编程(上)(很全,上篇四万多字)_第44张图片
Java学习_多线程编程(上)(很全,上篇四万多字)_第45张图片
继续看JMM,添加了volatile修饰之后,总线中会通过缓存一致性协议和lock指令让主线程处于监听状态,一直嗅探共享变量是否被改变,子线程修改flag后会立即同步回主内存,这时候会通知主线程将缓存行状态改为I(无效状态),即变量副本失效了,需要重新从主内存读取,这样就可以确保可见性了。如下图所示:

Java学习_多线程编程(上)(很全,上篇四万多字)_第46张图片

⚽指令重排序

编译器和处理器会在不改变程序执行结果的前提下,为了提高执行效率,会对既定的代码执行顺序进行指令重排序

为什么指令重排序可以提高性能?
简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是指令1还没有执行完,就可以开始执行指令2,而不用等到指令1执行结束之后再执行指令2,这样就大大提高了效率。
但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。
我们分析一下下面这个代码的执行情况:
a = b + c;
d = e - f ;
先加载b、c(注意,即有可能先加载b,也有可能先加载c),但是在执行add(b,c)的时候,需要等待b、c装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这降低了计算机的执行效率。
为了减少这个停顿,我们可以先加载e和f,然后再去加载add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。既然add(b,c)需要停顿,那还不如去做一些有意义的事情。
综上所述,指令重排对于提高CPU处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。
参考文章:重排序与happens-before

指令重排一般分为以下三种:

  • 编译器优化重排
    编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令并行重排
    现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
  • 内存系统重排
    由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
    Java学习_多线程编程(上)(很全,上篇四万多字)_第47张图片

指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。

看下面的例子,创建两个线程,当两线程执行完后打印x,y的结果
Java学习_多线程编程(上)(很全,上篇四万多字)_第48张图片

稍微思考一下就知道x,y的值只有三种情况

  1. thread1先执行完才执行thread2,x = b = 0; y = a = 1
  2. thread2先执行完才执行thread1,x = b = 1; y = a = 0
  3. thread1、thread2交替执行对齐,x = b = 1 ;y = a = 1

事实真的如此吗,我们可以创建个死循环,打印所有结果,完整代码如下

public class OrderingDemo {
     

    private volatile static int x = 0 ,y = 0;
    private static int a = 0 ,b = 0;

    public static void main(String[] args) {
     
        int count = 0;
        while (true) {
     
            count++;
            a = 0; b = 0; x = 0; y = 0;

            Thread thread1 = new Thread(new Runnable() {
     
                @Override
                public void run() {
     
                    a = 1;
                    x = b;
                }
            });

            Thread thread2 = new Thread(new Runnable() {
     
                @Override
                public void run() {
     
                    b = 1;
                    y = a;
                }
            });

            thread1.start();
            thread2.start();
            //join()是十分必要的,还记得它得作用吗,它能别的线程插队,别的线程执行完后才继续本线程的执行
            //这里就是确保thread1和thread2执行完后,main才执行
            //因为main作为线程也同样竞争资源,如果main先于两个子线程执行完,那毫无疑问x,y输出的都0,这样就达不到我们需要的效果
            try {
     
                thread1.join();
                thread2.join();
            } catch (InterruptedException e) {
     
                e.printStackTrace();
            }

            System.out.println(count + ":x=" + x + ",  y=" + y);
            if (x==0&&y==0)//当找到x=0,y=0的情况就退出,也可以替换查找其他的情况
                break;
        }
    }

}

先修改break语句,查找正常情况
执行到19次的时候就找到了x=1,y=0的情况,同时上面也输出了x=0,y=1的情况
在这里插入图片描述
查找x=1,y=1的情况时,发现了一个尴尬事情,因为线程执行太快,根本来不及切换就执行完了,所以这种情况甚至比x=0,y=0的情况还难出现,不过好在还是出现了
在这里插入图片描述
最后是正常情况下不会出现的x=0,y=0;但事实上出现了
Java学习_多线程编程(上)(很全,上篇四万多字)_第49张图片
如果程序执行顺序和代码编写顺序一致的话是不可能出现这种情况的,出现这种情况只有可能是x=b(y=a)先于a=1(b=1)执行了,看来底层确实对指令进行重排序了
对于单线程重排序是没有任何问题的,但是多线程就会产生很大的问题

我们可以使用锁来禁止指令重排序保障有序性
经过后面学习发现锁是不能禁止指令重排序的,但是可以保证有序性
Java synchronized 能防止指令重排序吗?为何双重校验单例模式要加上 volatile?
Java学习_多线程编程(上)(很全,上篇四万多字)_第50张图片
这里找了20万次没找到,原来十几万次就找到了,就不继续进行下去了,关于使用ReentrantLock有兴趣的自己试验
在这里插入图片描述
另一种方法则是使用volatile关键字修饰变量
在这里插入图片描述
也试了20多万次没出结果
在这里插入图片描述
有人看到这可能觉得有点小崩溃,多线程真是一个大坑,原子性容易理解,可见性看了JMM模型也能接受,但是有序性。。现在代码执行顺序甚至和程序编写顺序不一样!难道还得了解底层是如何实现指令重排序才能写出没有问题的代码吗?那这样程序员不仅要多掉十几根头发,而且并发编程的效率严重降低。
当然前辈们肯定提出了解决方法,他们提出了happens-before的概念,只要我们编写的程序符合其规则,就可以忽略底层指令重排序的细节,感谢他们帮我们掉的头发,如下

happens-before的概念最初由Leslie Lamport在其一篇影响深远的论文(《Time,Clocks and the Ordering of Events in a Distributed System》)中提出,有兴趣的可以google一下。JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。具体的定义为:

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
    这条是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!
  2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
    这条是JMM对编译器和处理器重排序的约束原则。正如前面所言,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。

参考文章:java内存模型以及happens-before规则

happens-before具体规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。

  • 锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
    如果线程1释放了某个锁,后续线程2请求了这个锁,那么线程1解锁前的写操作都对线程2可见。

  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读

  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C

  • start规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作

  • join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

  • 程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。

  • 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

参考文章:happens-before是什么?JMM最最核心的概念,看完你就懂了

⚽不保障原子性

特别注意volatile是不保障原子性的,我们让线程循环对count进行自增操作,最后看看count的值是什么,可以事先猜想,不出意外的话count最后应该是20000(循环次数大些,不然可能一个线程能在其时间片内执行完,看不出效果)
Java学习_多线程编程(上)(很全,上篇四万多字)_第51张图片
Java学习_多线程编程(上)(很全,上篇四万多字)_第52张图片

然而结果并不如我们所意,这里可以看出两个问题:自增操作不是一条语句执行完的、volatile确实保障不了原子性
在这里插入图片描述
关于自增操作,如果进行字节码反编译,可以看到是由多条汇编指令构成(图网上找的),所以也不难理解为什么输出结果不是20000
Java学习_多线程编程(上)(很全,上篇四万多字)_第53张图片
显然原子性问题可以用锁来保障,但既然volatile已经保障可见性和有序性,那么我们能不能和另一个单独保障原子性的机制一起使用来解决上面的问题呢,下面讲的Atomic类就可以实现

Atomic类

Atomic类是JUC下atomic包提供的一系列工具类。这些类可以保证多线程环境下,当某个线程在执行atomic的方法时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由JVM从等待队列中选择一个线程执行。Atomic类在软件层面上是非阻塞的,它的原子性其实是在硬件层面上借助相关的指令来保证的。

这些原子类可以分为四组

分组 原子类
基础数据型 AtomicInteger,AtomicLong,AtomicBoolean
数组型 AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray
字段更新型 AtomicLongFieldUpdater,AtomicIntegerFieldUpdater,AtomicReferenceFieldUpdater
引用型 AtomicReference,AtomicStampedReference,AtomicMarkableReference

这些原子类大同小异,我们以AtomicInteger为例,看看常用的方法

  • public AtomicInteger(int initialValue),构造器设置初始值

  • get() ,获取当前的值

  • getAndAdd(int newValue) ,增加指定的数据,返回变化前的数据

  • getAndDecrement() ,减少1,返回减少前的数据 ,功能上等价于n = n –

  • getAndIncrement() ,增加1,返回增加前的数据 ,等价n = n ++

  • getAndSet(int newValue), 设置指定的数据,返回设置前的数据

  • addAndGet(int newValue),增加指定的数据后返回增加后的数据

  • decrementAndGet() ,减少1,返回减少后的值,等价 n = --n

  • incrementAndGet() ,增加1,返回增加后的值,等价n = ++n

  • lazySet(int newValue) ,仅当get时才会set

  • compareAndSet(int expectedValue, int newValue) ,很重要的方法,跟expectedValue作对比,如果值一致就设置新值并返回true

知道了方法的简单使用,我们将其运用到上面的例子,虽然getAndIncrement() 和 incrementAndGet()都能直接完成任务,但我还是先用compareAndSet()来试验

compareAndSet()底层大致思路是从内存中获取共享变量的最新值(所以必须确保可见性和有序性),与参数expectedValu对比,如果一致就设置新值并返回true,反之亦然,估计还是有点懵逼,没关系看下面的代码

设置个死循环,变量n用于获取共享变量的最新值,变量m将n加一,下面的判断语句就是重点
比如现在n获取的值是10,假设现在途中资源被其他线程抢了,count的值变为了15,当进行compareAndSet()时,会从内存获取count最新值,那么n的值肯定过期的,count和n不一致,所以compareAndSet()设置新值失败返回false,并继续循环;
如果执行地很顺利,那么n的值和count获取的最新值一致,就设置新值将m赋给count并返回true,同时退出循环
那么这段代码的含义就很清楚了,就是循环直到成功设置新值,以很巧妙的思想保障了原子性
如果还是不明白也没关系,待回会在CAS中详细讲
Java学习_多线程编程(上)(很全,上篇四万多字)_第54张图片

输出结果如下

在这里插入图片描述
为什么要先讲compareAndSet(),因为有关数据变换的方法底层都是compareAndSet()或类似功能的方法实现,所以这些方法的思想是核心
拿getAndIncrement()为例查看源码

Java学习_多线程编程(上)(很全,上篇四万多字)_第55张图片
跟我们上面写的代码思想如出一辙,就是循环直到成功设置新值
Java学习_多线程编程(上)(很全,上篇四万多字)_第56张图片
底层还是compareAndSet()
Java学习_多线程编程(上)(很全,上篇四万多字)_第57张图片
现在我们就直接使用getAndIncrement()或incrementAndGet()了,如果不将结果返回给变量这两个方法效果是一样的Java学习_多线程编程(上)(很全,上篇四万多字)_第58张图片在这里插入图片描述
输出结果如下
在这里插入图片描述
细心的朋友会发现,AtomicInteger类的共享变量没有使用volatile关键字修饰,原子性是保障了,那可见性和有序性呢,AtomicInteger类全部囊括了?
没错,我们查看AtomicInteger内的源码,它的值就被volatile关键字修饰了
在这里插入图片描述
AtomicInteger的基本使用和作用就是这样,其他原子类的操作也差不多,自行了解

⚽CAS

CAS(Compare And Swap/Set,比较和替换/设置),连名字都和compareAndSet()一样,CAS不仅是原子类的底层实现,也是乐观锁的原理。

在大多数处理器的指令中,都会实现 CAS 相关的指令,这一条指令就可以完成“比较并交换”的操作,也正是由于这是一条(而不是多条)CPU 指令,所以 CAS 相关的指令是具备原子性的,这个组合操作在执行期间不会被打断,这样就能保证并发安全。由于这个原子性是由 CPU 保证的,所以无需我们程序员来操心。

CAS 有三个操作数:内存值 V、预期值 A、要修改的值 B。CAS 最核心的思路就是,仅当预期值 A 和当前的内存值 V 相同时,才将内存值修改为 B。
我们对此展开描述一下:CAS 会提前假定当前内存值 V 应该等于值 A,而值 A 往往是之前读取到当时的内存值 V。在执行 CAS 时,如果发现当前的内存值 V 恰好是值 A 的话,那 CAS 就会把内存值 V 改成值 B,而值 B 往往是在拿到值 A 后,在值 A 的基础上经过计算而得到的。如果执行 CAS 时发现此时内存值 V 不等于值 A,则说明在刚才计算 B 的期间内,内存值已经被其他线程修改过了,那么本次 CAS 就不应该再修改了,可以避免多人同时修改导致出错。这就是 CAS 的主要思路和流程。可以结合我们上面使用compareAndSet()的例子,就很容易理解CAS的流程了

利用 CAS 实现的无锁算法,就像我们谈判的时候,用一种非常乐观的方式去协商,彼此之间很友好,这次没谈成,还可以重试。CAS 的思路和之前的互斥锁是两种完全不同的思路,如果是互斥锁,不存在协商机制,大家都会尝试抢占资源,如果抢到了,在操作完成前,会把这个资源牢牢的攥在自己的手里。当然,利用 CAS 和利用互斥锁,都可以保证并发安全,它们是实现同一目标的不同手段。
参考文章:你知道什么是 CAS 吗?

CAS的优点:

  • 由于CAS是非阻塞的,可避免死锁,线程间的互相影响非常小
  • 没有锁竞争带来的系统开销,也没有线程间频繁调度的开销
  • 基于硬件指令实现,效率高

缺点

  • CPU开销大
    在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力,我们可以限制自旋循环次数
  • 不能保证代码块的原子性
    CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。
    这也是为啥我另举了例子来试验原子性问题,而不是直接拿卖票的例子来试验,卖票要线程安全地输出信息,原子类是管不到的,实际开发对于复杂的情况该用锁还是得用锁
  • 产生ABA问题,下面会讲

总的来说,对于资源竞争较少的情况,使用锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
对于资源竞争严重的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于锁。所以根据这些特性对于不同场景做出相应权衡

ABA问题

ABA问题是指在CAS操作中带来的潜在问题,我们知道对于一个要更新的变量A,我们提供一个它的旧值a和新值B ,如果变量A的值等于旧值那么更新B值成功, 否则失败。那么如果另一个线程已经修改过变量A后,又把值改回旧值a,这时该线程会作出如何反应,因为变量A现在确实等于旧值,尽管实际上被修改过了一次,该线程也会修改成功,这就是ABA问题,名字取得就很灵性:A->B->A
通俗来说就是你看到一杯水把它喝了,然后又把它接满,这时水的主人回来了,她并不知道这水有没有被动过
似乎ABA问题看起来不严重,有些场景是会造成严重的影响,我在知乎找到了一个例子,如下

小明在提款机,提取了50元,因为提款机问题,有两个线程,同时把余额从100变为50
线程1(提款机):获取当前值100,期望更新为50,
线程2(提款机):获取当前值100,期望更新为50,
线程1成功执行,线程2某种原因block了,这时,某人给小明汇款50
线程3(默认):获取当前值50,期望更新为100,
这时候线程3成功执行,余额变为100,
线程2从Block中恢复,获取到的也是100,compare之后,继续更新余额为50!!!
此时可以看到,实际余额应该为100(100-50+50),但是实际上变为了50(100-50+50-50)这就是ABA带来的问题
什么是ABA问题?

ABA问题的根本在于CAS在修改变量的时候,无法记录变量的状态,比如修改的次数,是否修改过这个变量。这样就很容易在一个线程将A修改成B时,另一个线程又会把B修改成A,造成CAS多次执行的问题。

我们看下面的例子
Java学习_多线程编程(上)(很全,上篇四万多字)_第59张图片

线程0真是涉世不深,可乐都被偷梁换柱也没发现
在这里插入图片描述

ABA问题的解决办法可以在变量前面追加时间戳(版本号),每次变量更新时把时间戳(版本号)加1,那么A-B-A就会变成1A-2B-3A。
Atomic包里就提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果都相等,则以原子方式将该引用和标志的值设为给定的更新值。用法跟之前没什么区别,只不过多了两个时间戳参数
Java学习_多线程编程(上)(很全,上篇四万多字)_第60张图片
平行宇宙的另一个线程1比较机灵,买好可乐后在瓶底下做好了记号

public static void main(String[] args) {
     

        //AtomicReference cola = new AtomicReference<>("未开的可口可乐");
        AtomicStampedReference<String> cola = new AtomicStampedReference<>("未开的可口可乐",0);
        Thread thread0 = new Thread(new Runnable() {
     
            @Override
            public void run() {
     
                try {
     
                    sleep(1000);
                } catch (InterruptedException e) {
     
                    e.printStackTrace();
                }
               if(cola.compareAndSet("未开的可口可乐","喝完的可口可乐",0,1)) {
     
                   System.out.println(currentThread().getName()+":看看瓶底做的记号");
                   System.out.println(currentThread().getName()+":睡了一会,起来快乐");
               } else{
     
                   System.out.println(currentThread().getName()+":看看瓶底做的记号");
                   System.out.println(currentThread().getName()+":嗯?谁换了我的可乐,算了凑合喝吧。。。艹! 谁放洁厕灵在里面");
               }

            }
        });
        Thread thread1 = new Thread(new Runnable() {
     
            @Override
            public void run() {
     
                cola.compareAndSet("未开的可口可乐","未开的百事可乐",0,1);
                System.out.println(currentThread().getName()+":百事可乐yyds,看我偷偷把标签换了");
                cola.compareAndSet("未开的百事可乐","未开的可口可乐",1,2);

            }
        });
        thread0.start();
        thread1.start();

    }

有了记号(时间戳),线程1免受一次喝百事可乐的灾难(无意冒犯,我两个都喝)
Java学习_多线程编程(上)(很全,上篇四万多字)_第61张图片

ThreadLocal

ThreadLocal早在JDK 1.2的版本中就存在了,有些地方叫做线程本地变量,也有些地方叫做线程本地存储,ThreadLocal为解决多线程程序的并发问题提供了一种新的思路,它通过为每个线程提供一个独立的变量副本解决了变量并发访问的冲突问题。在很多情况下,ThreadLocal比直接使用synchronized同步机制解决线程安全问题更简单,更方便,且结果程序拥有更高的并发性。需要注意的是ThreadLocal设计的初衷是为了能够在当前线程中有属于自己的变量,并不是为了解决并发或者共享变量的问题。

我找到了一个专门讲ThreadLocal的网站(连域名都是threadlocal),就没必要在这里学习了
一针见血 ThreadLocal

你可能感兴趣的:(java学习,多线程,java,锁,volatile,线程安全)