0%

Java并发浅析

Java并发浅析面经。线程、线程池、悲观锁、乐观锁。

Java并发浅析

[1] 参考了《Java高并发程序设计》,但我觉得这本书写的很乱、章节间的逻辑不清晰(仅个人观点)

[2] 参考了JavaGuide的文章:java线程池学习总结

1 并发编程和JMM

了解并发的前因后果(为什么?提高性能;怎么做?多线程)

了解JMM的作用和原理

1.1 并行和并发是什么?

并行则指同一时刻能运行多个指令。并发是指宏观上在一段时间内能同时运行多个程序,单个时间片仍然只运行一个指令。

并行需要硬件支持,如多流水线、多核处理器或者分布式计算系统。

操作系统通过引入进程和线程,使得程序能够并发运行。

1.2 为什么要使用并发(多线程)?

其实多线程就是并发编程的实现方式,无论在单核还是多核cpu上

之所以需要并发还在于cpu的速度、寄存器、缓存、内存、硬盘IO的速度存在较大差别

为了更好的利用CPU的性能,就需要并发调度来实现

并发和并行它们最重要的两个目都是:1为了获得更好的性能;2业务模型确实需要多个执行的实体。

首先聊一聊:关于并行的两个定律:Amdahl (安达尔)定律Gustafson(古斯塔夫森) 定律

  • Amdahl强调:当串行比例一定时,加速比是有上限的,不管你堆叠多少个CPU 参与计算,都不能突破这个上限!
  • Gustafson定律关心的是:如果可被并行化的代码所占比重足够多,那么加速比就能 随着CPU的数量线性增长。

虽然一个说加速比存在上限、一个说加速比可以随CPU数量线性增长,但是说的都有道理。安达尔认为串行并行比例一定,仅增加CPU资源,性能并不会得到巨大提升。古斯塔夫森认为当并行代码足够多时,性能随CPU数量正比增长。所以这其实就说明了多核CPU的并行是一种提升效率的手段、但提高代码的并发量同样是十分重要的。这也是为什么要并发编程、尽可能的提高单核CPU的利用效率的原因

而并发编程的实现手段其实就是进程和线程,多线程进行切换轮流获取CPU的资源,以实现CPU利用率的最大化!!并发编程极大的提高了代码运行的效率、但也会带来线程安全的问题,所以学习并发编程也要懂如何避免和处理线程安全的问题。

1.3 如何避免线程安全的问题

为了更高效的运行代码,所以需要并发编程技术,并发技术的实现就是利用多线程、轮流调度和执行cpu的资源。而并发编程时,极有可能照成线程安全的问题,(即多个线程同时对一个内存区域操作,例如堆区,造成读取数据不一致的问题),所以我们需要解决并发编程时造成的线程安全问题!!简略的讲一下下面5种方式:

  • 1 方式一:每一个线程,在堆区分配一份区域,每个线程自己认领一份,只能修改自己对应区域的数据。(数据还是在堆区,被线程认领了)这就是ThreadLocal的基本原理

  • 2 方式二:线程私有的栈区域,存取自己的数据(虚拟机栈

  • 3 方式三:比如final设置为常量,只能读取不能操作。

  • 4 方式四:加锁,想要超过公共的数据、需要先获得锁(锁的类型有多种)悲观锁

  • 5 方式五:CAS(其中可能出现ABA问题),适合于并发量不高的情况,也就是数据被意外修改的可能性较小的情况。这就是乐观锁的原理

1.4 Java内存模型

1.4.1 JMM介绍

JMM(Java Memory Model)是为了保证多个线程可以有效正确地协同地工作,实现在各种平台上都可以达到一致地内存访问的效果。

JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程有自己的工作内存(Working Memory),线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。

(这里的主内存工作内存是一种逻辑的抽象,不对应具体的内存区域)JMM通过这种主内存和工作内存的机制来解决多线程对共享数据读写一致性的问题。

Java Memory Model(Java内存模型), 围绕着在并发过程中如何处理可见性、原子性、有序性这三个特性而建立的模型。

  • 原子性:原子性是指一个操作是不可中断的。即使是在多线程的环境下,一个操作一旦开 始,就不会被其他线程干扰。

  • 可见性:可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。

  • 有序性:程序执行时基于性能的考虑,对指令进行重排。多线程并发执行时可能出现有序性的问题

1.4.2 volatile关键字保证可见性【重点】

Java提供了volatile关键字来保证可见性。 当一个共享变量被volatile修饰时,能保证修改的值会立即更新到主存,当有其他线程需要读取时,它会去主内中读取新的值。

volatile在汇编代码层面的实现原理:“加入volatile关键字时,汇编代码会多出一个lock前缀指令”,

  • 1)lock前缀指令实际上相当于一个内存屏障,,它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;

  • 2)它会强制将对缓存的修改操作立即写入主存;

  • 3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

