当前位置:网站首页 > 技术博客 > 正文

什么是java并发编程



并发编程已完结,章节如下:
Java 并发编程上篇 -(Synchronized 原理、LockSupport 原理、ReentrantLock 原理)
Java 并发编程中篇 -(JMM、CAS 原理、Volatile 原理)
Java 并发编程下篇 -(线程池)
Java 并发编程下篇 -(JUC、AQS 源码、ReentrantLock 源码)



  • 原子性 - 保证指令不会受到线程上下文切换的影响
  • 可见性 - 保证指令不会受 cpu 缓存的影响
  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响

1)退不出的循环

首先看一段代码:

 
  

首先 t1 线程运行,然后过一秒,主线程设置 run 的值为 false,想让 t1 线程停止下来,但是 t1 线程并没有停,分析如下图:
在这里插入图片描述
解决方法

  • 使用 volatile (易变关键字)
  • 它可以用来修饰成员变量和静态成员变量(放在主存中的变量),他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
 
  

2)可见性与原子性

上面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况。

  • 注意 synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是 synchronized 是属于重量级操作,性能相对更低。
  • 如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到 对 run 变量的修改了,想一想为什么?
  • 因为 printIn() 方法使用了 synchronized 同步代码块,可以保证原子性与可见性,它是 PrintStream 类的方法。

3)模式之两阶段终止

使用 volatile 关键字来实现两阶段终止模式。

 
  

4)模式之 Balking

Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做 了,直接结束返回,有点类似单例。

  • 用一个标记来判断该任务是否已经被执行过了
  • 需要避免线程安全问题
  • 加锁的代码块要尽量的小,以保证性能
 
  

1)指令重排

首先看一个例子:

 
  

指令重排简单来说可以,在程序结果不受影响的前提下,可以调整指令语句执行顺序。多线程下指令重排会影响正确性。

2)多线程下指令重排问题

首先看一段代码:

 
  

在多线程环境下,以上的代码 r1 的值有三种情况:
第一种:线程 2 先执行,然后线程 1 后执行,r1 的结果为 4
第二种:线程 1 先执行,然后线程 2 后执行,r1 的结果为 1
第三种:线程 2 先执行,但是发送了指令重排,num = 2 与 ready = true 这两行代码语序发生装换,


 
  

然后执行 ready = true 后,线程 1 运行了,那么 r1 的结果是为 0。

3)解决方法

volatile 修饰的变量,可以禁用指令重排,禁止的是加 volatile 关键字变量之前的代码重排序

volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
对 volatile 变量的写指令后会加入写屏障
对 volatile 变量的读指令前会加入读屏障

1)如何保证可见性

  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
 
  
  • 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
 
  

分析如图:
在这里插入图片描述

2)如何保证有序性

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
 
  
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
 
  

注意:volatile 不能解决指令交错
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读跑到它前面去。
而有序性的保证也只是保证了本线程内相关代码不被重排序

3)double-checked locking 问题

看如下代码:

 
  

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
  • 有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外
    但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码为:
 
  

其中

  • 17 表示创建对象,将对象引用入栈 // new Singleton
  • 20 表示复制一份对象引用 // 复制了引用地址
  • 21 表示利用一个对象引用,调用构造方法 // 根据复制的引用地址调用构造方法
  • 24 表示利用一个对象引用,赋值给 static INSTANCE

也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:
在这里插入图片描述
关键在于 0: getstatic 这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取 INSTANCE 变量的值 这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初 始化完毕的单例 对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效。

4)double-checked locking 解决

加volatile就行了。

 
  

如上面的注释内容所示,读写 volatile 变量操作(即 getstatic 操作和 putstatic 操作)时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:

  1. 可见性
    1. 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
    2. 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
  2. 有序性
    1. 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
    2. 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
  3. 更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性
 
  

2)线程对 volatile 变量的写,对接下来其它线程对该变量的读可见

 
  

3)线程 start 前对变量的写,对该线程开始后对该变量的读可见

 
  

4)线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)

 
  

5)线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过 t2.interrupted 或 t2.isInterrupted)

 
  
 
  

