并发编程(高并发、多线程) 第二章

并发编程

  • 1.Lock
    • 1.1 Lock接口相比于同步方法、同步块的优势? (难度:★★ 频率:★★)
    • 1.2 ReentrantLock(可重入独占式锁)(难度:★★★ 频率:★★★)
      • 1.2.1 可重入性
      • 1.2.2 公平性
      • 1.2.3 可中断性
      • 1.2.4 超时获取锁
      • 1.2.5 条件变量
    • 1.3 乐观锁和悲观锁(难度:★★ 频率:★★★★)
      • 1.3.1 悲观锁的实现
      • 1.3.2 乐观锁的实现
      • 1.3.3 乐观锁和悲观锁的异同
    • 1.4 CAS(难度:★★★ 频率:★★★)
      • 1.4.1 CAS原理
      • 1.4.2 CAS存在哪些缺陷
    • 1.5 AQS(难度:★★★★ 频率:★★)
      • 1.5.1 AQS的工作原理
      • 1.5.2 独占锁和共享锁
    • 1.6 ReentrantReadWriteLock(难度:★★ 频率:★★★)
      • 1.6.1 读锁是共享的, 写锁是独占的
      • 1.6.2 读写锁适用场景
  • 2.线程池
    • 2.1 线程池的优缺点(难度:★★ 频率:★★★)
    • 2.2 线程池有哪些参数?(难度:★★ 频率:★★★★★)
    • 2.3 阻塞队列的作用以及用法(难度:★★ 频率:★★★)
    • 2.4 不同阻塞队列的区别(难度:★★ 频率:★★★)
    • 2.5 线程工厂的作用, 以及使用方法(难度:★ 频率:★★)
    • 2.6 线程池拒绝策略有哪些?默认是哪个?(难度:★ 频率:★★★★)
    • 2.7 线程池有哪些提交方法(难度:★ 频率:★★★★)
    • 2.8 submit和execute方法的区别(难度:★ 频率:★★)
    • 2.9 如何捕获线程池中的异常? (难度:★★ 频率:★★★★★)
    • 2.10 线程池执行流程(难度:★★ 频率:★★★★★)
    • 2.11 Executors类创建四种常见线程池(难度:★★ 频率:★★★)
    • 2.12 怎么关闭线程池(难度:★ 频率:★★★)

1.Lock

1.1 Lock接口相比于同步方法、同步块的优势? (难度:★★ 频率:★★)

ava Concurrency API中的Lock接口是用于实现线程同步的一种机制。它提供了比传统的 synchronized块更灵活的方式来控制多个线程之间的访问共享资源的方式。

Lock接口的主要方法是lock()unlock(), 它们分别用于获取锁释放锁

它的优势有:

  • 公平性
    Lock 接口的实现类可以选择是否支持公平性。公平锁是指等待时间最长的线程将获得锁的访问权。而 synchronized 关键字默认是非公平的,没有提供选择的机会。
  • 可中断性
    接口提供了lockInterruptibly()方法,允许线程在等待锁的过程中被中断。而使用synchronized关键字时,线程一旦进入等待状态,只能等待锁的释放,不能被中断。
  • 超时获取锁
    tryLock(long time, TimeUnit unit) 允许在指定的时间范围内尝试获取锁,如果在指定时间内未获取到,可以根据返回值来执行相应的操作。而 synchronized 关键字不能提供这样的功能。
  • 尝试非阻塞地获取锁
    Lock 接口提供了 tryLock() 方法,可以在不阻塞的情况下尝试获取锁。如果锁不可用,立即返回 false。这样可以避免线程无限期地阻塞等待锁。
  • 多个条件变量
    Lock 接口支持多个与锁相关联的条件变量,通过 Condition 接口实现。这允许开发者更细粒度地控制线程的等待和唤醒。
  • 灵活性
    Lock 接口的实现类提供了更多的灵活性和控制选项,例如可重入锁 ReentrantLock 提供了可重入性,允许同一个线程多次获取同一个锁。

1.2 ReentrantLock(可重入独占式锁)(难度:★★★ 频率:★★★)

它实现了 Lock 接口。与传统的 synchronized 关键字相比,ReentrantLock 提供了更多的灵活性和功能。

1.2.1 可重入性

ReentrantLock 的可重入性体现在同一个线程可以多次获得同一个锁,而不会发生死锁。这是通过内部维护一个持有锁的线程计数器来实现的。synchronized关键字也同样具备可重入性

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockReentrancyExample {
    private final Lock lock = new ReentrantLock();

    public void outerMethod() {
        lock.lock(); // 第一次获得锁
        try {
            innerMethod(); // 在同一线程中调用另一个方法,再次获得锁
        } finally {
            lock.unlock(); // 第一次释放锁
        }
    }

    public void innerMethod() {
        lock.lock(); // 第二次获得锁
        try {
            // 执行需要同步的代码块
            System.out.println("线程 " + Thread.currentThread().getName() + " 获得锁");
        } finally {
            lock.unlock(); // 第二次释放锁
            System.out.println("线程 " + Thread.currentThread().getName() + " 释放锁");
        }
    }

    public static void main(String[] args) {
        ReentrantLockReentrancyExample example = new ReentrantLockReentrancyExample();

        // 启动一个线程调用 outerMethod 方法
        new Thread(() -> {
            example.outerMethod();
        }).start();
    }
}

outerMethod 方法中调用了 innerMethod 方法,而这两个方法都在同一个线程中执行。当线程首次进入 outerMethod 时,它成功获得了锁,并在 innerMethod 中再次成功获得了同一个锁。在 innerMethod 中释放锁时,锁的计数器减为零,才真正释放了锁。这种方式确保了同一线程可以在持有锁的情况下多次进入同一个锁保护的代码块。

我们看下执行流程:

  1. 线程进入outerMethod,获取锁。
  2. 调用innerMethod,在同一线程中再次获取锁。
  3. innerMethod执行完毕后释放锁,但锁仍然保持在outerMethod中。
  4. outerMethod执行完毕后释放锁,此时其他线程有机会获取锁。

什么场景下,会使用到锁的可重入性

  1. 递归调用
    当一个方法递归调用自身,并且这个方法是在持有锁的情况下调用的,可重入性允许同一个线程在递归调用中多次获取相同的锁,而不会发生死锁。
public class RecursiveExample {
    private final Object lock = new Object();

    public void recursiveMethod(int count) {
        synchronized (lock) {
            if (count > 0) {
                System.out.println("Count: " + count);
                recursiveMethod(count - 1); // 递归调用,再次获取相同锁
            }
        }
    }