1.4.3 as-if-serial 和 happens-before原则【重点】

as-if-serial 保证单线程程序的执行结果不变,happens-before 保证正确同步的多线程程序的执行结果不变。这两种语义的目的都是为了在不改变程序执行结果的前提下尽可能提高程序执行并行度。

  • as-if-serial原则

    • 不管怎么重排序,单线程程序的执行结果不能改变,编译器和处理器必须遵循 as-if-serial 语义。
  • Happen-Before原则——先行发生原则

    • 程序顺序原则:一个线程内保证语义的串行性

    • volatile规则:volatile变量的写,先发生于读,这保证了 volatile变量的可见性

    • 锁规则:解锁 (unlock) 必然发生在随后的加锁(lock) 前

    • 传递性:A 先于B, B 先于C,那么A 必然先于C

    • 线程 的 start()方法先于它的每一个动作

    • 线程的所有操作先于线程的终结(Threadjoin())

    • 线程的中断(interrupt()) 先于被中断线程的代码

    • 对象的构造函数执行、结束先于finalize()方法

2 线程

1 线程:线程创建、多线程、线程调度、线程协作

2.1 线程的生命周期和状态(6种)

  • 新建状态NEW:创建后尚未启动。
  • 可运行状态RUNABLE:该线程可以被运行,其实包括了Ready和Running两种状态
    • 线程调用 start() 方法后开始运行,线程这时候处于 READY(准备)状态
    • READY准备状态的线程获得了 CPU 时间片后就处于 RUNNING(运行) 状态。
  • 阻塞状态(Blocked):请求锁而未得,进入阻塞状态,需要等待其他线程释放锁才能重新进入runnable状态
  • 等待状态(WAITING):当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态
  • 限期等待状态(TIMED_WAITING):无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。
  • 终止状态(TERMINATED):可以是线程结束任务之后自己结束,或者产生了异常而结束。
image-20210819170721435

2.2 线程的创建和启动

1 有三种创建线程的方法【重要】:

  • ① 继承 Thread 类并重写 run 方法。实现简单,但不符合里氏替换原则,不可以继承其他类。

  • ② 实现 Runnable 接口并重写 run 方法。避免了单继承局限性,编程更加灵活,实现解耦。

  • ③实现 Callable 接口并重写 call 方法。可以获取线程执行结果的返回值,并且可以抛出异常。

实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过new Thread(XX) 来调用。可以理解为任务是通过线程驱动从而执行的。

2 启动线程的start()和run()方法的区别【重要】

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。

但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。(执行run()并没有开启一个新的线程)

总结: 调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

2.3 线程之间的协作

wait() notify() notifyAll()

调用 wait() 使线程挂起,当其他线程的运行时调用 notify() 或者 notifyAll() 来唤醒该挂起的线程。它们都属于 Object 的一部分,而不属于 Thread。

wait() notify() 都只能用在同步方法或者同步控制块中使用,使用 wait() 挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。

await() signal() signalAll()

java.util.concurrent 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal() 或 signalAll() 方法唤醒等待的线程。

相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活。一般使用 Lock 来获取一个 Condition 对象。

join()和yield()

在线程中调用另一个线程的 join() 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束。

Thread.yield() 是一个静态方法,一旦执行,它会使当前线程让出CPU。声明了当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行。让出CPU并不表 示当前线程不执行了。当前线程在让出CPU后,CPU根据优先级调度给优先级高的线程。

sleep()

Thread.sleep(millisec) 方法会休眠当前正在执行的线程,millisec 单位为毫秒。

sleep() 可能会抛出 InterruptedException,因为异常不能跨线程传播回 main() 中,因此必须在本地调用时自行进行处理。

wait()和sleep()的区别【重要】

  • wait() 是 Object 的方法,而 sleep() 是 Thread 的静态方法;
  • wait() 会释放锁,sleep() 不会释放锁。
  • Wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。

2.4 ThreadLocal是什么?有什么用?

