多线程

多线程

1. 什么是进程?什么是线程?

  • 进程是: 一个应用程序.
  • 线程是:一个进程中的执行场景/执行单元
  • 一个进程至少有一个线程

拿Java的HelloWorld来说:

  1. 对于Java程序来说,当在DOS命令窗口中运行以下代码:

    1
    2
    3
    4
    5
    public  class  Test {
    public static void main(String [] args){
    System.out.print("Hello World")
    }
    }

    回车之后。会先启动JVM,而JVM就是一个进程。

  2. JVM再启动一个主线程调用main方法(main方法就是主线程)。
    同时再启动一个垃圾回收线程负责看护,回收垃圾。

  3. 最起码,现在的java程序中至少有两个线程并发,一个是 垃圾回收线程,一个是 执行main方法的主线程。

2. 使用多线程有几种方法?

在Java中,创建多线程有几种主要的方式:

2.1 继承Thread类

这是最基本的创建线程的方式之一。你可以创建一个类,继承自Thread类,并重写其run()方法来定义线程的任务。然后创建该类的实例并调用start()方法来启动线程。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
javaCopy codeclass MyThread extends Thread {
public void run() {
// 线程执行的任务逻辑
}
}

public class Main {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start(); // 启动线程
}
}

2.2 实现Runnable接口

通过实现Runnable接口,可以将线程的任务逻辑与线程本身解耦,提高代码的灵活性。同样地,创建一个类,实现Runnable接口,并实现其run()方法来定义线程的任务。然后将该实现类的实例传递给Thread类的构造函数,并调用start()方法来启动线程。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
javaCopy codeclass MyRunnable implements Runnable {
public void run() {
// 线程执行的任务逻辑
}
}

public class Main {
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start(); // 启动线程
}
}

2.3 实现Callable接口

与Runnable接口类似,Callable接口也用于定义线程的任务,但它允许任务返回结果并抛出受检查的异常。可以使用ExecutorService的submit()方法来执行Callable任务。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
javaCopy codeimport java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

class MyCallable implements Callable<String> {
public String call() {
// 线程执行的任务逻辑
return "任务执行完成";
}
}

public class Main {
public static void main(String[] args) throws Exception {
MyCallable myCallable = new MyCallable();
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> future = executor.submit(myCallable);
String result = future.get(); // 获取任务执行结果
System.out.println(result);
executor.shutdown(); // 关闭线程池
}
}

2.4 使用线程池创建

2.4.1 线程池的参数

ThreadPoolExecutor类允许我们指定核心线程数、最大线程数、线程空闲时间等参数来创建线程池。

  • corePoolSize: 核心线程数量,决定是否创建新的线程来处理到来的任务
  • maximumPoolSize: 最大线程数量,线程池中允许创建线程地最大数量
  • keepAliveTime: 线程空闲时存活的时间
  • unit: 空闲存活时间单位
  • workQueue: 任务队列,用于存放已提交的任务
  • threadFactory: 线程工厂,用于创建线程执行任务
  • handler: 拒绝策略,当线程池处于饱和时,使用某种策略来拒绝任务提交

语法结构:

1
2
3
4
5
6
7
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)

2.4.2 线程池的五种状态

  1. RUNNING:线程池处于 RUNNING 状态时,接受新任务,并处理队列中的任务。此时,线程池中的工作线程可以执行任务。处于 RUNNING 状态的线程池可以通过 shutdown()shutdownNow() 方法转换为其他状态。
  2. SHUTDOWN:线程池处于 SHUTDOWN 状态时,不再接受新任务,但会处理队列中的任务。此时,调用 shutdown() 方法会使线程池转换到 SHUTDOWN 状态。
  3. STOP:线程池处于 STOP 状态时,不再接受新任务,不再处理队列中的任务,并且会尝试中断正在执行的任务。调用 shutdownNow() 方法会使线程池转换到 STOP 状态。
  4. TIDYING:线程池处于 TIDYING 状态时,所有的任务已经终止,同时工作线程数已经达到了核心线程数。当所有任务都已终止,工作线程数为 0 时,线程池会转换到 TIDYING 状态。TIDYING 状态是用来执行一些清理操作的,例如调用 terminated() 方法。
  5. TERMINATED:线程池处于 TERMINATED 状态时,线程池完全终止,不再执行任何任务。当线程池已经终止,且完成了清理操作后,会转换到 TERMINATED 状态。