    public static void main(String[] args) {
        RecursiveExample example = new RecursiveExample();
        example.recursiveMethod(3);
    }
}
  1. 嵌套调用
    当一个方法调用了另一个方法,而这两个方法都需要获取相同的锁时,可重入性使得同一个线程可以在嵌套调用中多次获取相同的锁。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class NestedCallExample {
    private final Lock lock = new ReentrantLock();

    public void outerMethod() {
        lock.lock(); // 第一次获得锁
        try {
            System.out.println("执行 outerMethod");
            innerMethod(); // 在同一线程中再次获取相同锁
        } finally {
            lock.unlock(); // 第一次释放锁
        }
    }

    public void innerMethod() {
        lock.lock(); // 第二次获得锁
        try {
            System.out.println("执行 innerMethod");
        } finally {
            lock.unlock(); // 第二次释放锁
        }
    }

    public static void main(String[] args) {
        NestedCallExample example = new NestedCallExample();
        example.outerMethod();
    }
}

1.2.2 公平性

ReentrantLock可以选择是公平锁(fair lock)还是非公平锁(non-fair lock),而 synchronized关键字默认是非公平的。

  1. 公平锁: 一个公平的锁会按照请求锁的顺序逐个地授予等待的线程,确保所有线程都有机会获得锁,避免饥饿(某个线程一直无法获取锁的情况)
ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
  1. 非公平锁: 在一个非公平的锁中,当锁可用时,系统会任意选择一个等待的线程赋予锁,而不考虑等待的时间或请求的顺序。这可能导致某些线程一直无法获得锁,从而引发饥饿问题
ReentrantLock unfairLock = new ReentrantLock(false); // 非公平锁

需要注意的是,虽然公平锁确保了锁的公平性,但在高并发环境下,公平锁的性能可能相对较低,因为每次都要考虑等待队列中线程的顺序。非公平锁可能会在性能上有一些优势,但可能导致某些线程长时间无法获得锁。

在实际应用中,一般情况下会使用非公平锁,因为在高并发的情况下,公平锁可能会导致线程频繁切换,影响性能。非公平锁虽然在一些情况下可能会引入不公平的竞争,但能够更好地提高并发性能。选择使用公平锁还是非公平锁需要根据具体的业务场景和性能需求来权衡。

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class FairnessExample {
    private static final int THREAD_COUNT = 5;
    private static final Lock fairLock = new ReentrantLock(true);  // 公平锁
    private static final Lock unfairLock = new ReentrantLock(false); // 非公平锁

    private static void performTask(Lock lock, String lockType) {
        for (int i = 0; i < 3; i++) {
            lock.lock();
            try {
                System.out.println("Thread " + Thread.currentThread().getName() +
                        " acquired " + lockType + " lock, counter: " + i);
            } finally {
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) {
        // 使用公平锁
        for (int i = 0; i < THREAD_COUNT; i++) {
            new Thread(() -> performTask(fairLock, "fair")).start();
        }

        // 使用非公平锁
        for (int i = 0; i < THREAD_COUNT; i++) {
            new Thread(() -> performTask(unfairLock, "unfair")).start();
        }
    }
}

在上述示例中,创建了两个 ReentrantLock,一个是公平锁,一个是非公平锁。在 performTask 方法中,线程通过 lock() 方法获取锁,执行一些操作,然后通过 unlock() 方法释放锁。运行这个程序,你会观察到使用公平锁时,线程按照请求锁的顺序获取锁,而使用非公平锁时,线程可能会插队成功,不按照请求的顺序获取锁。这是通过 ReentrantLock 的公平性来体现的。

1.2.3 可中断性

可中断性是指在一个线程等待获取锁的过程中,如果其他线程对该等待线程进行中断(调用 interrupt() 方法),那么等待线程能够感知到中断,并有机会响应中断而不是一直等待下去。在这种情况下,等待线程会收到 InterruptedException 异常。

在使用ReentrantLock的lockInterruptibly()方法时,线程可以响应中断,即在等待锁的过程中,如果线程被其他线程中断,它会立即抛出InterruptedException 异常,而不是一直等待。

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockInterruptExample {
    private static final Lock lock = new ReentrantLock();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            try {
                lock.lockInterruptibly(); // 可中断获取锁
                try {
                    System.out.println("Thread 1 acquired the lock");
                    Thread.sleep(2000); // 模拟持有锁的一些操作
                } finally {
                    lock.unlock();
                }
            } catch (InterruptedException e) {
                System.out.println("Thread 1 interrupted while waiting for the lock");
            }
        });

        Thread thread2 = new Thread(() -> {
            try {
                lock.lockInterruptibly(); // 可中断获取锁
                try {
                    System.out.println("Thread 2 acquired the lock");
                } finally {
                    lock.unlock();
                }
            } catch (InterruptedException e) {
                System.out.println("Thread 2 interrupted while waiting for the lock");
            }
        });

        // 启动第一个线程,并让它持有锁
        thread1.start();

        // 等待一段时间,确保第一个线程先获取到锁
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 启动第二个线程,但在获取锁之前中断它
        thread2.start();
        thread2.interrupt();
    }
}

结果输出:
Thread 1 acquired the lock
Thread 2 interrupted while waiting for the lock

第二个线程在等待锁的过程中, 感知到被中断, 并抛出InterruptedException, 从而可以在中断的处理代码块中执行相应的逻辑。

使用synchronized关键字进行同步的代码块或方法在等待锁的过程中是无法响应中断的。

"等待锁时响应中断"和"中断线程的执行"是两个不同的概念
虽然都是通过调用interrupt()来中断线程, 但是两者有实质区别

  • 等待锁时响应中断: 中断的是正在等待锁的线程, 抛出InterruptedException
  • 中断线程的执行: 中断的是正在执行中的线程, 通过Thread.interrupted()或isInterrupted()方法判断中断请求

1.2.4 超时获取锁

ReentrantLock提供了一种超时获取锁的方式,即通过tryLock(long time, TimeUnit unit)方法,线程在一定的时间范围内尝试获取锁,如果在指定的时间内获取到锁,则返回 true,否则返回 false。这样可以避免线程一直阻塞等待锁,而是在一定时间内尝试获取,如果获取不到则可以执行其他逻辑或放弃锁的获取。

方法的参数 time 表示等待时间的数量,unit 表示等待时间的单位。如果在指定的时间内获取到锁,方法返回 true,否则返回 false。

// 线程一: 获取锁, 并休眠3秒, 注意在finally中调用unlock
Thread thread1 = new Thread(() -> {
   try {
       System.out.println("线程一获取锁");
       lock.lock();
       TimeUnit.SECONDS.sleep(3);
   } catch (InterruptedException e) {
       System.out.println("线程一等待锁时响应中断");
   } finally {
       lock.unlock();
   }
});

// 线程二: 超时获取锁, 如果2秒内未获取锁, 则
Thread thread2 = new Thread(() -> {
   try {
       if (lock.tryLock(2, TimeUnit.SECONDS)) {
           try {
               System.out.println("线程二获取锁");
           } finally {
               lock.unlock();
           }
       } else {
           System.out.println("线程二放弃获取锁");
       }
   } catch (InterruptedException e) {
       System.out.println("线程二等待锁时响应中断");
   }
});

thread1.start();