ThreadLocal是一个本地线程副本变量工具类。主要用于将私有线程和该线程存放的副本对象做一个映射,各个线程之间的变量互不干扰,在高并发场景下,可以实现无状态的调用,特别适用于各个线程依赖不同的变量值完成操作的场景。

简单说ThreadLocal就是一种以空间换时间的做法,在每个Thread里面维护了一个以开地址法实现的ThreadLocal.ThreadLocalMap,把数据进行隔离,数据不共享,自然就没有线程安全方面的问题了。

(数据在堆上面,然后每个线程分一小块自己特定的区域?)

3 线程池【重要】

3.1 为什么要使用线程池

池化技术相比大家已经屡见不鲜了,线程池、数据库连接池、Http 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。

为了避免系统频繁地创建和销毁线程,我们可以让创建的线程进行复用。将活跃的线程放入线程池中,方便取用。创建线程和关闭线程的流程变为了:从线程池中获得空闲线程和向池子归还线程

好处:

  • 提高响应速度、减少了创建新线程的时间

  • 降低资源消耗、重复利用线程池中的线程,不需要每次都创建

  • 便于线程的管理,ThreadPoolExecutor类方法中的参数

    • corePoolSize 核心池的大小
    • maximumPoolSize 最大线程数
    • keepAliveTime 当线程池线程数量超过corePoolSize时,多余的空闲线程的存活时间。 即,超过corePoolSize的空闲线程,在多长时间内,会被销毁。
    • workQueue: 任务队列,被提交但尚未被执行的任务。
    • handler: 拒绝策略。当任务太多来不及处理,如何拒绝任务。

3.2 建议使用ThreadPoolExecutor

线程池实现类 ThreadPoolExecutorExecutor 框架最核心的类。

ScheduledThreadPoolExecutor继承了ThreadPoolExecutor

3.2.1 ThreadPoolExecutor类介绍

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。
  • maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。(这个参数在等待队列满了之后发挥作用,判断是否使用拒绝策略)
  • workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

ThreadPoolExecutor其他常见参数:

  1. keepAliveTime:当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
  2. unit : keepAliveTime 参数的时间单位。
  3. threadFactory :executor 创建新线程的时候会用到。
  4. handler :饱和策略。关于饱和策略下面单独介绍一下。
线程池各个参数的关系

3.2.2 推荐使用 ThreadPoolExecutor 构造函数创建线程池

1《阿里巴巴 Java 开发手册》“并发处理”这一章节,明确指出线程资源必须通过线程池提供!

2《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险

Executors 返回线程池对象的弊端如下:

  • FixedThreadPoolSingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。
  • CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。

首先创建一个 Runnable 接口的实现类(当然也可以是 Callable 接口,我们上面也说了两者的区别。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
MyRunnable.java
import java.util.Date;
/**
* 这是一个简单的Runnable类,需要大约5秒钟来执行其任务。
* @author shuang.kou
*/
public class MyRunnable implements Runnable {

private String command;

public MyRunnable(String s) {
this.command = s;
}

@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " Start. Time = " + new Date());
processCommand();
System.out.println(Thread.currentThread().getName() + " End. Time = " + new Date());
}

private void processCommand() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

@Override
public String toString() {
return this.command;
}
}

编写测试程序,我们这里以阿里巴巴推荐的使用 ThreadPoolExecutor 构造函数自定义参数的方式来创建线程池。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
ThreadPoolExecutorDemo.java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class ThreadPoolExecutorDemo {

private static final int CORE_POOL_SIZE = 5;
private static final int MAX_POOL_SIZE = 10;
private static final int QUEUE_CAPACITY = 100;
private static final Long KEEP_ALIVE_TIME = 1L;
public static void main(String[] args) {

//使用阿里巴巴推荐的创建线程池的方式
//通过ThreadPoolExecutor构造函数自定义参数创建
ThreadPoolExecutor executor = new ThreadPoolExecutor(
CORE_POOL_SIZE,
MAX_POOL_SIZE,
KEEP_ALIVE_TIME,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(QUEUE_CAPACITY),
new ThreadPoolExecutor.CallerRunsPolicy());

for (int i = 0; i < 10; i++) {
//创建WorkerThread对象(WorkerThread类实现了Runnable 接口)
Runnable worker = new MyRunnable("" + i);
//执行Runnable
executor.execute(worker);
}
//终止线程池
executor.shutdown();
while (!executor.isTerminated()) {
}
System.out.println("Finished all threads");
}
}