1)balking 模式习题
希望 doInit() 方法仅被调用一次,下面的实现是否有问题,为什么?

 
  

volatile 可以保存线程的可见性,有序性,但是不能保证原子性,doInit 方法没加锁,可能会被调用多次。
2)线程安全单例习题
单例模式有很多实现方法,饿汉、懒汉、静态内部类、枚举类,试着分析每种实现下获取单例对象(即调用 getInstance)时的线程安全,并思考注释中的问题

  • 饿汉式:类加载就会导致该单实例对象被创建
  • 懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建

实现1: 饿汉式

 
  

实现2: 饿汉式

 
  

实现3:懒汉式

 
  

实现4:DCL 懒汉式

 
  

实现5:静态内部类懒汉式

 
  

本章重点讲解了 JMM 中的

  1. 可见性 - 由 JVM 缓存优化引起
  2. 有序性 - 由 JVM 指令重排序优化引起
  3. happens-before 规则
  4. 原理方面
    1. volatile
  5. 模式方面
    1. 两阶段终止模式的 volatile 改进
    2. 同步模式之 balking

管程即 monitor 是阻塞式的悲观锁实现并发控制,这章我们将通过非阻塞式的乐观锁的来实现并发控制

如下代码,通过 synchronized 解决线程安全问题。

 
  

如上代码加锁会造成线程堵塞,堵塞的时间取决于临界区代码执行的时间,这使用加锁的性能不高,我们可以使用无锁来解决此问题。

 
  

1)cas

前面看到的 AtomicInteger 的解决方法,内部并没有用锁来保护共享变量的线程安全。那么它是如何实现的呢?
其中的关键是 compareAndSwap(比较并设置值),它的简称就是 CAS (也有 Compare And Swap 的说法),它必须是原子操作
在这里插入图片描述
如图所示,它的工作流程如下:
当一个线程要去修改 Account 对象中的值时,先获取值 preVal(调用get方法),然后再将其设置为新的值 nextVal(调用 cas 方法)。在调用 cas 方法时,会将 pre 与 Account 中的余额进行比较。



  • 如果两者相等,就说明该值还未被其他线程修改,此时便可以进行修改操作。
  • 如果两者不相等,就不设置值,重新获取值 preVal(调用get方法),然后再将其设置为新的值 nextVal(调用cas方法),直到修改成功为止。

注意:

  • 其实 CAS 的底层是 lock cmpxchg 指令(X86 架构),在单核 CPU 和多核 CPU 下都能够保证【比较-交换】的原子性。
  • 在多核状态下,某个核执行到带 lock 的指令时,CPU 会让总线锁住,当这个核把此指令执行完毕,再开启总线。这个过程中不会被线程的调度机制所打断,保证了多个线程对内存操作的准确性,是原子的 。

2)volatile

获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取 它的值,线程操作 volatile 变量都是直接操作主存。即一个线程对 volatile 变量的修改,对另一个线程可见。
注意
volatile 仅仅保证了共享变量的可见性,让其它线程能够看到新值,但不能解决指令交错问题(不能保证原子性)
CAS 是原子性操作借助 volatile 读取到共享变量的新值来实现【比较并交换】的效果



3)为什么无锁效率高

  • 无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。打个比喻:线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火,等被唤醒又得重新打火、启动、加速… 恢复到高速运行,代价比较大
  • 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。

4)CAS 的特点

结合 CAS 和 volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
  • CAS 体现的是无锁并发、无阻塞并发,请仔细体会这两句话的意思
    • 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
    • 但如果竞争激烈(写操作多),可以想到重试必然频繁发生,反而效率会受影响
  • AtomicInteger:整型原子类
  • AtomicLong:长整型原子类
  • AtomicBoolean :布尔型原子类

上面三个类提供的方法几乎相同,所以我们将以 AtomicInteger 为例子来介绍。
原子引用
原子数组
字段更新器
原子累加器
下面先讨论原子整数类,以 AtomicInteger 为例讨论它的api接口:通过观察源码可以发现,AtomicInteger 内部都是通过cas的原理来实现的。




 
  
  • AtomicReference:引用类型原子类
  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
  • AtomicMarkableReference :原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起。