// 休眠, 保证线程一先获取锁
try {
   Thread.sleep(100);
} catch (InterruptedException e) {
   throw new RuntimeException();
}

thread2.start();
// thread2.interrupt();

因为线程一先获取了锁, 并休眠3秒, 而休眠期间, 线程二等待锁的释放, 并愿意等待2秒. 但2秒之后, 线程一并没有释放锁, 所以tryLock(2, TimeUnit.SECONDS)返回false, 线程二放弃获取锁

释放锁的时机
很多人使用tryLock时, 会犯一个错误, 会在最外层的try中通过finally来释放锁, 这样是错误的. 因为tryLock返回true, 才能有释放锁(unlock)的操作, 因为你持有了锁, 才能释放锁. 如果放弃获取锁, 还释放锁, 会导致其他线程持有的锁被释放
并发编程(高并发、多线程) 第二章_第1张图片

tryLock等待锁时, 线程被中止会怎么样
在使用 tryLock() 方法尝试获取锁的过程中,如果线程在等待锁的过程中被中断,tryLock() 方法会响应中断,即会抛出 InterruptedException 异常。

1.2.5 条件变量

ReentrantLock提供了与锁关联的条件变量(Condition),条件变量允许线程以灵活的方式进行等待和通知,以实现更复杂的线程协作。条件变量提供了一种在某些条件不满足时线程等待的机制,并在条件满足时通知其他线程的方式。

public class ThreadTest {

    private static final Lock lock = new ReentrantLock();
    private static final Condition condition = lock.newCondition();
    private static boolean isConditionMet = false;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            // 上锁
            lock.lock();

            try {
                // 等待条件满足
                while (!isConditionMet) {
                    condition.await();
                }

                // 执行条件满足后的操作
                System.out.println("condition is met!");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                // 解锁
                lock.unlock();
            }
        });

        Thread thread2 = new Thread(() -> {
            // 上锁
            lock.lock();

            try {
                // 改变变量
                isConditionMet = true;

                // 通知等待的线程条件已满足
                condition.signal();
                System.out.println("通知等待的线程条件已满足");
            } finally {
                // 解锁
                lock.unlock();
            }
        });

        thread1.start();

        TimeUnit.SECONDS.sleep(1);
        thread2.start();
    }
}

上面的代码额外使用了isConditionMet来判断等待条件是否满足, 这是为了防止虚假唤醒

condition.await()会阻塞线程运行吗?
是的,condition.await()方法会阻塞当前线程的执行。

在await()被调用时,它会释放当前线程持有的锁,使其他线程有机会获取这个锁。当条件满足时,通过调用signal()或signalAll()来唤醒一个或所有等待的线程。被唤醒的线程会尝试重新获得锁,然后继续执行。

isConditionMet并没有使用voliate, 在线程中是怎么感知它的值变成true
虽然确实没有使用volatile关键字来声明isConditionMet变量,但是它是在同一个锁的保护下被读取和修改的。

在Java中,当一个线程在获取锁的时候,会从主内存中读取共享变量的最新值,并在执行过程中将其缓存在线程的本地内存中。其他线程在获取同一把锁时,会从主内存中重新读取共享变量的值。因为 ReentrantLock 是一个可重入锁,同一个线程在获取锁的时候,不会真正释放锁,因此它的本地内存中的共享变量值是可见的。

在你的例子中,awaitCondition 方法中的 while (!isConditionMet) 中的读取操作和 signalCondition 方法中的写入操作都在同一个 ReentrantLock 的保护下,这确保了线程在获取锁的时候能够看到最新的共享变量值。

虽然没有使用 volatile,但由于所有对isConditionMet的读取和写入都在同一把锁的保护下,因此在这个特定的上下文中,是可以保证可见性的。要注意的是,如果isConditionMet不是在同一个锁的保护下进行读写,或者在其他地方可能会涉及到多线程并发访问,那么使用 volatile 或其他同步手段可能是更安全的选择。

条件变量与多线程编程中wait()和notify()机制的异同

相同点:

  • 线程等待
    条件变量的await()方法和Object的wait()方法都允许线程等待某个条件的发生.

    • await()方法和wait()方法都需要在持有锁的情况下调用
    • await()方法和wait()方法都会释放持有的锁
    • await()方法和wait()方法都在try块内使用, 需要处理InterruptedException异常
    • await()方法和wait()方法都可以被唤醒
  • 线程通知
    条件变量的signal()方法和notify()方法都用于通知等待线程条件的变化,以便它们有机会再次检查条件是否满足。

    • await(): 线程可以通过调用 signal() 或 signalAll() 方法来唤醒等待的线程。
    • wait(): 线程可以通过调用 notify() 或 notifyAll() 方法来唤醒等待的线程。

区别:

  • 多条件等待
    一个 ReentrantLock 可以关联多个条件变量,这使得在同一个锁上可以实现更细粒度的线程控制。
public class ThreadTest {

    private static final Lock lock = new ReentrantLock();
    private static final Condition condition1 = lock.newCondition();
    private static final Condition condition2 = lock.newCondition();

    private static boolean isConditionMet1 = false;
    private static boolean isConditionMet2 = false;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            // 上锁
            lock.lock();

            try {
                // 等待条件满足
                while (!(isConditionMet1 && isConditionMet2)) {
                    condition1.await();
                    System.out.println("条件一已满足");
                    condition2.await();
                    System.out.println("条件二已满足");
                }

                // 执行条件满足后的操作
                System.out.println("condition is met!");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                // 解锁
                lock.unlock();
            }
        });

        Thread thread2 = new Thread(() -> {
            // 上锁
            lock.lock();

            try {
                // 改变变量
                isConditionMet1 = true;

                // 通知等待的线程条件已满足
                condition1.signal();
                System.out.println("通知等待的线程条件已满足");
            } finally {
                // 解锁
                lock.unlock();
            }
        });

        Thread thread3 = new Thread(() -> {
            // 上锁
            lock.lock();

            try {
                // 改变变量
                isConditionMet2 = true;

                // 通知等待的线程条件已满足
                condition2.signal();
                System.out.println("通知等待的线程条件已满足");
            } finally {
                // 解锁
                lock.unlock();
            }
        });

        thread1.start();

        TimeUnit.SECONDS.sleep(1);
        thread2.start();
        thread3.start();
    }
}
  • 超时等待
    Condition接口提供了带有超时参数的 await 方法,可以设置等待一定时间后自动唤醒线程。这在一些需要限时等待的场景下非常有用。
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();

lock.lock();
try {
    long timeout = 1000; // 等待1秒
    while (condition不满足) {
        timeout = condition.await(timeout, TimeUnit.MILLISECONDS);
        if (timeout <= 0) {
            // 超时处理
            break;
        }
    }
    // 执行相应操作
} finally {
    lock.unlock();
}
  • 精准唤醒:
    Condition提供的signal 和 signalAll 方法可以选择性地唤醒等待队列中的线程。这意味着你可以有选择地唤醒等待在不同条件上的线程,而不像notify 和notifyAll 那样只能唤醒所有等待线程。这有助于减少不必要的线程唤醒,提高性能。