这些状态在 ThreadPoolExecutor 类中由 volatile 修饰的 int 常量表示,用于控制线程池的行为和状态转换。通过调用 ThreadPoolExecutor 的不同方法,可以将线程池从一个状态转换到另一个状态,从而实现线程池的管理和控制。

####2.4.3 线程池的执行流程

  1. 如果workerCount < corePoolSize ==> 创建线程执行提交的任务

  2. 如果workerCount >= corePoolSize && 阻塞队列未满 ==> 添加至阻塞队列,等待后续线程来执行提交地任务

  3. 如果workerCount >= corePoolSize && workerCount < maxinumPoolSize && 阻塞队列已满 ==> 创建非核心线程执行提交的任务

  4. 如果workerCount >= maxinumPoolSize && 阻塞队列已满 ==> 执行拒绝策略

wokerCount: 需要处理的任务总数

corePoolSize: 核心线程

maxinumPoolSize: 最大线程数

2.4.3 线程池使用示例

以下是一个示例代码:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.kangkang;


import java.lang.ref.PhantomReference;
import java.util.concurrent.*;

public class TestThreadPool {
//线程参数
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;
//Keep alive time的时间单位 这里设置的是秒
private static final TimeUnit UNIT = TimeUnit.SECONDS;
//任务队列
private static final BlockingQueue<Runnable> BLOCKING_QUEUE = new LinkedBlockingQueue<>(QUEUE_CAPACITY);
//创建新线程的工厂
private static final ThreadFactory THREAD_FACTORY = Executors.defaultThreadFactory();
//拒绝策略 当提交任务无法被接受时的处理策略 这里使用的abortPolicy 会抛出RejectedExecutionException异常
public static final RejectedExecutionHandler REJECTED_EXECUTION_HANDLER = new ThreadPoolExecutor.AbortPolicy();



public static void main(String[] args) {
ThreadPoolExecutor threadPoolExecutor =
new ThreadPoolExecutor(CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, UNIT,BLOCKING_QUEUE,THREAD_FACTORY,REJECTED_EXECUTION_HANDLER);
try {
for (int i = 0; i < 1000000; i++) {

int finalI = i;
threadPoolExecutor.execute(() -> System.out.println(Thread.currentThread().getName() + "执行任务" + "执行次数" + finalI));
}
}catch (Exception e){
System.out.println("触发了拒绝策略");
e.printStackTrace();
}finally {
//表示让线程池完成当前任务
threadPoolExecutor.shutdown();
}




}
}

2.4.x 如何选择线程池?

阿里巴巴《Java开发手册》给我们的答案:

【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

说明:Executors 返回的线程池对象的弊端如下:
1) FixedThreadPoolSingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
2)CachedThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM(内存泄露)。

3. Java中的线程调用

3.1 常见的线程调用模型

  • 抢占式调度模型:
    那个线程的优先级比较高,抢到的CPU时间片的概率就高一些/多一些。
    Java采用的就是抢占式调度模型
  • 均分式调度模型:
    平均分配CPU时间片。每个线程占有的CPU时间片时间长度一样。
    平均分配,一切平等。
    有一些编程语言,线程调度模型采用的是这种方式。

3.2 Java中线程调用的方法

方法名 作用
int getPriority() 获得线程优先级
void setPriority(int newPriority) 设置线程优先级
  • 最低优先级1
  • 默认优先级是5
  • 最高优先级10

优先级比较高的获取CPU时间片可能会多一些。(只是权重会高,实际情况多少会有些出入)

Thread类中还提供了一些静态常量:

常量名 作用
MAX_PRIORITY 最高优先级,其值为10。线程被赋予最高优先级时,会优先于其他线程执行,但并不保证一定会先于其他线程执行完毕。
MIN_PRIORITY 最低优先级,其值为1。线程被赋予最低优先级时,会处于最低优先级队列,通常情况下会被较少地调度执行。
NORM_PRIORITY 默认优先级,其值为5。如果没有显式地为线程指定优先级,那么线程会被赋予默认的中等优先级。

两个线程之间的调度:

