javaEE -5(8000字详解多线程)

一:JUC(java.util.concurrent) 的常见类

1.1 ReentrantLock

可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全,ReentrantLock 也是可重入锁. “Reentrant” 这个单词的原意就是 “可重入”

ReentrantLock 的用法:

  1. lock() 方法:

    • void lock():如果锁可用,获取锁。如果锁不可用,当前线程会被阻塞,直到获取到锁为止。这是一个典型的阻塞式调用。
  2. tryLock() 方法:

    • boolean tryLock():尝试获取锁。如果锁可用,立即获取锁,并返回 true。如果锁不可用,立即返回 false,而不会等待。
    • boolean tryLock(long timeout, TimeUnit unit):加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁,如果在指定的超时时间内获取到锁,则返回 true,否则返回 false。
  3. unlock() 方法:

    • void unlock():释放锁,允许其他线程尝试获取锁。在使用完锁之后,必须显式地调用此方法进行解锁,否则会导致死锁。

下面是一个使用 ReentrantLock 的简单示例:

import java.util.concurrent.locks.ReentrantLock;

class MyThread implements Runnable {
    private static ReentrantLock lock = new ReentrantLock();

    @Override
    public void run() {
        try {
            lock.lock(); // 加锁
            // 临界区代码
        } finally {
            lock.unlock(); // 解锁
        }
    }
}

public class Main {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        Thread thread1 = new Thread(myThread);
        Thread thread2 = new Thread(myThread);

        thread1.start();
        thread2.start();
    }
}

ReentrantLock 和 synchronized 的区别:

  • synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock是标准库的一个类, 在 JVM 外实现的(基于 Java 实现).
  • synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活,但是也容易遗漏
    unlock.
  • synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
  • synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
// ReentrantLock 的构造方法
public ReentrantLock(boolean fair) {
  sync = fair ? new FairSync() : new NonfairSync();
}
  • 更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒.每次唤醒的是一个随机等待的线程.ReentrantLock 搭配 Condition 类实现等待-唤醒,可以更精确控制唤醒某个指定的线程.

如何选择使用哪个锁?

  • 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
  • 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
  • 如果需要使用公平锁, 使用 ReentrantLock.

1.2 原子类

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个

  • AtomicBoolean
  • AtomicInteger
  • AtomicIntegerArray
  • AtomicLong
  • AtomicReference
  • AtomicStampedReference

以 AtomicInteger 举例,常见方法有

addAndGet(int delta);  i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;

二:线程池

虽然创建销毁线程比创建销毁进程更轻量, 但是在频繁创建销毁线程的时候还是会比较低效.

线程池就是为了解决这个问题. 如果某个线程不再使用了, 并不是真正把线程释放, 而是放到一个 "池子"中, 下次如果需要用到线程就直接从池子中取, 不必通过系统来创建了.

2.1 ExecutorService 和 Executors

代码示例:

  • ExecutorService 表示一个线程池实例.
  • Executors 是一个工厂类, 能够创建出几种不同风格的线程池.
  • ExecutorService 的 submit 方法能够向线程池中提交若干个任务.
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
  @Override
  public void run() {
    System.out.println("hello");
 }
});

Executors 创建线程池的几种方式

  • newFixedThreadPool: 创建固定线程数的线程池
  • newCachedThreadPool: 创建线程数目动态增长的线程池.
  • newSingleThreadExecutor: 创建只包含单个线程的线程池.
  • newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.

我们之前提到过:Executors 本质上是 ThreadPoolExecutor 类的封装,那么什么是ThreadPoolExecutor呢?

2.2 ThreadPoolExecutor

ThreadPoolExecutor 提供了更多的可选参数, 可以进一步细化线程池行为的设定.

ThreadPoolExecutor是Java中用于创建和管理线程池的类。它提供了多个不同的构造方法来满足不同的需求。下面是ThreadPoolExecutor常用的四个构造方法的讲解:

  1. ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue):
    该构造方法指定了核心线程池大小(corePoolSize)、最大线程池大小(maximumPoolSize)、线程的空闲时间(keepAliveTime)以及这些参数的单位(unit),还有一个用于保存等待执行任务的阻塞队列(workQueue)。当一个任务提交到线程池时,如果线程池中的线程数量小于corePoolSize,则会创建一个新的线程来执行任务。如果线程池中的线程数量已经达到corePoolSize,但是阻塞队列(workQueue)还未满,则将任务添加到阻塞队列中等待执行。如果阻塞队列已满,且线程池中的线程数小于maximumPoolSize,则创建新的线程来执行任务。如果线程池中的线程数已达到maximumPoolSize且阻塞队列也已满,则根据ThreadPoolExecutor的拒绝策略来处理该任务。

  2. ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory):
    该构造方法与前一个构造方法相同,不过多了一个参数threadFactory,用于自定义创建线程的工厂类。可以通过自定义线程工厂来为线程池中的线程指定名称、设置线程的优先级等。

  3. ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, RejectedExecutionHandler handler):
    该构造方法与第一个构造方法相同,不过多了一个参数handler,用于处理任务被拒绝后的策略。RejectedExecutionHandler是一个接口,有四种预定义的实现:AbortPolicy(默认策略,直接抛出RejectedExecutionException异常)、CallerRunsPolicy(使用调用线程来执行被拒绝的任务)、DiscardOldestPolicy(丢弃阻塞队列中最旧的任务)、DiscardPolicy(直接丢弃被拒绝的任务)。

  4. ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler):
    该构造方法将前面三个构造方法的参数都包含进来,并多了一个threadFactory和handler参数。

