JUC基础理论


并发编程的基础

原子性、有序性、可见性

指令重排序

happens-before 规则

主要是指 动作之间的依赖

有传递性

几个子规则:

程序顺序规则、监视器规则、volatile 规则、start()规则、join 规则

as-if-serial 规则

无论如何重排序,单线程的执行结果不能被改变

主要的规则对象是单线程内部数据之间的依赖

JMM 内存模型

概念

Java内存模型(JMM)是一种抽象的概念,用于描述在Java程序中,一组线程如何通过共享内存进行交互。JMM并不真实存在,它是一种规范,规定了程序中变量在内存中的访问方式。

在JMM中,内存主要划分为两种类型:主内存和工作内存。主内存存储了所有的对象实例和静态变量,而工作内存存储了每个线程的局部变量、栈中的部分区域以及寄存器的内容。

JMM定义了一系列规则来规范线程之间的内存访问。其中最重要的规则是:当一个线程想要访问一个共享变量的值时,它必须先将其本地内存中的值更新到主内存中,然后再从主内存中读取该变量的值。这个过程被称为“主内存的一致性”。

JMM的作用主要是屏蔽底层硬件和操作系统的内存访问差异,实现平台一致性,使得Java程序在不同平台下能达到一致的内存访问结果。同时,JMM也规范了JVM如何与计算机内存进行交互,从而保证并发程序的正确性和可靠性。

在实践中,为了更好地利用JMM,程序员需要了解一些内存访问的规则和约束。例如,JMM允许编译器对指令进行重排序,但这种重排序必须符合原子性、可见性和有序性等规则。此外,程序员还需要注意避免出现数据竞争和死锁等问题,这些问题可能会导致程序的正确性受到影响。

读写屏障

Happen before、Happen After

volatile

volatile是Java虚拟机提供的轻量级的同步机制,具有以下特点:

  1. 保证可见性:volatile保证了多个线程对共享变量的操作是可见的。当一个线程修改了共享变量的值,其他线程会立即看到这个改变。
  2. 禁止指令重排:volatile通过禁止指令重排来保证顺序性。在多线程环境下,为了提高程序执行效率,编译器和处理器可能会对指令进行重新排序。但是,如果一个变量被volatile修饰,就禁止了指令重排,确保每个线程都能看到正确的操作顺序。

总的来说,volatile可以确保多个线程对共享变量的操作一致,避免了数据不一致的问题。但是它不能保证原子性,因此对于需要保证原子性的操作,还需要使用其他同步机制,如synchronized关键字或java.util.concurrent.atomic包中的原子类。

原理:

通过插入 StoreStore、StoreLoad、LoadStore、LoadLoad 屏障实现

不可变对象

不可变对象(Immutable object)是一种一旦创建后其状态就不能被修改的对象。在Java中,不可变对象包括String、基本类型的包装类(如Integer、Double等)等。
不可变对象对写并发有如下帮助:
1线程安全:不可变对象是线程安全的,因为它们不会被其他线程修改。因此,多个线程可以同时使用不可变对象,无需额外的同步措施。
2减少锁竞争:由于不可变对象的状态不能被修改,因此不需要使用锁来保护对它的访问。这减少了锁竞争的可能性,从而提高了程序的性能。
3缓存优化:由于不可变对象一旦创建后其状态就不能被修改,因此可以将它们用作缓存项。这是因为缓存项的值不会在缓存和使用之间发生改变,从而避免了因缓存项状态被修改而导致的缓存失效问题。
需要注意的是,虽然不可变对象有以上优点,但它们也有一些缺点。例如,创建新的不可变对象比创建可变对象需要更多的内存,因为每次状态改变都需要创建新的对象。因此,在设计并发应用时,应根据具体需求和性能要求来决定是否使用不可变对象

多线程基础

并发和并行

并发针对单核 CPU 而言,它指的是 多个任务交替执行,每个任务都会在一段时间内执行一部分,然后切换到另一个任务,因为单核 CPU 一次只能执行一个任务。并发的目的是提高系统的响应性和吞吐量,允许多个任务在同一个处理器上共享时间片。
并行针对多核 CPU 而言,它指的是多个任务真正同时执行,每个任务都有自己的处理器核心,它们可以在同一时刻执行不同的指令。并行的目的是提高计算能力和性能,允许多个任务同时处理,以加快任务完成的速度。
单核 CPU 只能并发,无法并行;换句话说,并行只可能发生在多核 CPU 中在多核 CPU 中,并发和并行通常会同时存在。多个任务可以在不同的核心上并行执行,并且每个任务内部可能也包含并发的逻辑,以处理不同的子任务。这样可以最大程度地提高系统的性能和响应性。