public class ThreadTest {

    private static final Lock lock = new ReentrantLock();
    private static final Condition condition1 = lock.newCondition();
    private static final Condition condition2 = lock.newCondition();

    private static boolean isConditionMet1 = false;
    private static boolean isConditionMet2 = false;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            // 上锁
            lock.lock();

            try {
                // 等待条件满足
                while (!(isConditionMet1 && isConditionMet2)) {
                    condition1.await();
                    System.out.println("条件一已满足");
                    condition2.await();
                    System.out.println("条件二已满足");
                }

                // 执行条件满足后的操作
                System.out.println("condition is met!");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                // 解锁
                lock.unlock();
            }
        });

        Thread thread2 = new Thread(() -> {
            // 上锁
            lock.lock();

            try {
                // 改变变量
                isConditionMet1 = true;

                // 通知等待的线程条件已满足
                condition1.signal();
                System.out.println("通知等待的线程条件已满足");
            } finally {
                // 解锁
                lock.unlock();
            }
        });

        Thread thread3 = new Thread(() -> {
            // 上锁
            lock.lock();

            try {
                // 改变变量
                isConditionMet2 = true;

                // 通知等待的线程条件已满足
                condition2.signal();
                System.out.println("通知等待的线程条件已满足");
            } finally {
                // 解锁
                lock.unlock();
            }
        });

        thread1.start();

        TimeUnit.SECONDS.sleep(1);
        thread2.start();
        thread3.start();
    }
}

1.3 乐观锁和悲观锁(难度:★★ 频率:★★★★)

乐观锁和悲观锁都是并发控制的机制, 用于在多个事务同时访问共享资源时, 保护数据的一致性

1.3.1 悲观锁的实现

悲观锁: 总是设想最坏的情况, 每次拿数据的时候都认为别人会修改, 所以在拿数据之前会上锁, 这样别人如果想来操作这个数据, 就需要等我先释放了锁

悲观锁的基本思想是: 在操作共享资源前, 先获取锁, 确保其他事务无法同时修改此资源, 从而避免数据冲突和不一致的问题

悲观锁的实现有哪些?

  • Synchronized关键字
  • Lock接口
  • 传统关系型数据库的行锁、表锁、读锁、写锁等待

1.3.2 乐观锁的实现

悲观锁: 总是乐观, 每次拿数据的时候都认为别人不会修改, 所以不会上锁, 但是在更新的时候会判断一下在此期间有没有人更新过这个数据

乐观锁的基本思想是: 假设事务冲突很少发生, 因为在操作前不会对数据上锁, 而是在提交事务时, 校验数据是否发生了冲突, 如果发生了冲突, 系统会拒绝提交当前事务, 并通知用户解决冲突

乐观锁的实现有哪些?

  • 版本控制
    给数据表增加一个版本号时间戳字段, 每个更新数据时, 版本号递增或者时间戳更新, 在提交事务时, 检查版本号或时间戳, 如果不一致, 说明数据已经被其他事务修改过
  • CAS操作
  • 逻辑锁

1.3.3 乐观锁和悲观锁的异同

乐观锁 悲观锁
基本思想 假设冲突较少, 不立即加锁, 而是在提交事务时检查是否发生冲突 假设会发生冲突, 因此在访问共享资源之前会先获取锁, 确保资源独占
加锁时机 在事务提交时才会检查冲突, 事务执行过程中不进行加锁 假设会发生冲突, 因此在访问共享资源之前会先获取锁, 确保资源独占
性能影响 可以提高并发性能,因为事务执行时不加锁,只在提交时检查冲突,减少了锁竞争 可能导致性能下降,因为在事务执行期间资源被锁定,其他事务需要等待=
冲突处理 冲突发生时需要回滚事务,重新尝试或通知用户处理冲突 冲突发生时直接阻塞或等待其他事务释放锁

选择标准:

  • 使用悲观锁还是乐观锁通常取决于应用场景和需求。
  • 悲观锁适用于写操作多、冲突频繁的场景,例如事务的更新、删除等。
  • 乐观锁适用于读操作多、冲突较少的场景,例如读取数据、查询等。

1.4 CAS(难度:★★★ 频率:★★★)

CAS即Compare And Swap的缩写, 即比较并交换

CAS操作包含三个参数

  • 内存地址: 需要进行原子操作的内存地址
  • 期望值: 预期内存位置的当前值, 可以理解为更新前的值, 用于比较
  • 新值

1.4.1 CAS原理

  1. 读取内存值: CAS首先读取内存地址的当前值, 这个值通常称为期望值
  2. 执行操作: CAS执行相应的操作, 例如给期望值赋值, 或者其他复杂的操作, 得到新值
  3. 比较值: CAS比较期望值与当前内存地址的实际值是否相等, 如果相等, 说明在读取和执行操作的过程中, 没有其他线程修改了这个值
  4. 更新值: 比较成功后, CAS将新值写回到内存位置, 如果在这个过程中,其他线程修改了内存位置的值,CAS会失败。
  5. 检查CAS的结果: CAS操作通常返回一个布尔值,指示操作是否成功。如果操作成功,表示CAS过程中没有其他线程干扰,如果失败,则可能需要重新尝试整个CAS过程, 直到CAS操作成功, 或达到某个预定的重试次数

1.4.2 CAS存在哪些缺陷

  • ABA问题
    CAS 操作本质上是比较并交换,但它不会关心变量的值在比较前后是否发生了其他变化。这可能导致ABA问题,即一个值在经过一系列操作后又变回原始值,但 CAS 操作无法察觉到这种变化
  • 循环时间长开销大
    CAS 操作需要通过循环来不断尝试更新变量的值,直到成功。在高并发情况下,可能需要多次尝试才能成功,导致循环时间较长,增加了开销。
  • 只能保证一个共享变量的原子操作:
    当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。

1.5 AQS(难度:★★★★ 频率:★★)

AQS(AbstractQueuedSynchronizer)是一个用于构建各种同步器的基础框架,提供了一些基本的同步操作的模板方法。AQS 的具体实现方式取决于具体的同步器需求,以下是一些常见的基于AQS实现的同步器:

  • ReentrantLock(可重入锁)
    ReentrantLock 是 AQS 的一个独占锁的实现。它允许线程在持有锁的情况下再次获取锁,因此是可重入的。
  • ReentrantReadWriteLock(可重入读写锁)
    ReentrantReadWriteLock 是 AQS 的一个读写锁的实现。它允许多个线程同时读取共享资源,但在写入时需要独占。
  • Semaphore(信号量)
    Semaphore 是 AQS 的一个实现,用于控制同时访问某个特定资源的线程数量。
  • CountDownLatch(倒计数门闩)
    CountDownLatch 是 AQS 的一个实现,用于实现一种线程间等待的机制。它允许一个或多个线程等待其他线程完成操作。
  • CyclicBarrier(循环屏障)
    CyclicBarrier 是 AQS 的一个实现,用于实现多个线程在一个点上进行等待,然后同时继续执行
  • Phaser(阶段屏障)
    Phaser 是 AQS 的一个实现,用于实现更复杂的阶段控制。