1)AtomicReference

先看如下代码的问题:

 
  
 
  

2)ABA 问题

看如下代码:

 
  

主线程仅能判断出共享变量的值与最初值 A 是否相同,不能感知到这种从 A 改为 B 又改回 A 的情况,如果主线程希望:只要有其它线程【动过了】共享变量,那么自己的 cas 就算失败,这时,仅比较值是不够的,需要再加一个版本号。使用AtomicStampedReference来解决。

3)AtomicStampedReference

使用 AtomicStampedReference 加 stamp (版本号或者时间戳)的方式解决 ABA 问题。代码如下:

 
  

4)AtomicMarkableReference

AtomicStampedReference 可以给原子引用加上版本号,追踪原子引用整个的变化过程,如:A -> B -> A ->C,通过AtomicStampedReference,我们可以知道,引用变量中途被更改了几次。但是有时候,并不关心引用变量更改了几次,只是单纯的关心是否更改过,所以就有了AtomicMarkableReference 。

使用原子的方式更新数组里的某个元素

  • AtomicIntegerArray:整形数组原子类
  • AtomicLongArray:长整形数组原子类
  • AtomicReferenceArray :引用类型数组原子类

上面三个类提供的方法几乎相同,所以我们这里以 AtomicIntegerArray 为例子来介绍,代码如下:

 
  

使用原子数组可以保证元素的线程安全。

  • AtomicReferenceFieldUpdater // 域 字段
  • AtomicIntegerFieldUpdater
  • AtomicLongFieldUpdater

注意:利用字段更新器,可以针对对象的某个域(Field)进行原子操作,只能配合 volatile 修饰的字段使用,否则会出现异常

 
  

代码如下:

 
  

字段更新器就是为了保证类中某个属性线程安全问题。

1)AtomicLong Vs LongAdder

 
  

执行代码后,发现使用 LongAdder 比 AtomicLong 快2,3倍,使用 LongAdder 性能提升的原因很简单,就是在有竞争时,设置多个累加单元(但不会超过cpu的核心数),Therad-0 累加 Cell[0],而 Thread-1 累加Cell[1]… 最后将结果汇总。这样它们在累加时操作的不同的 Cell 变量,因此减少了 CAS 重试失败,从而提高性能。

LongAdder 类有几个关键域
public class LongAdder extends Striped64 implements Serializable {}
下面的变量属于 Striped64 被 LongAdder 继承。

 
  

1)使用 cas 实现一个自旋锁

 
  

2)原理之伪共享

其中 Cell 即为累加单元

 
  

下面讨论 @sun.misc.Contended 注解的重要意义
得从缓存说起,缓存与内存的速度比较
在这里插入图片描述
因为 CPU 与 内存的速度差异很大,需要靠预读数据至缓存来提升效率。缓存离 cpu 越近速度越快。 而缓存以缓存行为单位,每个缓存行对应着一块内存,一般是 64 byte(8 个 long),缓存的加入会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中,CPU 要保证数据的一致性,如果某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效。
在这里插入图片描述
因为 Cell 是数组形式,在内存中是连续存储的,一个 Cell 为 24 字节(16 字节的对象头和 8 字节的 value),因 此缓存行可以存下 2 个的 Cell 对象。这样问题来了: Core-0 要修改 Cell[0],Core-1 要修改 Cell[1]




无论谁修改成功,都会导致对方 Core 的缓存行失效,比如 Core-0 中 Cell[0]=6000, Cell[1]=8000 要累加 Cell[0]=6001, Cell[1]=8000 ,这时会让 Core-1 的缓存行失效,@sun.misc.Contended 用来解决这个问题,它的原理是在使用此注解的对象或字段的前后各增加 128 字节大小的padding,从而让 CPU 将对象预读至缓存时占用不同的缓存行,这样,不会造成对方缓存行的失效
在这里插入图片描述

3)add 方法分析

LongAdder 进行累加操作是调用 increment 方法,它又调用 add 方法。

 
  

第一步:add 方法分析,流程图如下
在这里插入图片描述
源码如下:

 
  

第二步:longAccumulate 方法分析,流程图如下:
在这里插入图片描述
源码如下:

 
  