可以看到我们上面的代码指定了:

  1. corePoolSize: 核心线程数为 5。
  2. maximumPoolSize :最大线程数 10
  3. keepAliveTime : 等待时间为 1L。
  4. unit: 等待时间的单位为 TimeUnit.SECONDS。
  5. workQueue:任务队列为 ArrayBlockingQueue,并且容量为 100;
  6. handler:饱和策略为 CallerRunsPolicy

Output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pool-1-thread-3 Start. Time = Sun Apr 12 11:14:37 CST 2020
pool-1-thread-5 Start. Time = Sun Apr 12 11:14:37 CST 2020
pool-1-thread-2 Start. Time = Sun Apr 12 11:14:37 CST 2020
pool-1-thread-1 Start. Time = Sun Apr 12 11:14:37 CST 2020
pool-1-thread-4 Start. Time = Sun Apr 12 11:14:37 CST 2020
pool-1-thread-3 End. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-4 End. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-1 End. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-5 End. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-1 Start. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-2 End. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-5 Start. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-4 Start. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-3 Start. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-2 Start. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-1 End. Time = Sun Apr 12 11:14:47 CST 2020
pool-1-thread-4 End. Time = Sun Apr 12 11:14:47 CST 2020
pool-1-thread-5 End. Time = Sun Apr 12 11:14:47 CST 2020
pool-1-thread-3 End. Time = Sun Apr 12 11:14:47 CST 2020
pool-1-thread-2 End. Time = Sun Apr 12 11:14:47 CST 2020
Copy to clipboardErrorCopied

3.2.3 线程池执行原理分析

分析上小节的执行结果

核心线程是是5,当循环创建5个线程并执行之后,新创建的线程将进入等待队列。使用的拒绝策略是CallerRimsPolicy策略,类似于一直等核心核心线程空出来,出来了等待的线程就上去。

即每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的5个任务中如果有任务被执行完了,线程池就会去拿新的任务执行。

执行过程分析:

  • 线程池会判断当前已创建的线程**是否小于 corePoolSize (核心线程数)**,如果小于,就会选择创建一个新的线程来执行该任务。
  • 当线程池中已创建的线程数等于核心核心线程数时,此时判断任务队列是否已满,如果未满,就加入等待队列
  • 如果等待队列已满,再判断最大线程数,如果小于直接新建线程执行任务。大于就执行拒绝策略

图解线程池实现原理

3.3 拒绝策略handler【重要】

线程池中的线程数已经用完了,无法继续为新任务 服务,同时,等待队列中也己经排满了,再也塞不下新任务了。拒绝策略即系统超负荷运行时的补 救措施

四种拒绝策略如下:

  • AbortPolicy策略:该策略会直接抛出异常,阻止该任务正常执行。【默认】

  • CallerRimsPolicy策略:调用线程直接运行新任务的run方法。这样做虽然不会丢弃新任务,但是,任务提交线程的性能极有可能会急剧下降。

  • DiscardOledestPolicy策略:丢弃任务队列中最先进入的任务。通常用于类似记录轨迹、偶尔丢失一些数据没有关系,烦希望最新的数据可以的得到保存。

  • DiscardPolicy策略:该策略默默地丢弃无法处理的任务,不予任何处理。通用用于期望保存旧数据的场景

3.4 线程池的几个方法对比

execute() VS submit()

  • execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
  • submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功(可以通过 Future 的 get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。)

shutdown() VS shutdownNow()

  • shutdown() :关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。
  • shutdownNow() :关闭线程池,线程的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。
    shutdownNow的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终

isTerminated() VS isShutdown()

  • isShutDown 当调用 shutdown() 方法后返回为 true。
  • isTerminated 当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true

3.5 几个常见的线程池

这几个类其实都是对ThreadPoolExecutor类的封装,不推荐使用哦

newFixedThreadPool(int nThreads)

  • 返回一个固定线程数量的线程池。该线程池中的线 程数量始终不变。
  • FixedThreadPool 使用无界队列 LinkedBlockingQueue(队列的容量为 Integer.MAX_VALUE)作为线程池的工作队列
  • 使用无界队列,不会拒绝任务,会导致内存溢出OOM

newSingleThreadExecutor()

  • 返回一个只有一个线程的线程池
  • 同样使用无界队列 LinkedBlockingQueue 作为线程池的工作队列,会导致OOM

newCachedThreadPool()

  • 该方法返回一个可根据实际情况调整线程数量的线程池。 线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。
  • CachedThreadPool允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。