这些同步器都使用了AQS提供的模板方法,如acquirerelease,并通过扩展 AbstractQueuedSynchronizer类来实现特定的同步逻辑。通过 AQS,开发者可以相对容易地构建出各种灵活、可扩展的同步工具。实际上,除了上述提到的同步器之外,还可以根据具体需求实现自定义的同步器。

1.5.1 AQS的工作原理

AQS的工作原理基于等待队列(waiting queue)整数状态(state)

当线程请求获取共享资源时, AQS会根据状态判断是否可以获取访问权限, 如果不能就将线程加入等待队列中, 一旦资源可用或者满足特定条件, AQS会通过状态的改变唤醒等待队列中的线程来实现多线程协助和同步

  • 等待队列
    等待队列是一个先进先出(FIFO)的双向链表,用于管理等待获取同步资源的线程。等待队列中的每个节点Node都代表一个等待线程(等待中的线程会被封装成一个Node类的实例)
  • 整数状态(state)
    整数状态(state)是用来表示同步器状态的一个重要属性, 这个整数状态的具体含义由同步器的实现者定义
    • 对于独占锁(例如ReentrantLock)而言,state 的值通常表示锁的占用情况,比如:
      • state = 0:表示锁未被占用,可以被当前线程获取。
      • state = 1:表示锁已被占用,当前线程是锁的拥有者。
    • 对于共享锁(例如Semaphore)而言,state 的值可能表示已经获得锁的线程数量。

并发编程(高并发、多线程) 第二章_第2张图片

  • acquire方法: 当一个线程需要获得同步资源时,它会调用acquire方法。在acquire方法中,线程首先尝试通过tryAcquire方法获取同步资源,如果成功,则直接执行相应的同步操作, 此时state被设置为1。如果尝试失败,则线程会被加入到等待队列中,并进入阻塞状态, state不会更新。
  • release方法: 当一个线程释放同步资源时,它会调用release方法。在release方法中,线程首先通过tryRelease方法释放同步资源,并唤醒等待队列中的其他线程。被唤醒的线程会再次尝试获取同步资源,如果成功,则它将继续执行, state会被更新。

1.5.2 独占锁和共享锁

AQS支持独享锁共享锁两种不同类型的同步机制, 两者的区别在于它们允许同时被多个线程持有的方式

独占锁 共享锁
特点 独享锁是一种排它锁,即一次只能有一个线程持有该锁。当一个线程持有独享锁时,其他线程无法同时获取相同的锁,必须等待当前持有锁的线程释放 共享锁是一种允许多个线程同时持有的锁。当一个线程持有共享锁时,其他线程仍然可以获取相同的锁,只要它们也是请求获取共享锁的。但是,当一个线程持有共享锁时,其他线程无法获取独享锁。
示例 ReentrantLock 是 AQS 的独享锁的实现。它允许同一个线程多次获取锁,即支持重入,但要求每一次获取锁都必须对应一次释放锁 ReentrantReadWriteLock 是 AQS 的共享锁的实现。它区分了读锁和写锁,允许多个线程同时持有读锁,但在写锁被持有时,其他线程无法获取读锁或写锁。

1.6 ReentrantReadWriteLock(难度:★★ 频率:★★★)

ReentrantReadWriteLock也被称为读写锁, 它的内部维护了一对相关的锁, 一个用于只读操作, 称为读锁; 一个用于写入操作, 称为写锁

读写锁: 顾名思义, 包含两种锁, 一个是读锁、一个是写锁. 其中读锁是共享锁、写锁是排它锁. 读锁允许多个线程同时获取锁进行读操作, 而写锁在同一时间只允许一个线程获取锁, 进行写操作

1.6.1 读锁是共享的, 写锁是独占的

  • 读锁(共享锁)

    • 共享性: 多个线程可以同时持有读锁, 同时读取共享资源, 多个线程之间不会相互阻塞, 允许并发读取操作
    • 不独占: 读锁不阻止其他线程获取读锁, 多个线程可以同时持有读锁, 进行读取操作, 不互斥
  • 写锁(独占锁)

    • 独占性: 同一时刻只允许一个线程持有写锁, 其他线程无法同时持有读锁或写锁. 写锁独占共享资源, 确保写操作的原子性
    • 阻塞其他锁: 当有线程持有写锁时, 其他线程无法获取读锁或写锁, 会被阻塞, 这是为了确保写操作的独占性
public static void main(String[] args) {
    ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

    Thread read1 = new Thread(() -> {
        try {
            rwl.readLock().lock();
            System.out.println("读操作执行 时间:" + System.currentTimeMillis());

            // 休眠3秒
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            rwl.readLock().unlock();
        }
    });

    Thread read2 = new Thread(() -> {
        try {
            rwl.readLock().lock();
            System.out.println("读操作执行 时间:" + System.currentTimeMillis());
        } finally {
            rwl.readLock().unlock();
        }
    });

    Thread write = new Thread(() -> {
        try {
            rwl.writeLock().lock();
            System.out.println("写操作执行 时间:" + System.currentTimeMillis());
        } finally {
            rwl.writeLock().unlock();
        }
    });

    read1.start();

    // 休眠一会儿
    try {
        Thread.sleep(100);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    read2.start();
    write.start();
}

线程进入读锁的前提条件

  1. 没有其他线程的写锁
  2. 没有其他线程正在请求写锁

线程进入写锁的前提条件

  1. 没有其他线程的读锁
  2. 没有其他线程的写锁

只要没有Writer线程,读锁可以由多个Reader线程同时持有。也就是说,写锁是独占的,读锁是共享的

1.6.2 读写锁适用场景

有这么一些场景

  • 对共享资源有读和写的操作, 而且写操作并没有读操作那么频繁(读多写少)
  • 在没有写操作的时候, 多个线程同时读取一个资源没有任何问题, 所以应该允许多个线程同时读取共享资源(读读可以并发)
  • 但如果一个线程想去写这些共享资源, 就不应该允许其他线程对该资源进行读和写操作了(读写、写读、写写互斥)
  • 在读多于写的情况下, 读写锁能提供比排他锁更换的并发性和吞吐量

2.线程池

2.1 线程池的优缺点(难度:★★ 频率:★★★)

1.优点如下
线程池通过提供一种有效的线程管理调度机制,帮助提高应用程序的性能和可维护性,尤其在处理大量并发任务时,线程池是一种强大而有效的工具。

  1. 线程管理:
    线程池可以有效管理系统资源, 避免频繁创建和销毁线程, 减少系统开销, 通过重复利用线程, 降低资源的占用
  2. 调度和控制
    线程池允许对线程的数量进行有效的控制,可以防止系统因过度并发而陷入性能下降的状态。通过配置线程池的参数,可以灵活地调整线程的数量和行为。

2.缺点如下

  • 资源占用: 线程池本身会占用一定的系统资源,包括内存和 CPU 资源。如果线程池设置不当,可能会导致资源浪费。
  • 任务队列阻塞: 线程池的任务队列如果满了,新的任务可能会被阻塞或者拒绝。这可能导致任务延迟执行,特别是在高负载情况下。
  • 难以调试: 当线程池中的线程发生问题时,调试可能会变得复杂。由于线程的生命周期和执行过程由线程池管理,追踪问题可能会比直接管理线程的情况更加困难。
  • 配置复杂: 需要合理配置线程池的参数,包括线程数量、任务队列大小等。配置不当可能导致性能问题,需要开发人员具有一定的经验和调优技能。
  • 不适用于所有场景: 线程池并不是在所有情况下都是最佳选择。对于某些类型的任务,例如计算密集型任务,其他并发模型可能更为合适。

2.2 线程池有哪些参数?(难度:★★ 频率:★★★★★)

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)
  • corePoolSize 核心线程数
    核心线程数是指线程池中保持存活的最小线程数量, 这些线程会一直存活, 即使它们处于空闲状态
  • maximumPoolSize 最大线程数
    最大线程数是指线程池中允许的最大线程数量。当线程池中的任务队列已满,且活动线程数未达到最大线程数时,线程池会创建新的线程来执行任务,直到达到最大线程数。超过最大线程数的任务会根据线程池的拒绝策略进行处理,可能会被拒绝执行或者以其他方式处理。
  • keepAliveTime 非核心线程存活时间
  • unit 指定keepAliveTime的单位
  • workQueue 阻塞队列
    它充当了缓冲区的角色, 任务在没有核心线程处理是, 优先将任务扔到阻塞队列中
  • threadFactory 线程工厂
    指定创建线程的方式, 例如设置线程的名称、优先级、是否为守护线程等待
  • handler 拒绝政策
    用于定义当前线程池无法接受新任务时的策略