4)sum 方法分析

获取最终结果通过 sum 方法,将各个累加单元的值加起来就得到了总的结果。

 
  

1)Unsafe 对象的获取

Unsafe 对象提供了非常底层的,操作内存、线程的方法,Unsafe 对象不能直接调用,只能通过反射获得。LockSupport 的 park 方法,cas 相关的方法底层都是通过Unsafe类来实现的。

 
  

2)Unsafe 模拟实现 cas 操作

 
  

3)Unsafe 模拟实现原子整数

 
  

本章重点讲解

  1. CAS 与 volatile
  2. juc 包下 API
    1. 原子整数
    2. 原子引用
    3. 原子数组
    4. 字段更新器
    5. 原子累加器
  3. Unsafe
  4. 原理方面
    1. LongAdder 源码
    2. 伪共享

问题提出,下面的代码在运行时,由于 SimpleDateFormat 不是线程安全的,有很大几率出现 java.lang.NumberFormatException 或者出现不正确的日期解析结果。

 
  
 
  

String类中不可变的体现

 
  

1)final 的使用

发现该类、类中所有属性都是 final 的

  • 属性用 final 修饰保证了该属性是只读的,不能修改
  • 类用 final 修饰保证了该类中的方法不能被覆盖,防止子类无意间破坏不可变性

2)保护性拷贝

但有同学会说,使用字符串时,也有一些跟修改相关的方法啊,比如 substring 等,那么下面就看一看这些方法是 如何实现的,就以 substring 为例:

 
  

发现其内部是调用 String 的构造方法创建了一个新字符串

 
  

构造新字符串对象时,会生成新的 char[] value,对内容进行复制 。这种通过创建副本对象来避免共享的手段称之为【保护性拷贝(defensive copy)】

1)简介

简介定义英文名称:Flyweight pattern. 当需要重用数量有限的同一类对象时,归类为:Structual patterns

2)体现

包装类
在JDK中 Boolean,Byte,Short,Integer,Long,Character 等包装类提供了 valueOf 方法。
例如 Long 的 valueOf 会缓存 -128~127 之间的 Long 对象,在这个范围之间会重用对象,大于这个范围,才会新建 Long 对象:

 
  

Byte, Short, Long 缓存的范围都是 -128~127
Character 缓存的范围是 0~127
Integer 的默认范围是 -128~127,最小值不能变,但最大值可以通过调整虚拟机参数 "-Djava.lang.Integer.IntegerCache.high "来改变
Boolean 缓存了 TRUE 和 FALSE
String 池
参考如下文章:JDK1.8关于运行时常量池, 字符串常量池的要点
BigDecimal、BigInteger





3)DIY 实现简单的数据库连接池

 
  

以上实现没有考虑:

  • 连接的动态增长与收缩
  • 连接保活(可用性检测)
  • 等待超时处理
  • 分布式 hash

1)设置 final 变量的原理

理解了 volatile 原理,再对比 final 的实现就比较简单了

 
  

字节码

 
  

final 变量的赋值操作都必须在定义时或者构造器中进行初始化赋值,并发现 final 变量的赋值也会通过 putfield 指令来完成,同样在这条指令之后也会加入写屏障,保证在其它线程读到它的值时不会出现为 0 的情况。

2)获取 final 变量的原理

  • 不可变类使用
  • 不可变类设计
  • 原理方面:final
  • 模式方面
    • 享元模式-> 设置线程池

版权声明


相关文章:

  • 构造函数和拷贝构造函数2025-08-26 11:30:04
  • 计算机系统组成及其工作原理2025-08-26 11:30:04
  • android 设置textview内容2025-08-26 11:30:04
  • clr错误8007000e怎么办2025-08-26 11:30:04
  • 面向对象编程c++程序2025-08-26 11:30:04
  • 消防安全的四懂四会和四个能力分别是什么2025-08-26 11:30:04
  • 计算机组成包括哪些2025-08-26 11:30:04
  • 算法个性化推荐解释2025-08-26 11:30:04
  • html导入外部js文件代码2025-08-26 11:30:04
  • cas单点登录前后端分离2025-08-26 11:30:04