方法名 作用
yield() 使当前线程从执行状态(运行状态)变为可运行状态(就绪状态)。
join() 允许一个线程等待另一个线程执行完
  1. yield()方法: yield()方法是Thread类的一个静态方法,它使当前线程从执行状态(运行状态)变为可运行状态(就绪状态)。换句话说,它让当前线程暂停执行,让同等优先级的其他线程有机会执行。调用yield()方法并不会释放锁或者让线程进入阻塞状态,仅是一个对线程调度器的建议,因此不能保证线程一定会被调度。一般来说,使用yield()方法是为了让其他线程有机会执行,以提高系统的响应性。
  2. join()方法: join()方法是Thread类的一个实例方法,它允许一个线程等待另一个线程执行完成。调用join()方法的线程将会阻塞,直到被调用的线程执行完成或者指定的等待时间到期。join()方法通常用于在主线程中等待所有子线程执行完成,然后再继续执行后续逻辑。此外,还有join(long millis)join(long millis, int nanos)方法,允许设置等待时间。

4. 线程安全问题

4.1.什么时候数据在多线程并发的环境下会存在安全问题呢?

满足三个条件:

  • 条件1:多线程并发
  • 条件2:有共享数据
  • 条件3:共享数据有修改的行为

4.2 如何解决线程安全问题?

4.2.1 使用synchronized关键字

使用同步方法或同步代码块: 可以使用synchronized关键字来实现同步方法或同步代码块,确保多个线程不会同时访问共享资源,从而避免数据竞争和不一致性。

synchronized 语法

1
2
3
synchronized (lockObject) { //这个括号中的对象 必须是多个线程共享的数据的引用  这样才能发挥锁的作用
// 同步的代码块
}
4.2.1.1 synchronized 的使用场景
  1. 同步代码块: 使用synchronized关键字将代码块包裹起来,确保同一时间只有一个线程可以执行该代码块。需要提供一个对象作为锁,通常使用某个对象实例或者类的静态成员作为锁对象。

    示例代码:

    1
    2
    3
    synchronized (lockObject) {
    // 同步的代码块
    }
  2. 同步方法: 在方法的声明上使用synchronized关键字,使整个方法体成为一个同步代码块,确保同一时间只有一个线程可以执行该方法。同步方法的锁对象是当前对象的实例(对于静态方法则是当前类的Class对象)。

    示例代码:

    1
    2
    3
    public synchronized void synchronizedMethod() {
    // 同步的方法体
    }
  3. 同步类: 在类的声明上使用synchronized关键字,使整个类成为一个同步代码块,确保同一时间只有一个线程可以访问该类的任何同步方法或同步代码块。同步类的锁对象是该类的Class对象。

    示例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class SynchronizedClass {
    public void method1() {
    synchronized (SynchronizedClass.class) {
    // 同步的代码块
    }
    }

    public synchronized void method2() {
    // 同步的方法体
    }
    }

4.2.1.2 synchronized实现原理:

在Java中,每个对象都有一个对象头(Object Header),对象头中包含了一些与对象自身相关的信息,其中包括对象的哈希码、锁状态标志等。在使用synchronized关键字时,JVM会使用对象头中的锁状态来实现同步。

具体来说,当一个线程执行到synchronized代码块或方法时,它会尝试获取对象的锁。如果这个对象的锁状态为无锁状态(即没有任何线程持有锁),那么该线程就可以获取到锁,并将锁状态设置为锁定状态。如果这个对象的锁状态为锁定状态,并且是由其他线程持有的,那么当前线程就会被阻塞,直到持有锁的线程释放锁。

在Java中,锁状态通常被表示为一个标志位(1表示锁定状态,0表示无锁状态),因此synchronized关键字的底层原理其实就是通过对象头中的锁状态来实现线程同步的。

####4.2.2 使用volatile关键字

在多线程环境中,volatile关键字可以确保可见性,即一个线程对volatile变量的修改会立即被其他线程所感知,从而避免出现脏读或者数据不一致的问题。

假设我们有一个简单的场景:一个变量被两个线程访问和修改。其中,一个线程不断读取这个变量的值,另一个线程不断更新这个值。如果这个变量被声明为 volatile,那么任何一个线程对这个变量的修改都会立即反映给另外一个线程;反之,如果不使用 volatile,则一个线程修改的值可能对另一个线程不可见。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
javaCopy codepublic class VolatileExample {
// 使用volatile关键字修饰共享变量
private volatile static boolean flag = false;

public static void main(String[] args) throws InterruptedException {
// 线程1不断读取flag的值
new Thread(() -> {
while (!flag) {
// 这里不做任何事情,只是循环检查flag的值
}
System.out.println("线程1感知到flag的变化");
}).start();

// 保证线程1先运行
Thread.sleep(100);

// 线程2修改flag的值
new Thread(() -> {
flag = true;
System.out.println("线程2修改了flag的值");
}).start();
}
}