当我们核心线程数已经到达最大值、阻塞队列也已经放满了所有的任务、而且我们工作线程个数已经达到最大线程数, 此时如果还有新任务, 就只能走拒绝策略了

2.3 阻塞队列的作用以及用法(难度:★★ 频率:★★★)

作用有以下两点

  • 任务缓冲
    作为一个缓冲区, 可以在生产者产生任务时缓存这些任务, 等待线程池中的线程来执行
  • 任务调度
    不同类型的BlockingQueue实现了不同的任务调度策略, 例如FIFO(先进先出)、LIFO(后进先出)、优先级队列等, 这有助于更灵活的控制任务的执行顺序

在线程池中, BlockingQueue主要通过以下两个参数进行配置:
corePoolSizemaximumPoolSize这个两个参数来指定线程池的基本大小和最大大小

  1. 当线程池中的线程未达到corePoolSize时, 新任务将创造新线程
  2. 当线程池中的线程数达到corePoolSize且任务队列未满时, 新任务将被放入队列等待
  3. 当队列也满了, 且线程池中的线程数未达到maximumPoolSize时, 新任务将创建新线程
  4. 当线程池中的线程数达到maximumPoolSize时, 新任务将由饱和策略处理

通过选择不同的BlockingQueue实现,可以实现不同的任务调度策略。

import java.util.concurrent.*;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 创建一个LinkedBlockingQueue作为任务队列
        BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(10);

        // 创建一个ThreadPoolExecutor,使用LinkedBlockingQueue作为任务队列
        ExecutorService executor = new ThreadPoolExecutor(
                5,  // corePoolSize
                10, // maximumPoolSize
                1,  // keepAliveTime
                TimeUnit.SECONDS,
                queue);

        // 提交任务到线程池
        for (int i = 0; i < 15; i++) {
            executor.submit(() -> {
                try {
                    Thread.sleep(1000);
                    System.out.println("Task completed by: " + Thread.currentThread().getName());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }

        // 关闭线程池
        executor.shutdown();
    }
}

打印结果:
Task completed by: pool-1-thread-5
Task completed by: pool-1-thread-1
Task completed by: pool-1-thread-2
Task completed by: pool-1-thread-4
Task completed by: pool-1-thread-3
Task completed by: pool-1-thread-1
Task completed by: pool-1-thread-4
Task completed by: pool-1-thread-2
Task completed by: pool-1-thread-5
Task completed by: pool-1-thread-3
Task completed by: pool-1-thread-2
Task completed by: pool-1-thread-4
Task completed by: pool-1-thread-5
Task completed by: pool-1-thread-1
Task completed by: pool-1-thread-3

在这个例子中,LinkedBlockingQueue作为任务队列,可以存储最多 10 个等待执行的任务。线程池的核心线程数为5,最大线程数为10,因此在任务队列未满时,新任务将放入队列等待。如果队列已满,新任务将创建新线程执行,但不会超过最大线程数。

因为开启了15个线程, 而核心线程数+阻塞队列容量正好为15个, 所以不会创建新的线程

2.4 不同阻塞队列的区别(难度:★★ 频率:★★★)

1.ArrayBlockingQueue
基于数组实现的有界队列。固定容量,一旦创建就不能更改。需要指定容量,适用于任务数量固定的情况。

ThreadPoolExecutor threadPool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>(10),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());

使用有界任务队列, 若有新的任务需要执行时, 线程池会创建新的线程, 直到创建的线程数量达到corePoolSize时, 则会将新的任务加入到等待队列中. 若等待队列已满, 即超过 ArrayBlockingQueue初始化的容量, 则继续创建线程, 直到线程数量达到maximumPoolSize设置的最大线程数量, 若大于 maximumPoolSize, 则执行拒绝策略.

在这种情况下,线程数量的上限与有界任务队列的状态有直接关系,如果有界队列初始容量较大或者没有达到超负荷的状态, 线程数将一直维持在 corePoolSize 以下, 反之当任务队列已满时, 则会以maximumPoolSize为最大线程数上限

2.LinkedBlockingQueue
基于链表实现的有界或无界队列。可以选择是否指定容量,如果不指定容量则默认是 Integer.MAX_VALUE,适用于任务数量不固定的情况

ThreadPoolExecutor threadPool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());

无界任务队列,线程池的任务队列可以无限制的添加新的任务,而线程池创建的最大线程数量就是你corePoolSize设置的数量,也就是说在这种情况下maximumPoolSize这个参数是无效的,哪怕你的任务队列中缓存了很多未执行的任务,当线程池的线程数达到corePoolSize 后,就不会再增加了;若后续有新的任务加入,则直接进入队列等待,当使用这种任务队列模式时,一定要注意你任务提交与处理之间的协调与控制,不然会出现队列中的任务由于无法及时处理导致一直增长,直到最后资源耗尽的问题。

3.SynchronousQueue
一个不存储元素的队列, 每个插入操作必须等待另一个线程的对应移除操作, 反之亦然.
主要用于直接传递任务的场景, 一个线程产生任务, 另一个线程消费任务