newScheduledThreadPool(int corePoolSize)

  • 主要用来在给定的延迟后运行任务,或者定期执行任务。可以指定线程数量
  • 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。

3.6 线程池大小的确定

有一个简单并且适用面比较广的公式:

  • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • I/O 密集型任务(2N+1): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

如何判断是 CPU 密集任务还是 IO 密集任务?

CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。单凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。我们大多数的业务,因为要操作数据库、一般都是IO密集的任务,只有大量排序、计算圆周率小数点后位数这种任务可以认为是CPU密集的。

其实对于IO密集型类型的应用,网上还有一个公式:线程数 = CPU核心数/(1-阻塞系数)

引入了阻塞系数的概念,一般为0.8~0.9之间,如果取0.8就是5倍的核心数,如果取0.9就是10倍的核心数

4 悲观锁:synchronized关键字和ReentrantLock可重入锁

4.1 synchronized关键字介绍

功能:实现线程之间的同步,对同步的代码加锁,使得每一次、只有一个线程能进入同步块

三种使用方法(范围越来越大)

  • 指定加锁的对象:给指定的对象加锁

  • 直接作用于实例方法:对当前实例加锁

  • 直接所用于静态方法:对当前的类加锁

4.2 谈谈synchronized与ReentrantLock的区别?

  • 底层实现上来说

    • synchronized 是JVM层面的锁,是Java关键字,synchronized 的实现涉及到锁的升级和优化,具体为无锁、偏向锁、轻量级锁、自旋锁等。

    • ReentrantLock 是从jdk1.5以来提供的API层面的锁。

  • 从锁的对象来说

    • synchronized 可以给类、方法、代码块加锁;而 lock 只能给代码块加锁。
  • 从操作步骤来说

    • synchronized 不需要手动获取锁和释放锁,使用简单;

    • lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。

  • ReentrantLock具有更多得功能

    • ReentrantLock则可以设置中断,可通过trylock(long timeout,TimeUnit unit)设置超时方法;

    • ReentrantLock则即可以选公平锁也可以选非公平锁,通过构造方法new ReentrantLock时传入boolean值进行选择,为空默认false非公平锁,true为公平锁。

    • ReentrantLock通过绑定Conditiont条件结合await()/singal()方法实现线程的精确唤醒

4.3 Java虚拟机对锁优化(synchronized)

方案一:锁的升级,无锁——>锁偏向——>轻量级锁——>自旋锁

方案二: 锁消除

1 锁偏向

  • 如果一个线程获得了锁,那么 锁就进入偏向模式。当这个线程再次请求锁时,无须再做任何同步操作。

  • 对于几乎没有锁竞争的场合,偏向锁有比较好 的优化效果,因为连续多次极有可能是同一个线程请求相同的锁。

  • 而对于锁竞争比较激烈的场合,其效果不佳

2 轻量级锁

  • 如果偏向锁失败,虚拟机并不会立即挂起线程。而是使用轻量级锁。

  • 轻量级锁简单地将对象头部作为指针,指向持有锁的线程堆栈的内部, 来判断一个线程是否持有对象锁。如果线程获得轻量级锁成功,则可以顺利进入临界区。

  • 如果 轻量级锁加锁失败,则表示其他线程抢先争夺到了锁,那么当前线程的锁请求就会膨胀为重量级锁。

3 自旋锁

  • 锁膨胀后,虚拟机为了避免线程真实地在操作系统层面挂起,虚拟机还会在做最后的努力 — 自旋锁

  • 系统会进行一次赌注:它会假设在不久的将来,线程可以得到这把锁。

  • 因此,虚拟机会让当前线程做几个空循环(这也是自旋的含义),在经过若干次循环后,如果可以得到锁,那么就顺利进入临界区。

  • 如果还不能获得锁,才会真实地将线程在操作系统层面挂起。

4 锁消除

  • 锁消除是一种更彻底的锁优化。Java虚拟机在JIT编译时,通过扫描,去除不可能存在共享资源竞争的锁。通过锁消除,可以节省毫无意义的请求锁时间。

  • 比如, 你很有可能在一个不可能存在并发竞争的场合使用Vector。

    • 如果Vector内部所有加锁同步都是没有必要的。如果虚拟机检测到 这种情况,就会将这些无用的锁操作去除。

5 乐观锁和CAS

5.1 乐观锁与CAS算法