在这个例子中,flag 被声明为 volatile 类型。这意味着,当线程2修改了 flag 的值时,线程1几乎立即就能感知到 flag 值的变化,并跳出循环,输出提示信息。如果我们去掉 volatile 关键字,就不能保证线程1能够及时感知到 flag 的变化,甚至可能会导致线程1永远处于循环中,因为线程1可能会把 flag 的初值缓存在自己的工作内存中,而忽略主内存中 flag 的更新。

4.2.2.1 volatile 无法保证原子性

虽然volatile 保证了变量修改的可见性但是它不涉及锁定,因此不保证操作原子性

比如 i++这样的操作

i++ 这样的自增操作之所以不能仅通过 volatile 来保证线程安全,是因为 i++ 实际上是一个复合操作,包含了三个独立的步骤:读取变量的当前值、增加变量的值、写回新值到变量。这三个步骤不是原子性的,当多个线程同时执行这样的操作时,它们之间可能会互相干扰,导致数据不一致的问题。

来具体分析一下过程:

  1. **读取 (read)**:首先,线程读取 i 当前的值到自己的工作内存中。
  2. **增加 (increment)**:然后,线程在自己的工作内存中将 i 的值加1。
  3. **写回 (write)**:最后,线程将更新后的值写回到主内存中。

在多线程环境中,如果两个或多个线程几乎同时执行这个操作,它们可能都会在自己的工作内存中读取到 i 的相同值,然后各自增加1,并写回新值到主内存中。这样就会导致 i 实际上被增加了多次,但结果只增加了1次,因为多个线程读取和写回的操作互相覆盖了。

即使 i 被声明为 volatile,虽然每次写操作都会立即同步到主内存中,确保了可见性,但是由于 i++ 的复合操作性质,它不能保证在读-改-写这整个过程中的原子性。因此,在并发环境下,仅仅依靠 volatile 关键字是不足以使 i++ 操作成为线程安全的。

要安全地实现 i++ 操作,可以使用如下方法:

  • 使用 synchronized 关键字或锁(如 ReentrantLock)来确保每次只有一个线程可以执行这个操作。
  • 使用 AtomicInteger 这类的原子类,Java 并发包中提供了这种线程安全的数字操作类,它们可以保证此类操作的原子性。
4.2.2.2 volatile 的底层原理
  1. 内存可见性
  • 主内存与工作内存:在Java内存模型中,所有变量都存储在主内存中,而每个线程都有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存副本。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写主内存中的数据。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存来完成。
  • 可见性保证:当变量被声明为 volatile 后,线程对这个变量的修改会立即强制刷新到主内存中,同时,每次使用这个变量之前,都会从主内存中重新读取它的值,而不是使用工作内存中的缓存。这就保证了一个线程修改了某个 volatile 变量的值后,其他线程能够立即看到这一变化。
  1. 禁止指令重排序
  • 指令重排序:在不影响单线程程序执行结果的前提下,编译器和处理器为了优化程序性能可能会对指令序列进行重排序。但是,在多线程环境中,这种重排序可能会破坏线程安全。
  • 有序性保证:volatile 变量的另一个关键作用是禁止对其读写操作的指令重排序,确保在它之前的操作都不会被重排到它之后,它之后的操作也不会被重排到它之前。这样,即使在多线程环境中,也能保证基于 volatile 变量的操作顺序符合预期。
  1. 底层支持

实现 volatile 的底层支持主要依赖于处理器的内存屏障指令:

  • 内存屏障(Memory Barrier):是一种处理器指令,用于实现对内存操作的顺序限制。内存屏障有多种类型,每种类型都有其特定的作用,如确保某些特定操作的执行顺序,防止指令之间的重排序等。
  • 当写入一个 volatile 变量时,会在写操作后插入一条存储屏障(Store Barrier),确保对这个 volatile 变量的写操作对其他线程立即可见(通过将当前处理器缓存行的数据写回到系统内存)。
  • 当读取一个 volatile 变量时,会在读操作前插入一条加载屏障(Load Barrier),确保对这个 volatile 变量的读操作能够看到最新的值(通过无效化当前处理器缓存行,强制从系统内存中读取)。

4.2.3 使用Lock接口

