type
status
date
slug
summary
tags
category
icon
password
example-row
example-row
为什么需要多线程
CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用性能,平衡三者的速度差异,计算机体系结构、操作系统、编译程序都做出了策略,主要体现为:
- CPU增加了缓存,以均衡CPU与内存的速度差异。但可能导致
可见性
问题。
- 操作系统增加了进程和线程,以分时复用CPU,以均衡CPU与I/O设备的速度差异。但可能导致
原子性
问题。
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。但可能导致
有序性
问题。
可见性
一个线程对共享变量的修改,另外一个线程能够立刻看到。可见性问题由CPU缓存引起。
假若执行线程1的是CPU1,执行线程2的是CPU2。
当线程1执行
i = 10
这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。此时线程2执行
j = i
,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
原子性
一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。原子性问题由分时复用引起。
i += 1
需要三条 CPU 指令:- 将变量
i
从内存读取到 CPU寄存器;
- 在CPU寄存器中执行
i + 1
操作;
- 将最后的结果
i
写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
由于CPU分时复用(线程切换)的存在,线程1执行了第一条指令后,可能就切换到线程2执行,假如线程2执行了这三条指令后,再切换会线程1执行后续两条指令,这就将造成最后写到内存中的
i
值是2而不是3。有序性
程序执行的顺序按照代码的先后顺序执行。有序性问题由指令重排序引起。
上面代码定义了一个int类型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候不一定会保证语句1一定会在语句2前面执行,因为这里可能会发生指令重排序。
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 java 源代码到最终实际执行的指令序列,会按上面顺序分别经历重排序,其中1 属于编译器重排序,2 和 3 属于处理器重排序。
这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(
memory barriers
,intel
称之为 memory fence
)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。JAVA是怎么解决并发问题的: JMM(Java内存模型)
JMM本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:
volatile
、synchronized
和final
三个关键字
Happens-Before
规则。
java提供了
volatile
关键字来保证可见性。当一个共享变量被
volatile
修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过
synchronized
和Lock
也能够保证可见性,synchronized
和Lock
能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。在Java里面,可以通过
volatile
关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized
和Lock
保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然JMM是通过Happens-Before
规则来保证有序性的。Happens-Before 规则
- 程序顺序规则 (Program Order Rule):在一个线程内,按照程序代码顺序,前面的操作happens-before后面的操作。这意味着在单个线程内,代码是按照书写顺序执行的。
- 监视器锁规则 (Monitor Lock Rule):一个锁的解锁操作先行发生于后续对同一个锁的加锁操作。这意味着释放锁的线程的所有操作在后续获取同一个锁的线程看到之前是可见的。
- volatile变量规则 (Volatile Variable Rule):对一个volatile变量的写操作先行发生于后续对这个volatile变量的读操作。这意味着对volatile变量的修改对所有线程立即可见。
- 线程启动规则 (Thread Start Rule):在主线程中,调用Thread.start()方法会先行发生于新线程中的任何操作。这意味着主线程启动新线程后,所有的操作在新线程中都是可见的。
- 线程终止规则 (Thread Termination Rule):一个线程中的所有操作先行发生于其他线程检测到这个线程已经终止。这可以通过Thread.join()方法来检测,这意味着在调用join()方法后,主线程可以看到被等待线程的所有操作结果。
- 线程中断规则 (Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程检测到中断事件的发生。这意味着如果一个线程被中断,其他线程可以通过检测中断状态或抛出InterruptedException来感知这个中断。
- 对象终结规则 (Finalizer Rule):一个对象的构造函数的执行先行发生于这个对象的finalize()方法的执行。这意味着在finalize()方法中,可以看到对象构造完成后的状态。
- 传递性 (Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。
线程安全
一个类在可以被多个线程安全调用时就是线程安全的。可以将共享数据按照安全程度的强弱顺序分成以下五类: 不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
不可变
不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。多线程环境下,应当尽量使对象成为不可变,来满足线程安全。
不可变的类型:
- final 关键字修饰的基本数据类型。
- string
- 枚举类型
- Number部分子类。如
Long
和Double
等数值包装类型,BigInteger
和BigDecimal
等大数据类型。但同为 Number 的原子类AtomicInteger
和AtomicLong
则是可变的。
对于集合类型,可以使用
Collections.unmodifiableXXX()
方法来获取一个不可变的集合。比如:Collections.unmodifiableMap(map)
。绝对线程安全
对象或方法在任何情况下都可以安全地被多个线程同时使用,无需额外的同步机制。
特点:
- 内部已经实现了所有必要的同步。
- 无需使用者额外处理同步。
- 在任何多线程环境下都能保持数据一致性。
示例:
AtomicInteger
AtomicLong
ConcurrentHashMap
CopyOnWriteArrayList
相对线程安全
对象或方法在特定条件下是线程安全的,但在某些情况下需要使用者额外的同步措施来确保线程安全。
特点:
- 内部可能实现了一些同步机制,但不足以保证所有情况的线程安全。
- 使用者在进行并发写操作时需要进行同步处理。
- 在单线程或只读多线程环境下通常是安全的。
示例:
ArrayList
(需要同步)
HashMap
(需要同步)
LinkedList
(需要同步)
线程兼容
对象本身不是线程安全的,但可以通过外部同步机制使其在多线程环境下安全使用。
特点:
- 使用者必须始终确保在多线程访问时使用同步机制。
- 通过适当的同步措施,可以在多线程环境下安全地使用这些对象。
示例:
- 大多数Java集合类(通过
Collections.synchronizedList
等方法进行同步包装)。
- 自定义的非线程安全类。
线程对立
对象在多线程环境下无法安全使用,即使使用同步机制也很难确保线程安全。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。
特点:
- 通常设计为单线程使用。
- 在多线程环境下使用可能会导致不可预见的问题。
- 需要避免在多线程环境下使用这些对象。
示例:
SimpleDateFormat
(需要通过ThreadLocal
或其他机制来确保线程安全)。
- 非线程安全的单例模式实现。
线程安全的实现方法
互斥同步
- 使用
synchronized
关键字来锁定代码块或方法,确保同一时刻只有一个线程可以访问共享资源,其他线程需要等待锁释放后才能访问。
- Java提供了
java.util.concurrent.locks
包中的Lock接口及其实现类。如ReentrantLock,提供了比synchronized
更灵活的锁定方式,如可重入性、公平性等。
非阻塞同步
互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。
互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
- CAS
基于冲突检测的乐观并发策略: 先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。
乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是: 比较并交换(
Compare-and-Swap
,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。- 使用原子变量(Atomic Variables)
java.util.concurrent.atomic
包中的类如AtomicInteger
、AtomicLong
等,提供了一种无锁的线程安全方式。AtomicStampedReference
解决ABA问题
如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。
AtomicStampedReference
通过引入一个额外的标记(stamp)来跟踪值的变化,从而避免 ABA 问题。每次更新值时,标记也会更新,这样即使值本身没有变化,标记的变化也能反映出值曾经被修改过。下面是
AtomicStampedReference
使用示例:打印输出:
参考:
- 作者:黄x黄
- 链接:https://hxhowl.site/article/java-concurrent001
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章