正文 Java并发 拾年之璐 V管理员 /2022年 /315 阅读 0706 ## 什么是线程和进程? - 进程是程序的一次执行过程,是系统运行程序的基本单位 - 线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的**堆**和**方法区**资源,但每个线程有自己的**程序计数器**、**虚拟机栈**和**本地方法栈**,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。 ## 描述线程与进程的关系,区别及优缺点? **线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。** ## 并发与并行的区别? - **并发**:两个及两个以上的作业在同一 **时间段** 内执行。 - **并行**:两个及两个以上的作业在同一 **时刻** 执行。 ## 为什么要使用多线程呢? 总体上来说 - 计算机底层 : 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。 - 当代互联网趋势 : 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。 从计算机底层来说 - 单核时代 : 主要是为了提高单进程利用 CPU 和 IO 系统的效率。 - 多核时代 : 为了提高进程利用多核 CPU 的能力 ## 使用多线程可能带来什么问题? 内存泄漏、死锁、线程不安全等等 ## 说说线程的生命周期和状态? Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态 - 初始状态 : 线程被构建 , 但是还没有调用start()方法 - 运行状态 - 阻塞状态 - 等待状态 - 超时等待状态 - 终止状态 ## 产生死锁的四个必要条件 1. 互斥条件:该资源任意一个时刻只由一个线程占用。 2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。 3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。 4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。 ## 如何预防和避免线程死锁? **如何预防死锁?** 破坏死锁的产生的必要条件即可 1. **破坏请求与保持条件** :一次性申请所有的资源。 2. **破坏不剥夺条件** :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。 3. **破坏循环等待条件** :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。 **如何避免死锁?** 借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。 ## 说说 sleep() 方法和 wait() 方法区别和共同点? - 两者最主要的区别在于:**`sleep()` 方法没有释放锁,而 `wait()` 方法释放了锁** 。 - 两者都可以暂停线程的执行。 - `wait()` 通常被用于线程间交互/通信,`sleep() `通常被用于暂停执行。 - `wait()` 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 `notify() `或者 `notifyAll()` 方法。`sleep() `方法执行完成后,线程会自动苏醒。或者可以使用 `wait(long timeout)` 超时后线程会自动苏醒。 ## 为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法? new 一个 Thread,线程进入了新建状态。调用 `start()`方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 `start()` 会执行线程的相应准备工作,然后自动执行 `run()` 方法的内容,这是真正的多线程工作。 但是,直接执行 `run()` 方法,会把 `run()` 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。 ## JMM 用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现 让 Java 程序在各种平台下都能达到一致的并发效果,JMM 规范了 Java 虚拟机 与计算机内存是如何协同工作,规定了一个线程如何以及何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。 计算机在高速的 CPU 和相对低速的存储设备之间使用高速缓存,作为内存和处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存之中。 在多处理器的系统中(或者单处理器多核的系统),每个处理器内核都有自己的高速缓存,它们有共享同一主内存(Main Memory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。 ## 并发编程的三个重要特性 **缓存**导致的**可见性问题**,**线程切换**带来的**原子性问题**,**编译优化**带来的**有序性问题**。 1. **原子性** : 一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。`synchronized` 可以保证代码片段的原子性。 2. **可见性** :当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。`volatile` 关键字可以保证共享变量的可见性。 3. **有序性** :代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。`volatile` 关键字可以禁止指令进行重排序优化。 ## 说说 synchronized 关键字和 volatile 关键字的区别 - **`volatile` 关键字**是线程同步的**轻量级实现**,所以 **`volatile `性能肯定比`synchronized`关键字要好** 。但是 **`volatile` 关键字只能用于变量而 `synchronized` 关键字可以修饰方法以及代码块** 。 - **`volatile` 关键字能保证数据的可见性,但不能保证数据的原子性。`synchronized` 关键字两者都能保证。** - **`volatile`关键字主要用于解决变量在多个线程之间的可见性,而 `synchronized` 关键字解决的是多个线程之间访问资源的同步性。** ## synchronized 线程代码执行在进入 synchronized 代码块时候会自动获取内部锁,这个时候其他线程访问时候会被阻塞,直到进入 synchronized 中的代码执行完毕或者抛出异常或者调用了 wait 方法,都会释放锁资源。在进入 synchronized 会从主内存把变量读取到自己工作内存,在退出的时候会把工作内存的值写入到主内存,保证了原子性。 ## **ReentrantLock** ReentrantLock 主要利用 CAS+AQS 队列来实现。它支持公平锁和非公平锁。 ## 锁机制(锁的种类) - 要不要锁住同步资源 - 锁 : **悲观锁** 自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。 **悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。** **Java中,synchronized关键字和Lock的实现类都是悲观锁。** - 不锁 : **乐观锁** 自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。**如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。** **乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。** **[拓展]CAS** CAS算法涉及到三个操作数: - 需要读写的内存值 V。 - 进行比较的值 A。 - 要写入的新值 B。 当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。 - 锁住资源失败,要不要阻塞 - 阻塞 - 不阻塞 - **自旋锁** - 为了让当前线程“稍等一下”,我们需让当前线程进行自旋,**如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。** - **自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。**如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。 - **适应性自旋锁** - 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。 - 多个线程竞争同步资源的流程 - 不锁资源 , 多个线程只有一个能修改资源成功 , 其他线程不断重试 : **无锁** - 同一个线程执行资源时自动获取资源 : **偏向锁** 指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。 - 多个线程竞争同步资源 , 没有获取到的线程自旋等待锁释放 : **轻量级锁** 当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。 - 多个线程竞争同步资源 , 没有获取到的线程自旋阻塞锁释放 : **重量级锁** 目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。  - 多个资源竞争时要不要排队 - 排 : **公平锁** 公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。 - 先尝试插队,插队失败再排队 : **非公平锁** 非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。 - 一个线程中的多个流程能不能获取同一把锁 - 能 : **可重入锁(递归锁)** 同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。  - 不能 : **非可重入锁** - 多个线程能不能共享同一把锁 - 能 : **共享锁** 该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。 - 不能 : **排它锁** 该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。 **JDK中的synchronized和JUC中Lock的实现类就是互斥锁。** ## JUC--原子变量 一个是 locks 包,一个是 atomic 包,它们可以解决原子性问题。 加锁是一种阻塞式方式实现 , 原子变量是非阻塞式方式实现 ### 原子类 原子类的原子性是通过 volatile + CAS 实现原子操作的 ### CAS **CAS(Compare-And-Swap) :比较并交换,该算法是硬件对于并发操作的支持。** CAS 是乐观锁的一种实现方式,他采用的是自旋锁的思想,是一种轻量级 的锁机制。 即每次判断我的预期值和内存中的值是不是相同,如果不相同则说明该内存值已经被其他线程更新过了,因此需要拿到该最新值作为预期值,重新判断。而该线程不断的循环判断是否该内存值已经被其他线程更新过了,这就是自旋的思想。 CAS 包含了三个操作数: ①内存值 V ②预估值 A (比较时,从内存中再次读到的值) ③更新值 B (更新后的值) **当且仅当预期值 A==V,将内存值 V=B,否则什么都不做。** #### **CAS的缺点** CAS 使用自旋锁的方式,由于该锁会不断循环判断,因此不会类似 synchronize 线程阻塞导致线程切换。但是不断的自旋,会导致 CPU 的消耗,在并发量大的时候容易导致 CPU 跑满。 #### **ABA 问题** ABA 问题,即某个线程将内存值由 A 改为了 B,再由 B 改为了 A。当另外一个线程使用预期值去判断时,预期值与内存值相同,误以为该变量没有被修改过而导致的问题。 解决 ABA 问题的主要方式,通过使用类似添加版本号的方式,来避免 ABA 问题。如原先的内存值为(A,1),线程将(A,1)修改为了(B,2),再由(B,2)修改为(A,3)。此时另一个线程使用预期值(A,1)与内存值(A,3)进行比较,只需要比较版本号 1 和 3,即可发现该内存中的数据被更新过了。 ### JUC常用类 - **ConcurrentHashMap** **放弃分段锁的原因** 1. 加入多个分段锁浪费时间 2. map在放入时竞争同一个锁的概率很小,分段锁反而会造成更新等操作的长时间等待 jdk8 放弃了分段锁而是用了 Node 锁,减低锁的粒度,提高性能,并使用 CAS操作来确保 Node 的一些操作的原子性,取代了锁。 put 时首先通过 hash 找到对应链表过后,查看是否是第一个Node,如果是,直接用 cas 原则插入,无需加锁。然后, 如果不是链表第一个 Node, 则直接用链表第一个 Node 加锁,这加 的锁是 synchronized。 - **CopyOnWriteArrayList** 将读取的性能发挥到极致,是完全不用加锁的,并且更厉害的是:写入也不会阻塞读取操作,只有写入和写入之间需要进行同步等待,读操作的性能得到大幅度提升。 所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,并不直接修改原有数组对象,而是对原有数据进行一次拷贝,将修改的内容写入副本中。写完之后,再将修改完的副本替换成原来的数据,这样就可以保证写操作不会影响读操作了。 - **CopyOnWriteArraySet** 基于 CopyOnWriteArrayList,不能存储重复数据. - **辅助类CountDownLatch** 使一个线程等待其他线程各自执行完毕后再执行。是通过一个计数器来实现的,计数器的初始值是线程的数量。每当一个线程执行完毕后,计数器的值就-1,当计数器的值为 0 时,表示所有线程都执行完毕,然后在闭锁上等待的线程就可以恢复工作了 - **辅助类CyclicBarrier** 是一个同步辅助类,让一组线程到达一个屏障时被阻塞,直到最 后一个线程到达屏障时,屏障才会开门 ## 多线程的实现方式 **1.继承Thread类,重写run方法 2.实现Runnable接口,重写run方法,实现Runnable接口的实现类的实例对象作为Thread构造函数的target 3.通过Callable和FutureTask创建线程 4.通过线程池创建线程** ## 高并发的情况下保证数据的一致性 1. 业务层面乐观锁CAS,使用版本号解决ABA问题,实际使用中使用时间戳,更新的时候把查出来的时间戳带上,如果更新失败可以自旋,获取最近值和时间戳,直到更新成功。 2. DB层面开启一个事务,然后select一行for update给这一行加上排它锁,再去更新行,然后提交,其他事务就会阻塞在select for update。 ## ThreadLocal **主要解决的就是让每个线程绑定自己的值,可以将`ThreadLocal`类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。** ### ThreadLocal 内存泄露问题 `ThreadLocalMap` 中使用的 key 为 `ThreadLocal` 的弱引用,而 value 是强引用。所以,如果 `ThreadLocal` 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,`ThreadLocalMap` 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 `set()`、`get()`、`remove()` 方法的时候,会清理掉 key 为 null 的记录。使用完 `ThreadLocal`方法后 最好手动调用`remove()`方法 ## 线程池 ### 使用线程池的好处 - **降低资源消耗**。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。 - **提高响应速度**。当任务到达时,任务可以不需要等到线程创建就能立即执行。 - **提高线程的可管理性**。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。 ### 重要参数 - **`corePoolSize` :** 核心线程数定义了最小可以同时运行的线程数量。 - **`maximumPoolSize` :** 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。 - **`workQueue`:** 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。 - **`keepAliveTime`**:当线程池中的线程数量大于 `corePoolSize` 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 `keepAliveTime`才会被回收销毁; - **`unit`** : `keepAliveTime` 参数的时间单位。 - **`threadFactory`** :executor 创建新线程的时候会用到。 - **`handler`** :饱和策略。关于饱和策略下面单独介绍一下 ### 饱和策略 - **`AbortPolicy`**: 抛出 `RejectedExecutionException`来拒绝新任务的处理。 - **`CallerRunsPolicy`**:调用执行自己的线程运行任务 , 如果执行程序已关闭,则会丢弃该任务。 - **`DiscardPolicy`:** 不处理新任务,直接丢弃掉 - **`DiscardOldestPolicy`:** 此策略将丢弃最早的未处理的任务请求 本文采用创作共用版权 CC BY-NC-SA 3.0 CN 许可协议,转载或复制请注明出处! -- 展开阅读全文 --