Java提供了Lock接口及其实现类(如ReentrantLock),通过Lock接口可以实现更灵活和精确的锁定机制,避免了使用synchronized关键字可能出现的一些问题,例如死锁。

4.2.3.1 Lock的核心方法
方法名 作用
void lock() 获取锁。如果锁不可用,则当前线程将处于休眠状态直到锁被释放。
void lockInterruptibly() throws InterruptedException 如果当前线程未被中断,则获取锁。如果已经被中断,则抛出一个 InterruptedException
boolean tryLock() 尝试获取锁,如果锁可用立即返回 true,否则返回 false。该方法会立即返回,不会等待。
boolean tryLock(long time, TimeUnit unit) throws InterruptedException 如果锁在给定等待时间内变得可用,并且当前线程未被中断,则获取锁。
void unlock() 释放锁
Condition newCondition() 返回绑定到此 Lock 实例的新 Condition 实例。
4.2.3.2 Lock示例

下面是一个使用 ReentrantLock 类(Lock 接口的一个实现)的示例,展示了如何在代码中使用它来实现同步:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
javaCopy codeimport java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Counter {
private final Lock lock = new ReentrantLock(); // 创建一个 ReentrantLock 实例
private int count = 0;

public void increment() {
lock.lock(); // 在修改前获取锁
try {
count++;
} finally {
lock.unlock(); // 在修改后释放锁
}
}

public int getCount() {
return count;
}

public static void main(String[] args) {
Counter counter = new Counter();

// 创建并启动线程
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});

Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});

t1.start();
t2.start();

try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println("Final count is: " + counter.getCount());
}
}

synchronized 相比

  • 灵活性Lock 提供了更灵活的锁定操作,允许尝试非阻塞地获取锁、尝试可中断地获取锁以及在指定的等待时间内获取锁。
  • 公平性:某些 Lock 实现(如 ReentrantLock)允许你构建公平锁,这种锁会按照线程等待的顺序来获取锁。
  • 锁绑定多个条件Lock 允许一个锁关联一个或多个条件对象(Condition 类),这可以分开管理不同的等待线程集合,提供了类似 Object 监视器方法(waitnotifynotifyAll)的功能,但更灵活。

使用 Lock 还是 synchronized 取决于具体的应用场景和对灵活性、响应中断、定时锁等待、公平性锁定等的需求。

4.2.3.3 Lock的底层原理

Lock 接口的实现,尤其是 ReentrantLock,是基于 Java 并发包 java.util.concurrent 中的高级同步机制实现的。它的底层原理较为复杂,涉及到多种并发编程的基本概念,包括但不限于:CAS(Compare-And-Swap)操作、AQS(AbstractQueuedSynchronizer)框架、以及Java内存模型(JMM)。下面是对这些概念和 ReentrantLock 底层工作原理的简要说明:


  1. CAS(Compare-And-Swap)
  • 基础:CAS 是一种原子操作,用于实现变量的无锁编程。它包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。只有当内存位置的值与预期原值相匹配时,系统才将该位置值更新为新值。这个操作是原子的,即中间不会被线程调度机制打断。
  • 用途:在 ReentrantLock 的实现中,CAS 用来实现锁状态的管理,比如尝试获取锁时更新状态值。
  1. AQSAbstractQueuedSynchronizer
  • 核心:AQS 是构建锁或其他同步器的框架,提供了一个基于FIFO队列的框架,用于管理等待获取锁的线程。它使用一个int成员变量表示同步状态,通过内置的FIFO队列来管理那些失败获取同步状态的线程。
  • 同步状态:AQS 使用一个整型的变量来表示同步状态,并通过protected方法来改变这个状态。锁实现者可以根据自己的需要来决定状态的意义。
  • 节点和队列:AQS 内部通过一个名为 Node 的静态嵌套类来表示等待队列中的每一个元素,每个节点包含了一个线程引用。当线程尝试获取锁而失败时,AQS 会将该线程包装成一个节点加入到等待队列的尾部;当锁被释放时,头节点的线程会尝试再次获取锁。
  1. 锁的获取与释放
  • 获取锁:当线程尝试获取锁时,ReentrantLock 首先尝试通过CAS设置同步状态,如果成功(意味着没有其他线程持有锁),则拥有了锁。如果失败,AQS会将当前线程封装成节点放入等待队列,同时线程将会被阻塞。
  • 释放锁:当锁持有者线程释放锁时,ReentrantLock 会更新同步状态并检查等待队列是否有因等待锁而阻塞的线程,如果有,将会选择一个(通常是队列头部的)线程来尝试获取锁。
  1. 公平性和非公平性
  • ReentrantLock 提供了公平锁和非公平锁两种模式。公平锁意味着锁的分配会遵循队列中等待时间最长的线程优先的原则;非公平锁则允许插队,可能会导致等待时间较长的线程出现饥饿现象。

  • 公平锁在锁释放时总是选择等待时间最长的线程来获取锁,而非公平锁则允许其他线程抢占,从而可能直接获取锁。