理解 ThreadPoolExecutor 构造方法的参数
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.

  • corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
  • maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
  • keepAliveTime: 临时工允许的空闲时间.
  • unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
  • workQueue: 传递任务的阻塞队列
  • threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
  • RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
  • AbortPolicy(): 超过负荷, 直接抛出异常.
  • CallerRunsPolicy(): 调用者负责处理
  • DiscardOldestPolicy(): 丢弃队列中最老的任务.
  • DiscardPolicy(): 丢弃新来的任务.

代码示例:

ExecutorService pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS,
                       new SynchronousQueue<Runnable>(),
                       Executors.defaultThreadFactory(),
                       newThreadPoolExecutor.AbortPolicy());
for(int i=0;i<3;i++) {
  pool.submit(new Runnable() {
    @Override
    void run() {
      System.out.println("hello");
   }
 });
}

2.3线程池的工作流程

javaEE -5(8000字详解多线程)_第1张图片

2.4 信号量 Semaphore

信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.

理解信号量:
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.

当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)

当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)

如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.

注意:Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.

代码示例:

  • 创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源.
  • acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
  • 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果.
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
  @Override
  public void run() {
    try {
      System.out.println("申请资源");
      semaphore.acquire();
      System.out.println("我获取到资源了");
      Thread.sleep(1000);
      System.out.println("我释放资源了");
      semaphore.release();
   } catch (InterruptedException e) {
      e.printStackTrace();
   }
 }
};
for (int i = 0; i < 20; i++) {
  Thread t = new Thread(runnable);
  t.start();
}

2.5 CountDownLatch

就好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。

  • 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
  • 每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
  • 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.
public class Demo {
  public static void main(String[] args) throws Exception {
    CountDownLatch latch = new CountDownLatch(10);
    Runnable r = new Runable() {
      @Override
      public void run() {
        try {
          Thread.sleep(Math.random() * 10000);
          latch.countDown();
       } catch (Exception e) {
          e.printStackTrace();
       }
     }
   };
    for (int i = 0; i < 10; i++) {
      new Thread(r).start();
   }
 // 必须等到 10 人全部回来
    latch.await();
    System.out.println("比赛结束");
    }
}

三:线程安全的集合类

原来的集合类, 大部分都不是线程安全的(Vector, Stack, HashTable, 是线程安全的其他的集合类不是线程安全的)

3.1多线程环境使用 ArrayList

  1. 自己使用同步机制 (synchronized 或者 ReentrantLock)
    前面做过很多相关的讨论了. 此处不再展开.

  2. Collections.synchronizedList(new ArrayList);
    synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
    synchronizedList 的关键操作上都带有 synchronized

  3. 使用 CopyOnWriteArrayList
    CopyOnWrite容器即写时复制的容器。

  • 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,
  • 添加完元素之后,再将原容器的引用指向新的容器。

这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

优点:

  • 在读多写少的场景下, 性能很高, 不需要加锁竞争.

缺点:

  1. 占用内存较多.
  2. 新写的数据不能被第一时间读取到.

3.2 多线程环境使用队列

  1. ArrayBlockingQueue
    基于数组实现的阻塞队列

  2. LinkedBlockingQueue
    基于链表实现的阻塞队列

  3. PriorityBlockingQueue
    基于堆实现的带优先级的阻塞队列

  4. TransferQueue
    最多只包含一个元素的阻塞队列

3.3 多线程环境使用哈希表

HashMap 本身不是线程安全的,在多线程环境下使用哈希表可以使用:

  • Hashtable
  • ConcurrentHashMap
  1. Hashtable
    只是简单的把关键方法加上了 synchronized 关键字.
    在这里插入图片描述
    在这里插入图片描述

这相当于直接针对 Hashtable 对象本身加锁.

  • 如果多线程访问同一个 Hashtable 就会直接造成锁冲突.
  • size 属性也是通过 synchronized 来控制同步, 也是比较慢的.
  • 一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低.

javaEE -5(8000字详解多线程)_第2张图片
2) ConcurrentHashMap
相比于 Hashtable 做出了一系列的改进和优化. 以 Java1.8 为例

  • 读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁. 加锁的方式仍然是是用
    synchronized, 但是不是锁整个对象, 而是 “锁桶” (用每个链表的头结点作为锁对象), 大大降低了锁冲突的概率.
  • 充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况.
  • 优化了扩容方式: 化整为零

扩容方式为:

  1. 发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.
  2. 扩容期间, 新老数组同时存在.
  3. 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小部分元素.
  4. 搬完最后一个元素再把老数组删掉.
  5. 这个期间, 插入只往新数组加.
  6. 这个期间, 查找需要同时查新数组和老数组

javaEE -5(8000字详解多线程)_第3张图片

四:死锁

死锁是多个进程或线程由于竞争共享资源而陷入的一种无限等待的状态,导致它们都无法继续执行。在死锁中,每个进程或线程都在等待其他进程或线程释放资源,但这些资源都被其他进程或线程占用,从而导致所有参与者都无法继续执行,形成了相互等待的循环。

死锁通常发生在以下四个必要条件同时满足时:

  1. 互斥条件:至少有一个资源在任意时刻只能被一个进程或线程占用。
  2. 请求和保持条件:进程或线程至少要占用一个资源,并请求其他资源。
  3. 不剥夺条件:资源只能由持有者显式地释放,其他进程无法强制性地剥夺资源。
  4. 循环等待条件:形成一个等待资源的环路,每个进程或线程都在等待下一个资源的释放。

当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。

你可能感兴趣的:(javaEE,java-ee,java)