java 高阶-并发探究01
并发探究 01
ps:
2024/10/21 2h
我对并发的了解-再解决线程安全的问题下-利用线程池来解决各种问题实现,高并发
之前的学习都是基于c++ 现在来系统的学习一下java的并发问题

理论基础
0.目录
0.什么是线程和进程
一个程序 在硬盘中存储着 就是一堆数据 在内存中加载后 便是进程 进程拥有四GB 然后会有一个线程执行工作
1.为什么需要多线程
并发处理: 通过在多线程中执行不同的任务,程序可以同时处理多个用户请求,提高应用程序的响应能力。
提高性能: 在多核处理器上,多线程程序可以真正实现并行处理,不同的线程可以同时在不同的CPU核心上执行,从而提高整个程序的执行速度。
2.多线程安全示例
如果多个线程对同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。
3.并发出现问题根源
操作共享资源
4.java解决并发
5.线程安全问题探究
6.线程安全实现
1.问题
- 多线程的出现是要解决什么问题的?
- 线程不安全是指什么? 举例说明
- 并发出现线程不安全的本质什么? 可见性,原子性和有序性。
- Java是怎么解决并发问题的? 3个关键字,JMM和8个Happens-Before
- 线程安全是不是非真即假? 不是
- 线程安全有哪些实现思路?
- 如何理解并发和并行的区别?
2.操作系统进行线程切换
一个程序 在硬盘中存储着 就是一堆数据 在内存中加载后 便是进程 进程拥有四GB 然后会有一个线程执行工作
CONTEXT:结构
当一个程序有很多线程的时候 CPU给每个线程都分配了运行时间
例如 线程1 -》线程2-》-》线程1
必须有一个结构保存了线程1的寄存器的值

1 | |
3.线程不安全示例
如果多个线程对同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。
以下代码演示了 1000 个线程同时对 cnt 执行自增操作,操作结束之后它的值有可能小于 1000。
1 | |
4.并发安全问题探究
cpu分时切换问题
线程不安全问题的出现是由于多个因素交织在一起,主要体现在可见性、原子性和有序性方面。
1.可见性
可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。
CPU缓存引起
1 | |
线程执行完毕了-但是数据没写入主存-被cpu打断-线程2开始执行
j=i–这个时候的线程值还是0(从主存读到缓存)
这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
2.原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
示例:
cnt++操作实际上分为读取、修改和写入三个步骤。如果两个线程同时执行这个操作,可能会出现如下情况:
- 线程A读取
cnt的值为0。- 线程B也读取
cnt的值为0。- 线程A将
cnt加1并写入1。- 线程B也将
cnt加1并写入1。- 最终结果变成了1,而不是预期的2。
3.有序性问题
编译器优化
概念:编译器和 CPU 优化可能导致指令执行顺序与代码书写顺序不一致,这种现象称为指令重排序。在多线程情况下,这种重排序可能会导致某个线程在读取共享变量时,未能获得最新的值。
示例:如果线程A在执行时对某个变量进行了修改,线程B在执行时可能读取的是该变量修改前的值。这种情况在缺乏合适的同步机制时尤为常见。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

Java Memory Model
Java 内存模型(Java Memory Model, JMM)是 Java 语言规范的一部分,定义了 Java 程序中变量的读写规则,特别是在多线程环境下,确保程序的可见性、原子性和有序性。JMM 是解决并发问题的重要基础
Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:
- volatile、synchronized 和 final 三个关键字
- Happens-Before 规则
理解的第二个维度:可见性,有序性,原子性
1.原子性
概念:原子性指操作要么完全成功,要么完全失败,不会被其他线程干扰。
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
1 | |
上面4个语句只有语句1的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
synchronized 关键字:将方法或代码块声明为 synchronized,保证在同一时刻只有一个线程可以执行该部分代码,从而确保对共享变量的操作是原子的。
1 | |
2.可见性
概念:可见性是指一个线程对共享变量的修改,其他线程能够及时看到。
解决方案:
- volatile 关键字:使用
volatile修饰的变量保证所有线程对该变量的读写都直接作用于主内存,而不是线程的工作内存。这样,当一个线程修改volatile变量时,其他线程会立即看到这个变化。
1 | |
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
volatile 确保所有线程读取 cnt 时,都能看到其他线程对 cnt 的最新写入值。
它避免了线程从自己的工作内存中读取缓存值,而是直接从主内存中读取变量的最新值。
cpu切换-导致的线程安全还是无法解决
3.有序性
概念:有序性指程序中操作的执行顺序可能与代码的书写顺序不同。编译器和 CPU 会优化代码,使其执行效率更高,但这可能导致线程看到的执行顺序与预期不一致。

