• 《Java并发编程之美》


            首先说明文章来源:《Java并发编程之美》学习笔记 - 简书 (jianshu.com),博主写的很好,适合从宏观整理一下自己学的零碎知识点。

    一、并发编程基础知识点

    1、什么是线程?

           线程是进程中的一个实体,线程本身是不会独立存在的。进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,线程则是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的资源。

            操作系统在分配资源时是把资源分配给进程的,但是 CPU 资源比较特殊,它是被分配到线程的,因为要真正占用 CPU 运行的是线程,所以也说线程是 CPU 分配的基本单位。

            多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器和栈区域。

    • 程序计数器是一块内存区域,用来记录线程当前要执行的指令地址。
    • 需要注意的是,如果执行的是 native 方法,那么 pc 计数器记录的是 undefined 地址,只有执行的是 Java 代码时 pc 计数器记录的才是下一条指令的地址。
    • 每个线程都有自己的栈资源,用于存储该线程的局部变量,这些局部变量是该线程私有的,其他线程是访问不了的,除此之外栈还用来存放线程的调用栈帧。
    • 堆是一个进程中最大的一块内存,堆是被进程中的所有线程共享的,是进程创建时分配的,堆里面主要存放使用 new 操作创建的对象实例。
    • 方法区则用来存放 JVM 加载的类、常量及静态变量等信息,也是线程共享的。

    附图(来自javaGuide):可以直观了解->JavaGuide/memory-area.md at main · Snailclimb/JavaGuide · GitHub

    2、 线程三种创建方式的优缺点

            Java 中有三种线程创建方式,分别为:

    •         实现 Runnable 接口的 run 方法
    •         继承 Thread 类并重写 run 方法
    •         使用 FutureTask 方式

            使用继承方式的好处是方便传参,可以在子类里添加成员变量,通过 set 方法设置参数或者通过构造函数进行传递,而如果使用 Runnable 方式,则只能使用主线程里面被声明为 final 的变量。不好的地方是 Java 不支持多继承,而如果继承了 Thread 类,那么子类不能再继承其他类,而 Runnable 则没有这个限制。前两种方式都没办法拿到任务执行的返回结果,但是 FutureTask 方式可以。

     3、线程通知与等待

            Java 中的 Object 类是所有类的父类,鉴于继承机制,Java 把所有类都需要的方法放到了 Object 类里面,其中就包含通知与等待系列的函数wait() / wait(long timeout)当一个线程调用一个共享变量的 wait() 方法时,该调用线程会被阻塞挂起,直到发生下面几件事情之一才返回:

    •  其他线程调用了该共享对象的 notify() 或者 notifyAll() 方法
    •  其他线程调用了该线程的 interrupt() 方法,该线程抛出 InterruptedException 异常返回
    •  如果带有超时参数,没有在指定时间的 timeout ms 时间内被其他线程调用该共享变量的  notify() 或者 notifyAll() 方法唤醒,那么该函数还是会因为超时而返回
    •  不加参数的 wait() 方法内部就是调用了 wait(0)

            当线程调用共享对象的 wait() 方法时,当前线程只会释放当前共享对象的锁,当前线程持有的其他共享对象的监视器锁并不会被释放。

    notify()
    一个线程调用共享对象的 notify() 方法后,会唤醒一个在该共享变量上调用 wait 系列方法后被挂起的线程。一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的。这个被唤醒的线程还需要和其他线程一起竞争该锁,只有该线程竞争到了共享变量的监视器锁后才可以继续执行

    notifyAll()
    notifyAll() 方法会唤醒所有在该共享变量上由于调用 wait 系列方法而被挂起的线程

    4、等待线程执行终止的 join 方法

            Thread 类中的 join 方法可以用来等待多个线程全部加载完毕再汇总处理。

            线程 A 调用线程 B 的 join 方法后会被阻塞,当其他线程调用了线程 A 的 interrupt() 方法中断了线程 A 时,线程 A 会抛出 InterruptedException 异常而返回。

    5、让线程睡眠的 sleep 方法

            Thread 类有一个静态的 sleep() 方法,当一个执行中的线程调用了 Thread 的 sleep() 方法后,调用线程会暂时让出指定的执行权,也就是在这期间不参与 CPU 的调度,但是该线程所拥有的监视器资源,比如锁还是持有不让出的。指定的睡眠时间到了后该函数会正常返回,线程就处于就绪状态,然后参与 CPU 的调度,获取到 CPU 的资源后就可以运行了。

    6、让出 CPU 执行权的 yield 方法

            当一个线程调用了 Thread 类的静态方法 yield() 时,是在告诉线程调度器自己占有的时间片中还没有使用完的部分自己不想使用了,这暗示线程调度器现在就可以进行下一轮的线程调度。

            sleep() 和 yield() 方法的区别在于,当线程调用 sleep() 方法时调用线程会被阻塞挂起指定的时间,在这期间线程调度器不会去调度该线程。而调用 yield() 方法时,线程只是让出自己剩余的时间片,并没有被阻塞挂起,而是处于就绪状态,线程调度器下一次调度时就有可能调度到当前线程执行。

    7、线程中断

            Java 中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行,而是被中断的线程根据中断状态自行处理。

    • void interrupt() : 中断线程,当线程 A 运行时,线程 B 可以调用线程 A 的 interrupt() 方法来设置线程 A 的中断标志为 true 并立即返回。设置标志仅仅是设置标志,线程 A 实际并没有被中断,它会继续往下执行。
    • boolean isInterrupted() : 检测当前线程是否被中断,如果是返回 true,否则返回 false
    • boolean interrupted() : 检测当前线程是否被中断,如果是返回 true,否则返回 false,如果该方法发现当前线程被中断,则会清除中断标志,并且该方法是 static 方法,可以通过 Thread 类直接调用。

    8、线程上下文切换

            线程上下文切换时机有:当前线程的 CPU 时间片使用完处于就绪状态时,当前线程被其他线程中断时。

    9、线程死锁

    什么是死锁
            死锁是指两个或两个以上的线程在执行过程,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直等待而无法继续运行下去产生死锁的条件。

            死锁的产生必须具备以下四个条件:

    • 互斥条件:指线程对已经获取到的资源进行排它性使用,即该资源同时只由一个线程占用。如果此时还有其他线程请求使用该资源,则请求者只能等待,直至占有资源的线程释放该资源
    • 请求并持有条件:指一个线程已经持有了至少一个资源,但又提出了新的资源请求,而新资源已被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己已经获取的资源
    • 不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有在自己使用完毕后才由自己释放该资源
    • 环路等待条件:指在发生死锁时,必然存在一个线程一资源的环形链,即线程集合 {T0, T1, T2, ... , Tn} 中的 T0 正在等待一个 T1 占用的资源,T1 正在等待 T2 占用的资源,......Tn 正在等待已被 T0 占用的资源。

    如何避免线程死锁
            要想避免死锁,只需要破坏掉至少一个构造死锁的必要条件即可,但是目前只有 请求并持有 和 环路等待 条件是可以被破坏的

            资源的有序分配会避免死锁,因为资源的有序性破坏了资源的请求并持有条件和环路等待条件,因此避免了死锁。

    10、守护线程与用户线程

            Java 中的线程分为两类,分别为 daemon 线程(守护线程)和 user 线程(用户线程)。在 JVM 启动时会调用 main 函数,main 函数所在的线程就是一个用户线程,而垃圾回收线程则是守护线程

            守护线程和用户线程区别之一是当最后一个非守护线程结束时,JVM 会正常退出,而不管当前是否有守护线程,也就是说守护线程是否结束并不影响 JVM 的退出。言外之意,只要有一个用户线程还没结束,正常情况下 JVM 就不会退出

            创建守护线程的的方式是,设置线程的 daemon 参数为 true 即可

    11、ThreadLocal

            ThreadLocal 是 JDK 包提供的,它提供了线程本地变量,也就是如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题

            ThreadLocal 是一个 HashMap 结构,其中 key 就是当前 ThreadLocal 的实例引用,value 是通过 set 方法传递的值。ThreadLocal 变量在父线程中被设置值后,在子线程中是获取不到的。

    二、并发编程的其他基础知识

    2.1 为什么要进行多线程并发编程

            多核 CPU 时代的到来打破了单核 CPU 对多线程效能的限制。多个 CPU 意味着每个线程可以使用自己的 CPU 运行,这减少了线程上下文切换的开销,但随着对应用系统性能和吞吐量要求的提高,出现了处理海量数据和请求的要求,这些都会高并发编程有着迫切的需求。

    2.2 Java 中的线程安全问题

    • 共享资源:就是说该资源被多个线程所持有或者说多个线程都可以去访问该资源

            线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果的问题

    2.3  Java 中共享变量的内存可见性问题

            当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存。

            假如线程 A 和线程 B 使用不同的 CPU 执行,此时由于 Cache 的存在,将会导致内存不可见问题。

    2.4 synchronized

    2.4.1 synchronized 关键字介绍

            synchronized 块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当做一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为 内部锁,也叫做 监视器锁 。

            内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。

            另外,由于 Java 中的线程是与操作系统中的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而 synchronized 的使用就会导致上下文切换。

    2.4.2 synchronized 的内存语义(如何保证可见性

            进入 synchronized 块的内存语义是把在 synchronized 块内使用到的变量从线程的工作内存中清除,这样在 synchronized 块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。退出 synchronized 块的内存语义是把在 synchronized 块内对共享变量的修改刷新到主内存

            除了可以解决共享变量内存可见性问题外,synchronized 经常被用来实现原子性操作。另外请注意,synchronized 关键字会引起线程上下文切换并带来线程调度开销。

    2.5 volatile

            对于解决内存可见性的问题,Java 还提供了一种弱形式的同步,也就是使用 volatile 关键字。该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为 volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。

    2.6 Java 中的原子性操作

            所谓原子性操作,是指执行一系列操作时,这些操作要么全部执行,要么全部不执行,不存在只执行其中一部分的情况。

            线程安全性:即内存可见性和原子性。

    2.7 Java 中的 CAS 操作

            CAS 即 Compare and Swap,是 JDK 提供的非阻塞原子性操作,它通过硬件保证了比较 -- 更新操作的原子性。JDK 里面的 Unsafe 类提供了一系列的 compareAndSwap 方法。

    1. // 比如说下面这个
    2. boolean compareAndSwapLong(Object obj, long valueOffset, long expect, long update);

            其中 compareAndSwap 的意思是比较并交换。

            CAS 有四个操作数,分别为:对象内存位置、对象中的变量的偏移量、变量预期值和新的值。其操作含义是,如果对象 obj 中内存偏移量为 valueOffset 的变量值为 expect ,则使用新的值 update 替换旧的值 expect。这是处理器提供的一个原子性指令。

    ABA 问题
            CAS 操作有个经典的 ABA 问题。

            ABA 问题的产生是因为变量的状态值产生了环形转换,就是变量的值可以从 A 到 B,然后再从 B 到 A 。如果变量的值只能朝着一个方向转换,比如 A 到 B,B 到 C,不构成环形,就不会存在问题。JDK 中的 AtomicStampedReference 类给每个变量的状态值都配备了一个时间戳,从而避免了 ABA 问题的产生。

    2.8 Unsafe 类

    • JDK 的 rt.jar 包中的 Unsafe 类提供了硬件级别的原子性操作,Unsafe 类中的方法都是 native 方法,它们使用 JNI 的方式访问本地 C++ 实现库。

    2.9 Java 指令重排序

            Java 内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序。在单线程下重排序可以保证最终执行的结果与程序顺序执行的结果一致,但是在多线程下就会存在问题。

            重排序在多线程下会导致非预期的程序执行结果,而使用 volatile 修饰变量就可以避免重排序和内存可见性问题。

            写 volatile 变量时,可以确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。读 volatile 变量时,可以确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。

    2.10 伪共享

            为了解决主内存与 CPU 之间运行速度差的问题,会在 CPU 与主内存之间添加一级或多级高速缓冲器(Cache)。这个 Cache 一般是被集成到 CPU 内部的,所以也叫 CPU Cache 。

            在 Cache 内部是按行存储的,其中每一行称为一个 Cache 行。Cache 行是 Cache 与主内存进行数据交换的单位。

            由于存放到 Cache 行的是内存块而不是单个变量,所以可能会把多个变量存放到一个 Cache 行中。当多个线程同时修改一个缓存行里面的多个变量时,由于同时只能有一个线程操作缓存行,所以相比将每一个变量放到一个缓存行,性能会有所下降,这就是伪共享。

    2.11 锁的概述

    2.11.1 乐观锁与悲观锁

    • 悲观锁是指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态
    • 乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会对数据冲突与否进行检测

    2.11.2 公平锁与非公平锁

    根据线程获取锁的抢占机制,锁可以分为 公平锁 和 非公平锁

    • 公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。

    • 非公平锁则在运行时闯入,也就是先来不一定先得。
      ReentrantLock 提供了公平和非公平锁的实现

    • 公平锁:ReentrantLock pairLock = new ReentrantLock(true)

    • 非公平锁:ReentrantLock pairLock = new ReentrantLock(false) ,默认是非公平锁

    在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销

    2.11.3 独占锁与共享锁

            根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为 独占锁 和 共享锁 。

            独占锁保证任何时候都只有一个线程能得到锁,ReentrantLock 就是以独占方式实现的。共享锁则可以同时由多个线程持有,例如 ReadWriteLock 读写锁,它允许一个资源可以被多个线程同时进行读操作。

    • 独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读操作并不会影响数据的一致性,而独占锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取
    • 共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作

    2.11.4 可重入锁

    • 当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞,那么当一个线程再次获取它自己已经获取的锁时,如果不被阻塞,那么该锁就是可重入的。
      synchronized 内部锁是可重入锁。

            可重入锁的原理是在锁内部维护了一个线程标示,用来标示该锁目前被哪个线程占用,然后关联一个计数器,当计数器值为 0 时说明该锁没有被任何线程占用,当一个线程获取了该锁,计数器值会变为 1,这时其他线程再来获取锁时会发现锁的所有者不是自己而被阻塞挂起。但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己,计数器值就 + 1,当释放锁后,计数器值 - 1。当计数器值为 0 时,锁里面的线程标示被重置为 null ,这时候被阻塞的线程会被唤醒来竞争获取该锁。

    2.11.5 自旋锁

            由于 Java 中的线程是与操作系统中的线程一一对应的,所以当一个线程在获取锁失败后,会被切换到用户态而被挂起。当该线程获取到锁时又需要将其切换到内核状态而唤醒该线程。而从用户状态切换到内核状态的开销是比较大的,在一定程度上会影响并发性能。

            自旋锁则是,当前线程在获取锁时,如果发现锁已经被其他线程占有,它不马上阻塞自己,在不放弃 CPU 使用权的情况下,多次尝试获取(默认次数是 10,可以使用 -XX:PreBlockSpinsh 参数设置该值),很有可能在后面几次尝试中其他线程已经释放了锁。如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起。

            由此看来自旋锁是使用 CPU 时间换取线程阻塞与调度的开销,但是很有可能这些 CPU 时间白白浪费了。

    三、JUC 中的原子操作类、

    四、CopyOnWriteArrayList、

    五、JUC中锁原理、

    六、Java 并发包中的并发队列、

    七、ThreadPoolExecutor、

    八、 Java 并发包中线程同步器--线程协作

    以上几章建议所有学习之后再阅读:原文:《Java并发编程之美》学习笔记 - 简书 (jianshu.com)

  • 相关阅读:
    Servlet概念和Tomcat的安装配置
    Linux登陆配置虚拟机
    Maven
    spice VDAgent简介
    vue单页面应用使用 history模式路由时刷新页面404的一种可能性
    小米华为,化干戈为玉帛!
    day02IP地址详解以及简单的DOS命令
    python读取vivo手机截图,将满屏图片文件移动别的路径
    最新Justnews主题源码6.0.1开心版+社交问答插件2.3.1+附教程
    常用工具类之Spring-boot-configuration-processor的学习使用
  • 原文地址:https://blog.csdn.net/qq_43202405/article/details/126287776