进程和线程

当一个程序在计算机上运行时,通常会创建至少一个进程。进程被认为是操作系统分配资源的最小单元,每个进程都拥有独立的内存空间和系统资源,包括文件句柄和网络连接等。操作系统通常使用进程来表示独立的应用程序实例。比如,你的计算机上可能同时运行着浏览器、文本编辑器、音乐播放器等多个进程。
每个进程至少包含一个线程,通常被称为主线程。线程被视为操作系统调度的最小单元,它们共享相同的进程内存空间和系统资源。在一个进程内,多个线程可以协同工作,执行不同的任务,共享数据。这种多线程的使用方式有助于提高程序的并发性和性能。例如,一个文字处理软件的进程可能包括一个主线程,用于处理用户界面响应,同时还有一个后台线程,负责自动保存文件。

守护线程和本地线程

守护线程,可以看作是为其他线程提供服务的辅助工具。它们通常执行一些后台任务,比如垃圾回收、定期检查、日志记录等。一个重要的特点是它们不会阻止JVM的退出。当所有的本地线程都执行完毕后,JVM会自动退出,不会等待守护线程。

本地线程,则是应用程序的主力军,执行着应用的核心逻辑。它们的存在会阻止JVM退出,因为只要还有本地线程在运行,JVM会继续工作。

至于如何创建它们,守护线程需要通过设置 setDaemon(true) 来告诉JVM它是守护线程。而本地线程则是默认的线程类型,不需要额外设置。

多线程通信

当我们处理线程通信时,通常有两种主要的实现方式,每种方式都有其独特的机制和优势:

共享内存: 这是一种常见的方式,多个线程可以访问同一个共享内存区域,通过读取和写入共享内存中的数据来进行通信和同步。在Java中,我们可以使用共享变量或共享数据结构来实现共享内存通信。例如,可以使用 volatile 关键字来确保共享变量的可见性,以及使用等待和通知机制,即 wait()notify() 方法,来实现线程之间的协作。这种方式适用于需要高效共享数据的场景,但需要谨慎处理数据竞争和同步问题。

消息传递: 另一种方式是消息传递,多个线程之间通过消息队列、管道、信号量等机制来传递信息和同步状态。这种方式通常涉及线程之间的显式消息发送和接收操作,使线程能够协调它们的工作。例如,我们可以使用信号量机制,通过获取和释放许可证来控制线程的访问。又或者使用栅栏机制,通过等待所有线程达到栅栏点来同步它们的执行。此外,锁机制也是一种重要的消息传递方式,通过获取和释放锁来实现线程之间的互斥和同步。消息传递的优点在于可以实现更松散的耦合,线程之间不需要直接共享内存,从而减少了潜在的竞争条件。

线程调度算法

在Java中,线程调度采用的是一种抢占式调度模型。这就像在一个抢夺战中,有较高优先级的线程将首先占用CPU资源。如果线程具有相同的优先级,那么Java虚拟机会随机选择一个线程来执行,以保持公平竞争的原则。一旦一个线程获得了CPU,它将一直运行,直到自愿放弃CPU资源,或者由于某些情况(比如等待I/O操作、等待锁等)被迫放弃CPU,从而让其他线程有机会执行。

这种抢占式调度模型的目标是确保具有较高优先级的线程在争夺CPU资源时具有优势,但仍然让较低优先级的线程有机会执行,以避免它们被永远地忽略。Java的多线程调度机制有助于平衡线程之间的竞争和公平性,从而提高了多线程程序的响应速度和效率。

锁原理

活锁与死锁,如何避免死锁

死锁是指多个线程相互等待对方释放资源,导致它们都无法继续执行下去。这是一种静止状态,这种情况会导致所有线程都被永久性地阻塞,没有一个线程能够继续执行。就像交通堵塞一样,没有车辆能够前进。

活锁是指多个线程不断地改变自己的状态以回应对方,但最终无法取得进展,导致线程不断重试相同的操作,却无法成功。这是一种运行时状态,线程在持续地执行,但任务不会向前推进。活锁通常发生在线程在避免冲突时不断改变状态,但却没有成功,就像两个人在狭窄的道路上不断让对方走,却无法通过一样。