SynchronousQueue常用于以下场景:

  • 直接传递任务
    如果希望在提交任务时立即将任务交给工作者线程, 而不需要维护一个任务队列
  • 避免任务排队
    SynchronousQueue的容量为0, 它不会保存任务, 而是直接将任务交给等待的任务. 这有助于避免任务排队等待执行, 适用于一些实时性的场景

4.PriorityBlockingQueue
支持优先级的无界队列。它可以确保按照元素的优先级顺序进行处理,优先级较高的元素会被优先处理。

5.DelayedWorkQueue
一个支持延时获取元素的无界队列,用于实现定时任务。元素需要实现Delayed接口
通常用于ScheduledThreadPoolExecutor中。

2.5 线程工厂的作用, 以及使用方法(难度:★ 频率:★★)

ThreadFactory是一个接口, 用于创建新线程的工厂, 它允许你自定义线程的创建过程, 例如设置线程的名称、优先级、守护状态等…

import java.util.concurrent.*;

public class CustomThreadFactoryExample {
    public static void main(String[] args) {
        // 创建一个自定义的ThreadFactory
        ThreadFactory customThreadFactory = new CustomThreadFactory("CustomThread");

        // 使用自定义的ThreadFactory创建线程池
        ExecutorService executorService = Executors.newFixedThreadPool(5, customThreadFactory);

        // 提交一些任务
        for (int i = 0; i < 10; i++) {
            executorService.submit(() -> System.out.println(Thread.currentThread().getName()));
        }

        // 关闭线程池
        executorService.shutdown();
    }
}

// 自定义的ThreadFactory实现
class CustomThreadFactory implements ThreadFactory {
    private final String threadNamePrefix;

    public CustomThreadFactory(String threadNamePrefix) {
        this.threadNamePrefix = threadNamePrefix;
    }

    @Override
    public Thread newThread(Runnable r) {
        // 创建新线程并设置线程名称
        Thread thread = new Thread(r, threadNamePrefix + "-" + System.nanoTime());
        // 设置为后台线程(可选)
        thread.setDaemon(false);
        // 设置线程优先级(可选)
        thread.setPriority(Thread.NORM_PRIORITY);
        return thread;
    }
}

2.6 线程池拒绝策略有哪些?默认是哪个?(难度:★ 频率:★★★★)

1.AbortPolicy
这是默认的拒绝策略,当队列满时直接抛出RejectedExecutionException异常,阻止系统继续运行。

ExecutorService executor = new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    timeUnit,
    new LinkedBlockingQueue<>(capacity),
    Executors.defaultThreadFactory(),
    new ThreadPoolExecutor.AbortPolicy());

2.CallerRunsPolicy
新任务会被直接在提交任务的线程中运行。这样做可以避免任务被拒绝,但会影响任务提交的线程的性能。

ExecutorService executor = new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    timeUnit,
    new LinkedBlockingQueue<>(capacity),
    Executors.defaultThreadFactory(),
    new ThreadPoolExecutor.CallerRunsPolicy());

3.DiscardPolicy
新任务被直接丢弃,不做任何处理。

ExecutorService executor = new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    timeUnit,
    new LinkedBlockingQueue<>(capacity),
    Executors.defaultThreadFactory(),
    new ThreadPoolExecutor.DiscardPolicy());

4.DiscardOldestPolicy
尝试将最旧的未处理任务从队列中删除,然后重新尝试执行任务

ExecutorService executor = new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    timeUnit,
    new LinkedBlockingQueue<>(capacity),
    Executors.defaultThreadFactory(),
    new ThreadPoolExecutor.DiscardOldestPolicy());

2.7 线程池有哪些提交方法(难度:★ 频率:★★★★)

1.execute
用于提交普通的可运行任务(Runnable), 没有办法获取任务执行的结果和异常

public void execute(Runnable command)

异常处理: execute方法提交任务后,异常会被线程池中的线程捕获并处理,但是这个异常处理是在线程内部进行的,不会传递到主线程中。

public static void main(String[] args) {
    try {
        System.out.println("主线程开始");
        // 创建线程池
        ExecutorService executor = Executors.newSingleThreadExecutor();
        // 执行线程方法
        executor.execute(()->{
            System.out.println("子线程运行开始");
            int i = 1 / 0;
            System.out.println("子线程运行结束");
        });
        executor.shutdown();
        System.out.println("主线程结束");
    } catch (Exception e) {
        System.out.println("异常信息:" + e.getMessage());
    }
}

在这里插入图片描述
在上面的代码中, 异常无法被捕获的原因是因为异常发生在子线程中,而主线程并不直接捕获这个异常。

execute方法提交任务后,异常会被线程池中的线程捕获并处理,但是这个异常处理是在线程内部进行的,不会传递到主线程中。

2.submit
提交可调度任务(Callable), 返回一个Future对象, 通过这个对象可以判断任务的执行状态和获取执行结果

public Future<?> submit(Runnable task)
public <T> Future<T> submit(Callable<T> task)
public <T> Future<T> submit(Runnable task, T result)

异常处理: submit方法可以通过Future对象的get方法来获取任务执行过程中抛出的异常。如果任务执行过程中发生异常,get方法会抛出ExecutionException。

try {
    System.out.println("主线程开始");
    ExecutorService executor = Executors.newSingleThreadExecutor();
    Future<?> future = executor.submit(() -> {
        System.out.println("运行开始");
        int i = 1 / 0;
        System.out.println("运行结束");
    });
    executor.shutdown();

    // 获取任务执行的结果,这里会阻塞直到任务完成
    future.get();
    System.out.println("主线程结束");
} catch (Exception e) {
    System.out.println("捕获到异常:" + e.getMessage());
}

submit适用于提交既可以是Runnable也可以是Callable 的任务,可以获取任务执行结果,更灵活。

3.schedule
ScheduledThreadPoolExecutor类的方法, 用于提交定时任务, 它支持在将来的某个时间点执行任务, 以及以一定时间间隔执行重复任务

public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit)
public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit)

4.invokeAll
提交一组可调用任务,返回一个包含所有Future对象的列表。在所有任务都完成后,调用线程将得到一个包含各个任务执行结果的列表。

注意: 在所有任务完成之前, invokeAll方法会一直阻塞

ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.SECONDS, workQueue);
List<Callable<String>> tasks = Arrays.asList(new MyCallable(), new AnotherCallable());
List<Future<String>> futures = executor.invokeAll(tasks);

5.invokeAny
提交一组可调用任务,并返回其中一个成功执行的任务的结果。如果其中一个任务成功执行,其他任务将被取消。

ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.SECONDS, workQueue);
List<Callable<String>> tasks = Arrays.asList(new MyCallable(), new AnotherCallable());
String result = executor.invokeAny(tasks);

2.8 submit和execute方法的区别(难度:★ 频率:★★)