Happens-Before规则
ps:该规则保证了代码的有序性
1.单一线程原则
在一个线程内,在程序前面的操作先行发生于后面的操作。
一个线程中的操作按照代码顺序执行。

2.锁规则
一个线程对锁的解锁操作 先行于下一个线程对同一锁的加锁操作。

3.volatile 变量规则
对 volatile 变量的写操作先行于后续对该变量的读操作。

4.线程启动规则
主线程对 Thread.start() 的调用先行于新线程的任何动作。

5.线程终止规则
线程的所有操作先行于线程的终止。
线程安全问题探究
一个类在可以被多个线程安全调用时就是线程安全的。
线程安全不是一个非真即假的命题,可以将共享数据按照安全程度的强弱顺序分成以下五类: 不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
1.不可变
不可变对象的状态一旦创建就不能改变,因此是最简单的线程安全形式。所有线程可以同时访问不可变对象而不需要担心数据竞争问题。
final 关键字修饰的基本数据类型
String
枚举类型
Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
2.绝对线程安全问题
绝对线程安全的类或对象在任何情况下都能确保线程安全,无论客户端程序员如何调用它,都不需要采取额外的同步措施。
例如:Java 标准库中的 ConcurrentHashMap,它提供了完整的线程安全保证,在多线程环境中使用时无需额外的同步。
3.相对线程安全问题
某个对象在大多数情况下是线程安全的,但在特定条件下,可能仍然会出现竞争条件。它允许在通常情况下,多个线程可以安全地访问对象或方法,但在某些特定场景下,需要开发者额外关注以避免问题。
在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。
例子
例子: Java 中的 Vector 类被认为是线程安全的,因为其所有的关键方法都进行了同步控制。
对于下面的代码,如果删除元素的线程删除了 Vector 的一个元素,而获取元素的线程试图访问一个已经被删除的元素,那么就会抛出 ArrayIndexOutOfBoundsException。
1 | |
如上-
1 | |
Vector 的 add 和 remove 操作本身是线程安全的,它们各自通过同步块控制了并发访问,但是它们之间没有同步。但问题在于,for 循环的遍历和删除是一个复合操作
线程A 开始遍历
Vector,i = 0,size()返回 100。线程B 在某一时刻删除或添加了元素,使
Vector的大小发生变化(比如减少或增加)。线程A 继续执行
remove(i)时,可能会发生以下两种情况:
- 索引越界:
remove(i)尝试访问一个已不存在的索引,触发ArrayIndexOutOfBoundsException。- 删除错误的元素:由于
remove操作会使元素向前移动,下一个i对应的元素可能已经发生了改变,导致删除了错误的元素。
如线程池执行任务-那一块的删除就会出现问题-出现线程安全问题
解决方法-手动同步
1 | |
4.线程兼容
线程兼容表示一个对象或方法本身不是线程安全的,但可以在多线程环境中安全使用,前提是开发者在外部使用正确的同步机制进行保护。换句话说,这种对象不是天然线程安全的,但可以通过外部措施使其安全。
例子: Java 中的 ArrayList 不是线程安全的,但如果你在多线程环境下使用时,在外部加锁进行同步,它也可以安全地工作。
1 | |
5.线程对立
示某个对象或方法在多线程环境中是危险的,它没有任何内置的线程安全保障,也无法通过外部的同步措施轻易解决这个问题。多个线程同时访问时,可能会导致不可预知的结果,甚至可能引发致命的错误。
例子: 假设一个对象依赖线程本地的状态或使用全局变量,而不加控制。这种设计会导致严重的线程冲突问题。比如,某个对象在多个线程间共享****,而对象内部大量使用非线程安全的全局变量来存储状态,这会导致竞态条件和错误的结果。
1 | |
虽然这个问题可以通过加锁来解决,但有些情况下,当对象依赖线程本地状态或使用全局变量时,光靠外部加锁并不能解决线程冲突问题。比如:
非线程安全的全局变量:即使你对某个方法加锁,如果多个线程共享的全局状态没有同步保护,仍然可能导致线程间的冲突。
对象的设计导致难以加锁:有些对象的操作流程过于复杂,单靠简单的加锁可能会导致性能问题,或者难以确保所有相关的操作都在锁的保护下完成。
线程安全的实现方法
互斥同步
实现线程安全的方法主要通过控制共享资源的访问方式来防止竞态条件。互斥方法通过加锁机制(如 synchronized、Lock)来确保同一时刻只有一个线程访问共享资源,从而避免冲突。选择合适的线程安全实现方法要根据具体应用场景和性能要求,常见的方法有:
- 锁机制(如
synchronized、ReentrantLock) - 原子类(如
AtomicInteger) - 读写锁(如
ReadWriteLock) - 线程局部变量(如
ThreadLocal)
1.互斥锁 Lock
互斥锁是一种保证线程安全的基本机制。通过加锁,确保某个临界区的代码在同一时间只能被一个线程执行,从而避免多个线程同时操作共享资源。
1 | |
ReentrantLock 是 Java 中常用的锁,允许线程多次进入锁定的代码段。
2.同步方法(Synchronized Method)
Synchronized 关键字是 Java 中最简单的实现互斥的方式。它自动加锁和解锁,确保同一时刻只有一个线程可以执行同步方法。
1 | |
synchronized 保证了隐式锁定当前对象实例 (this),从而确保了线程安全。
3.同步代码块 (Synchronized Block)
如果你不想整个方法都被锁住,而只是想对某一部分代码进行同步,可以使用同步代码块。在 Java 中,使用 synchronized 关键字对某个特定的对象或类进行加锁。
1 | |
这里使用了
synchronized(lock)对代码块进行加锁,而不是整个方法。这样可以更灵活地控制锁的粒度,提升程序性能。
4.原子类操作-非互斥
Java 提供了一些原子类,如
AtomicInteger、AtomicLong等,它们通过底层硬件指令保证线程安全,并且性能优于synchronized或显式的锁机制。使用这些类可以在不加锁的情况下实现线程安全的自增或自减操作。
类似于-redis的操作
1 | |
原子性操作-就是不可被打断的操作-当我+的时候,线程不会被切换(理论上)
5.线程局部变量-非互斥
ThreadLocal 是一种避免线程之间共享变量的机制。每个线程都维护一个自己的局部变量副本,这样就不需要使用锁来保证线程安全。
每个线程都会有自己的 threadLocalCount,不同线程之间的副本互不干扰,因此没有线程安全问题。
ThreadLocal 适用于数据只在线程内访问、不需要跨线程共享的场景。
1 | |
6.读写锁
对于那些读多写少的场景,使用 ReadWriteLock 可以提高并发性能。ReadWriteLock 提供了读锁和写锁:
- 读锁允许多个线程同时读取资源。
- 写锁则是独占的,确保只有一个线程可以进行写操作。
1 | |
7.无锁算法 非互斥
无锁算法通过使用底层的原子操作,如 CAS(Compare-And-Swap),来避免显式加锁。无锁算法可以提高并发性能,减少上下文切换的开销。
Java 的 ConcurrentHashMap、AtomicReference 就是使用无锁机制的例子。
compareAndSet() 是 CAS 操作,它不依赖锁来保证线程安全。
无锁算法适合高并发场景,减少了线程阻塞。
1 | |
while循环的存在是为了确保操作成功。如果多个线程同时访问共享变量,可能会有线程在compareAndSet期间修改了该变量的值,从而导致当前线程的 CAS 操作失败。这时,线程必须重新获取最新的值,再次尝试更新,直到成功为止。
非阻塞同步
互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。
互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。
1.Cas
CAS(Compare-And-Swap,比较并交换)是一种用于实现多线程同步的原子操作,它的主要特点是无锁的,并通过硬件指令直接支持,能够有效避免传统锁机制带来的性能开销。
CAS 操作包含三个参数:
- 内存位置:表示要操作的变量地址。
- 预期值(Expected Value):期望的当前值。
- 新值(New Value):希望更新为的新值。
无锁算法
1 | |
while循环的存在是为了确保操作成功。如果多个线程同时访问共享变量,可能会有线程在compareAndSet期间修改了该变量的值,从而导致当前线程的 CAS 操作失败。这时,线程必须重新获取最新的值,再次尝试更新,直到成功为止。
2.AtomicInteger
J.U.C 包里面的整数原子类 AtomicInteger,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作。
1 | |
以下代码是 incrementAndGet() 的源码,它调用了 unsafe 的 getAndAddInt() 。
1 | |
以下代码是 getAndAddInt() 源码,var1 指示对象内存地址,var2 指示该字段相对对象内存地址的偏移,var4 指示操作需要加的数值,这里为 1。通过 getIntVolatile(var1, var2) 得到旧的预期值,通过调用 compareAndSwapInt() 来进行 CAS 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。
可以看到 getAndAddInt() 在一个循环中进行,发生冲突的做法是不断的进行重试。
1 | |
解释了 CAS 操作的重试机制和自旋锁的工作原理。
类似于
1 | |
3.aba
如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。
无同步方案
要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。
1.栈封闭
多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。
2.线程共享

可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能。
1 | |

3.可重入代码
这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。