饥饿是指一个或多个线程或进程由于某种原因无法获得所需的资源或执行机会,因此无法适时地执行。这是一种动态问题,通常由资源分配不合理或线程优先级设置不当等原因导致。在饥饿中,线程不一定被永久性地阻塞,但是它们可能长时间无法获得所需的资源。就像一个人在繁忙的自助餐厅排队等待很长时间,但一直无法获得

死锁的四个条件

1、互斥 不可破坏

2、请求与保持 可以破坏,一次性申请所有资源

3、不可剥夺 若申请不到需要的资源,则主动是否已占用的资源

4、循环等待 按照按顺申请资源来预防

死锁检测

银行家算法

避免死锁

死锁是多线程编程中的一种常见问题,它发生在两个或多个线程相互等待对方释放资源的情况下,导致程序无法继续执行。为了避免死锁,我们可以采用以下策略:
1**锁顺序: 定义一个固定的锁获取顺序,并要求所有线程都按照相同的顺序获取锁。这可以减少不同线程之间资源争夺的可能性。
2
超时机制: 在获取锁时,设置一个超时时间。如果超过指定时间仍然无法获取锁,线程应该释放已经持有的锁并重试,或者采取其他适当的措施。这有助于避免线程无限期地等待锁。
3
避免嵌套锁: 尽量避免在一个锁的持有期间再次尝试获取其他锁。如果确实需要获取多个锁,请确保获取的顺序是固定的,以减少死锁风险。
4
使用锁机制: 比如Java中的ReentrantLock**,它支持可中断的锁获取和条件等待,有助于避免死锁。

锁优化机制

是的,锁的优化机制是Java等编程语言中常见的一种提高并发性能的方法。锁的优化旨在减少锁的竞争,从而提高程序的性能。以下是一些常见的锁优化机制:
1偏向锁(Biased Locking):偏向锁是一种针对无竞争情况的锁优化机制。它通过消除无谓的获取锁和释放锁的操作,提高了程序的性能。偏向锁会记录哪个线程正在访问某个对象,并且后续的访问请求如果是同一个线程,就可以直接访问,而不需要加锁。
2轻量级锁(Lightweight Locking):轻量级锁是一种针对单线程访问的情况的锁优化机制。它通过使用标记位或者CAS操作来对共享资源进行加锁和解锁,避免了使用重量级锁时的上下文切换和内核态切换等开销。
3自旋锁(Spin Lock):自旋锁是一种非阻塞的锁机制,当线程无法立即获取锁时,它会持续检查锁是否被释放,直到获取到锁为止。自旋锁可以减少线程的上下文切换开销,但在锁持有时间较长的情况下,会浪费CPU资源。
4适应性自旋锁(Adaptive Spin Lock):适应性自旋锁是一种结合了自旋锁和阻塞锁的锁机制。在刚开始时,线程会采用自旋的方式来等待锁的释放,但随着时间的推移,如果锁仍然没有被释放,线程会逐渐切换到阻塞状态,从而减少CPU资源的浪费。
5分段锁(Segmented Locking):分段锁是一种针对共享资源过多的情况下的锁优化机制。它将共享资源分成多个段,每个线程只需要对其中一部分进行加锁和解锁操作,从而减少了锁的竞争和开销。
6乐观锁(Optimistic Locking):乐观锁是一种基于冲突检测的锁机制。它假设多个线程同时访问和修改同一个数据的概率较小,因此在读取数据时不会加锁,而是在提交修改时检测是否存在冲突。如果存在冲突,则进行回滚或重试操作。乐观锁适用于读操作较多的场景。
7锁粗化(Lock Coarsening):锁粗化是一种针对长时间持有锁的场景的优化策略。如果一个线程在短时间内需要连续多次加锁和解锁,那么可以将这些加锁和解锁操作合并成一个较大的加锁和解锁操作,从而减少了加锁和解锁的次数,提高了效率。
这些锁的优化机制都有各自的适用场景和优缺点,需要根据具体的业务需求和性能要求来选择合适的锁机制。

基础方法

CAS算法

ABA问题

解决办法:

AtomicStampedReference

1、加版本号

2、加时间戳

只能保证一个变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。


文章作者: 王利康
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 王利康 !
  目录