submit和execute都可以提交任务, 两者有一些关键的区别

  • 返回值
    • execute 方法没有返回值,它用于执行实现了Runnable接口的任务。
    • submit 方法返回一个Future对象,该对象可以用于获取任务执行的结果。这是因为submit可以执行实现了Callable接口的任务,它们可以返回一个结果。
  • 任务类型
    • execute 方法接受Runnable类型的任务,这种任务没有返回值。
    • submit 方法既可以接受Runnable类型的任务,也可以接受Callable类型的任务,后者可以返回结果。
  • 异常处理
    • execute方法无法处理任务执行过程中抛出的异常,异常会被直接抛到调用者
    • submit方法可以通过Future对象的get方法来获取任务执行过程中抛出的异

Callable的返回值类型是在实现接口时指定的, 例如下面这个例子

public class GetStrService implements Callable<String> {
    private int i;

    public GetStrService(int i) {
        this.i = i;
    }

    @Override
    public String call() throws Exception {
        int t=(int) (Math.random()*(10-1)+1);
        System.out.println("第"+i+"个任务开始啦:"+Thread.currentThread().getName()+"准备延时"+t+"秒");
        Thread.sleep(t*1000);
        return "第"+i+"个GetStrService任务使用的线程:"+Thread.currentThread().getName();
    }
}

2.9 如何捕获线程池中的异常? (难度:★★ 频率:★★★★★)

  • 方式一: 使用submit提交任务, 并使用阻塞方法(get)直达任务完成, 如果这个过程中发生了异常, 会在主线程中被捕获(上文有案例)
  • 方式二: 自定义ThreadPoolExecutor作为线程池

自定义ThreadPoolExecutor作为线程池

// 自定义线程池
class MyThreadPoolExecutor extends ThreadPoolExecutor {
    public MyThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
    }

    @Override
    protected void afterExecute(Runnable r, Throwable t) {
        super.afterExecute(r, t);
        System.out.println("捕获到异常。异常信息为:" + t.getMessage());
        System.out.println("异常栈信息为:");
        t.printStackTrace();
    }
}

public static void main(String[] args) {
    System.out.println("主线程开始");
    // 创建线程池
    ExecutorService executor = new MyThreadPoolExecutor(5,50, 3, TimeUnit.SECONDS, new LinkedBlockingQueue<>(20));
    // 执行线程方法
    executor.execute(()->{
        System.out.println("子线程运行开始");
        int i = 1 / 0;
        System.out.println("子线程运行结束");
    });
    executor.shutdown();
    System.out.println("主线程结束");
}

在这里插入图片描述

2.10 线程池执行流程(难度:★★ 频率:★★★★★)

并发编程(高并发、多线程) 第二章_第3张图片

在这里插入图片描述

2.11 Executors类创建四种常见线程池(难度:★★ 频率:★★★)

FixedThreadPool SingleThreadExecutor ScheduledThreadPool CachedThreadPool
名称 固定大小线程池 单线程线程池 定时任务线程池 缓存线程池
特点 固定线程数量的线程池,适用于负载较重的服务器 只有一个工作线程的线程池,确保所有任务按顺序执行 支持定时及周期性任务执行的线程池 线程数量根据需求动态调整,线程空闲一定时间后被回收

1.FixedThreadPool
创建一个固定大小的线程池,可控制并发的线程数,超出的线程会在队列中等待

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
}
  • corePoolSize与maximumPoolSize相等,即其线程全为核心线程,是一个固定大小的线程池,是其优势;
  • keepAliveTime = 0 该参数默认对核心线程无效,而 FixedThreadPool 全部为核心线程;
  • workQueue 为 LinkedBlockingQueue(无界阻塞队列),队列最大值为 Integer.MAX_VALUE。
    如果任务提交速度持续大于任务处理速度,会造成队列大量阻塞。因为队列很大,很有可能在拒绝策略前,内存溢出。是其劣势;
  • FixedThreadPool 的任务执行是无序的;
public class NewFixedThreadPoolTest {

    public static void main(String[] args) {
        System.out.println("主线程启动");
        // 1.创建1个有2个线程的线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(2);

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(2000);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                System.out.println("任务被执行,线程:" + Thread.currentThread().getName());
            }
        };
        // 2.线程池执行任务(添加4个任务,每次执行2个任务,得执行两次)
        threadPool.submit(runnable);
        threadPool.execute(runnable);
        threadPool.execute(runnable);
        threadPool.execute(runnable);
        System.out.println("主线程结束");
    }
}

上述代码:创建了一个有2个线程的线程池,但一次给它分配了4个任务,每次只能执行2个任务,所以,得执行两次。

该线程池重用固定数量的线程在共享的无界队列中运行。 在任何时候,最多 nThreads 线程将是活动的处理任务。如果在所有线程都处于活动状态时提交了其他任务,它们将在队列中等待,直到有线程可用。 所以,它会一次执行 2 个任务(2 个活跃的线程),另外 2 个任务在工作队列中等待着。

submit() 方法和 execute() 方法都是执行任务的方法。它们的区别是:submit() 方法有返回值,而 execute() 方法没有返回值。

2.CachedThreadPool
创建一个可缓存的线程池,若线程数超过处理所需,缓存一段时间后会回收,若线程数不够,则新建线程。

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
}
  • corePoolSize = 0,maximumPoolSize = Integer.MAX_VALUE,即线程数量几乎无限制;
  • keepAliveTime = 60s,线程空闲 60s 后自动结束
  • workQueue 为 SynchronousQueue 同步队列,这个队列类似于一个接力棒,入队出队必须同时传递,因为 CachedThreadPool 线程创建无限制,不会有队列等待,所以使用 SynchronousQueue

适用场景:快速处理大量耗时较短的任务,如 Netty 的 NIO 接受请求时,可使用 CachedThreadPool。

public class NewCachedThreadPool {

    public static void main(String[] args) {
        ExecutorService threadPool = Executors.newCachedThreadPool();

        for (int i = 0; i < 10; i++) {
            threadPool.execute(() -> {
                System.out.println("任务被执行,线程:" + Thread.currentThread().getName());
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            });
        }
    }
}

3.SingleThreadExecutor
创建单个线程数的线程池,它可以保证先进先出的执行顺序。

4.SingleThreadScheduledExecutor
创建一个单线程的可以执行延迟任务的线程池。

public class SingleThreadScheduledExecutorTest {

    public static void main(String[] args) {
        ScheduledExecutorService threadPool = Executors.newSingleThreadScheduledExecutor();
        System.out.println("添加任务,时间:" + new Date());
        threadPool.schedule(() -> {
            System.out.println("任务被执行,时间:" + new Date());
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
            }
        }, 2, TimeUnit.SECONDS);
    }
}

2.12 怎么关闭线程池(难度:★ 频率:★★★)

关闭线程池的正确方式是调用其shutdown方法。这个方法会平滑地关闭线程池,不再接受新的任务,但会让已经在队列中的任务执行完毕。随后,线程池会进入TERMINATED状态。

你可能感兴趣的:(java)