对于乐观锁,开发者认为数据发送时发生并发冲突的概率不大,所以读操作前不上锁。

到了写操作时才会进行判断,数据在此期间是否被其他线程修改。如果发生修改,那就返回写入失败;如果没有被修改,那就执行修改操作,返回修改成功。

乐观锁一般都采用 Compare And Swap(CAS)算法进行实现。顾名思义,该算法涉及到了两个操作,比较(Compare)和交换(Swap)。

CAS 算法的思路如下:

  • 包含三个参数C A S (V ,E ,N )。V 表示要更新的变量,E 表示预期值,N 表示新值。

  • 仅当V 值等于E 值时,才会将V 的值设为N 。

  • 如果V 值和E 值不同,则说明己经有其他线程做了更新,则当前线程什么都不做。最后,CAS 返回当前 V 的真实值。

5.2 ABA问题及解决方法简述

CAS 算法是基于值来做比较的,如果当前有两个线程,一个线程将变量值从 A 改为 B ,再由 B 改回为 A ,当前线程开始执行 CAS 算法时,就很容易认为值没有变化,误认为读取数据到执行 CAS 算法的期间,没有线程修改过数据。

juc 包提供了一个 AtomicStampedReference,即在原始的版本下加入版本号戳,解决 ABA 问题。

5.3 常见的Atomic类

简述常见的Atomic类

在很多时候,我们需要的仅仅是一个简单的、高效的、线程安全的++或者–方案,使用synchronized关键字和lock固然可以实现,但代价比较大,此时用原子类更加方便。 基本数据类型的原子类有:

Atomic整形类型有:

  • AtomicInteger 原子更新整形
  • AtomicLong 原子更新长整型
  • AtomicBoolean 原子更新布尔类型

Atomic数组类型有:

  • AtomicIntegerArray 原子更新整形数组里的元素
  • AtomicLongArray 原子更新长整型数组里的元素
  • AtomicReferenceArray 原子更新引用类型数组里的元素。

Atomic引用类型有

  • AtomicReference 原子更新引用类型
  • AtomicMarkableReference 原子更新带有标记位的引用类型,可以绑定一个 boolean 标记
  • AtomicStampedReference 原子更新带有版本号的引用类型

FieldUpdater类型:

  • AtomicIntegerFieldUpdater 原子更新整形字段的更新器
  • AtomicLongFieldUpdater 原子更新长整形字段的更新器
  • AtomicReferenceFieldUpdater 原子更新引用类型字段的更新器

5.4 简述Atomic类基本实现原理

以AtomicIntger 为例: 方法getAndIncrement:以原子方式将当前的值加1,具体实现为:

  1. 在 for 死循环中取得 AtomicInteger 里存储的数值
  2. 对 AtomicInteger 当前的值加 1
  3. 调用 compareAndSet 方法进行原子更新
  4. 先检查当前数值是否等于 expect
  5. 如果等于则说明当前值没有被其他线程修改,则将值更新为 next,
  6. 如果不是会更新失败返回 false,程序会进入 for 循环重新进行 compareAndSet 操作。

图片

5.5 悲观锁和乐观锁的区别【重要】

悲观锁:总是假设最坏情况,每次取数据时都认为其他线程会修改数据,所以都会加锁,当其他线程想要访问数据时,都要挂起等待。

乐观锁:总是认为不会产生并发的问题,每次去取数据的时候总认为不会有其他线程对数据进行修改,因此不会上锁,但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般使用CAS机制来实现!

悲观锁和乐观锁分别有不同的使用场景,显然在高并发的场合或者重试代价特别大的场合,线程竞争激烈,适合用悲观锁、避免出现线程安全问题。在很少发生线程安全问题的场合,适合用乐观锁的思想,能提高程序运行的效率。

6 AQS、并发容器

这部分不看了、直接说不知道吧

AQS 队列同步器是用来构建锁或其他同步组件的基础框架,它使用一个 volatile int state 变量作为共享资源,如果线程获取资源失败,则进入同步队列等待;如果获取成功就执行临界区代码,释放资源时会通知同步队列中的等待线程。

AQS是实现锁的关键。AQS有两种模式:

  • 独占模式表示锁只会被一个线程占用,其他线程必须等到持有锁的线程释放锁后才能获取锁,同一时间只能有一个线程获取到锁。

  • 共享模式表示多个线程获取同一个锁有可能成功,ReadLock 就采用共享模式。

-------------感谢阅读没事常来-------------