总结

ReentrantLock 的实现复杂且高效,它通过结合CAS操作、AQS框架以及Java内存模型(JMM)提供的内存可见性保证,实现了一种比传统 synchronized 方法更灵活、更高性能的线程同步机制。

4.2.4 使用并发容器

Java提供了一些并发安全的容器类,例如ConcurrentHashMapCopyOnWriteArrayList等,这些容器类在多线程环境下可以安全地访问和修改共享数据。

ConcurrentHashMapCopyOnWriteArrayList 都是 Java 中的并发集合,设计用于在多线程环境中提供高性能的线程安全性。它们通过具体的策略来最小化锁的竞争,从而提高并发访问的效率。下面分别介绍这两个类的特点和工作原理。


4.2.4.1 ConcurrentHashMap

ConcurrentHashMap 是一个线程安全的哈希表,用于在多线程环境中替代同步的 HashMap (Collections.synchronizedMap(new HashMap<...>()))。它通过分段锁(Segmentation)的概念提供了比 Hashtable 更好的并发性能。

  • 分段锁ConcurrentHashMap 将数据分成一段一段存储,然后给每一段数据配上一把锁。当一个线程占用锁访问其中一段数据时,其他段的数据也能被其他线程访问。ConcurrentHashMap 默认将数据分为 16 段,每当进行修改操作时,只需锁定相应的段,而不是锁定整个哈希表。

  • 读取不加锁:对于读取操作,ConcurrentHashMap 不加锁,因为它在内部采用了一种不可变的数据结构,即使没有加锁,也可以保证读取操作的安全性。

  • 迭代器弱一致性ConcurrentHashMap 的迭代器(和集合视图)提供了弱一致性,而不是快速失败(fail-fast)。这意味着迭代器在创建后不会抛出 ConcurrentModificationException,它们可以容忍并发的修改。


4.2.4.2 CopyOnWriteArrayList

CopyOnWriteArrayList 是一个线程安全的 List 实现,适用于读多写少的并发场景。如其名,该类的核心思想是“写入时复制”(Copy-On-Write)。

  • 写入时复制:每当我们尝试修改(添加、删除、设置等)CopyOnWriteArrayList 中的内容时,它实际上会先复制一份数据,然后在这份新的副本上进行修改。修改完成后,再将原来的引用指向新的副本。这样读取操作仍然可以安全地执行,而不受并发修改的影响。

  • 迭代器不支持修改CopyOnWriteArrayList 的迭代器不支持 removeaddset 操作,抛出 UnsupportedOperationException

  • 读取性能高:由于读取操作不需要加锁,所以在读多写少的场景下,CopyOnWriteArrayList 的读取性能非常高。

  • 内存开销:由于每次修改都要复制整个底层数组,CopyOnWriteArrayList 在内存使用上比较昂贵,特别是当列表很大时。因此,它通常适用于包含少量修改操作的场景。


    总结

ConcurrentHashMapCopyOnWriteArrayList 都是解决并发环境下集合操作的有效工具,它们通过不同的策略(分段锁和写入时复制)来提高并发性能和线程安全。选择哪一个取决于具体的应用场景:ConcurrentHashMap 适用于高并发的键值对操作,而 CopyOnWriteArrayList 更适用于读操作远远多于写操作的列表处理场景。

4.2.5 使用原子类

Java提供了一系列的原子类,例如AtomicIntegerAtomicLong等,它们提供了一些原子操作,可以在不使用锁的情况下实现线程安全的操作。

4.2.6 避免死锁

在设计多线程程序时要注意避免死锁的发生,即避免多个线程之间相互等待对方持有的资源而无法继续执行的情况。

4.2.7 使用线程安全的第三方库

在开发过程中,可以使用一些已经经过验证的线程安全的第三方库,如Apache Commons中的并发工具类。


多线程
http://example.com/2024/04/02/多线程/
作者
kangkang
发布于
2024年4月2日
许可协议