Java 并发编程(七)|JUC 工具类与线程安全集合考点

并发

  • 前言
  • JUC工具类
    • Callable 接口
      • 示例
      • 小结
    • ReentrantLock 可重入锁
      • 使用
      • 优势
      • 劣势
    • 信号量 Semaphore
      • 示例
    • CountDownLatch
      • 示例
  • 线程安全的集合类
    • 多环境使用ArrayList
    • 多线程环境使用队列
    • 多线程环境使用哈希表
      • Hashtable
      • ConcurrentHashMap
  • 全文总结
  • 🔗 系列文章导航

前言

上一篇我们讲解了各类锁策略、CAS 无锁编程、synchronized 底层锁升级优化。本篇聚焦并发工程实战内容,完整梳理 JUC 包核心工具类与各类线程安全集合,覆盖 Callable、ReentrantLock、Semaphore、CountDownLatch 以及 ConcurrentHashMap 等高频面试考点,配套可运行案例,补齐 Java 并发编程业务落地相关知识点。

JUC工具类

JUC(java.util.concurrent):
这里的内容主要是一些多线程相关的组件。

Callable 接口

Callable 接口:带有泛型参数的接口,有返回值,一种创建线程的方式。

  1. 和 Runnable 对比:
    (1)Runnable.run():无返回值;
    (2)Callable.call():支持返回执行结果,可抛出异常。
  2. 注意:Callable 不能直接传入 Thread 构造方法,必须依靠FutureTask包装。

示例

实现使用Callable来创建线程计算1+2+3+…+1000。

importjava.util.concurrent.Callable;importjava.util.concurrent.ExecutionException;importjava.util.concurrent.FutureTask;publicclassMain{publicstaticvoidmain(String[]args)throwsExecutionException,InterruptedException{//定义任务Callable<Integer>callable=newCallable<Integer>(){@OverridepublicIntegercall()throwsException{intsum=0;for(inti=1;i<=1000;i++){sum+=i;}returnsum;}};//把任务放到线程中执行//callable不能直接传给Thread的构造方法,要借用中间的类包装一下//futureTask相当于号码牌,拿着号码牌取结果FutureTask<Integer>futureTask=newFutureTask<>(callable);Threadt=newThread(futureTask);t.start();//此处的get就能获取到callable里面返回的结果//线程是并发执行,执行到主线程的get时,t线程可能还没执行完,此时,get就会阻塞System.out.println(futureTask.get());}}

小结

线程的创建方式:

  1. 继承Thread类,重写run方法(可以创建单独的类,也可以使用匿名内部类)。
  2. 实现Runnable接口,重写run方法(可以创建单独的类,也可以使用匿名内部类)。
  3. 实现Callable接口,重写call方法(可以创建单独的类,也可以使用匿名内部类)。
  4. 使用lambda表达式。
  5. ThreadFactory,线程工厂。
  6. 线程池。

ReentrantLock 可重入锁

使用

ReentrantLocklock=newReentrantLock();lock.lock();//加锁try{//working}finally{lock.unlock();//一定不能忘记unlock的调用}

相对来说,synchronized是Java的内置锁,由JVM自动管理锁的获取与释放,无需手动调用解锁方法,因此不会出现因忘记解锁或异常导致锁无法释放的问题。

优势

  1. ReentrantLock 在加锁时有两种方式:lock,与synchronized类似,若遇到锁冲突,就会一直阻塞等待;trylock,尝试加锁,若没加上就放弃,给了更多的可操作空间。
  2. ReentrantLock 提供了公平锁的实现,默认情况下是非公平锁。
  3. ReentrantLock 提供了更强大的等待通知机制。搭配Condition类实现等待通知,可显示唤醒几个线程。

劣势

ReentrantLock 使用起来复杂,容易忘记解锁。
加锁时,首选synchronized(背后还有一系列的优化手段)。

信号量 Semaphore

信号量,就是一个计数器,描述了“可用资源”的个数。
信号量用法:

  1. 每次申请一个可用资源,计数器-1 ⇒ P操作(原子操作)acquire。
  2. 每次释放一个可用资源,计数器+1 ⇒ V操作(原子操作)release。

假设信号量初始值为10,已经进行了十次P操作之后,值为零,此时继续进行P操作,会阻塞等待。

锁本质上就是一种特殊的信号量,即可用资源为1的信号量,值只可能为1和0,也就是二元信号量。

操作系统提供了信号量实现,提供了API,JVM封装了这样的API,就可以在Java代码中使用。

示例

publicclassMain{publicstaticvoidmain(String[]args)throwsInterruptedException{Semaphoresemaphore=newSemaphore(4);semaphore.acquire();System.out.println("P 操作");semaphore.acquire();System.out.println("P 操作");semaphore.acquire();System.out.println("P 操作");semaphore.acquire();System.out.println("P 操作");semaphore.acquire();System.out.println("P 操作");//semaphore.release();}}

开发中如果遇到了需要申请资源的场景,就可以使用信号量来实现了。

CountDownLatch

CountDownLatch,适用于多个线程来完成一系列任务的时候,用来衡量任务的进度是否完成。
CountDownLatch 的方法:

  1. await,调用时会阻塞,就会等待其他线程完成任务,所有的线程都完成了任务之后,此时await才会返回,就会继续往下走。
  2. countDown,告诉countDownLatch,当前这一个子任务完成了。
  3. 如果 countDown 调用总次数小于初始化数值,await 会永久阻塞。

示例

publicclassMain{publicstaticvoidmain(String[]args)throwsInterruptedException{//十个选手参赛,await会在10次调用完countDown之后才能继续执行CountDownLatchcountDownLatch=newCountDownLatch(10);//如果循环次数少于10,await会一直处于阻塞状态for(inti=0;i<10;i++){intid=i;Threadt=newThread(()->{System.out.println("thread "+id);try{Thread.sleep(500);}catch(InterruptedExceptione){thrownewRuntimeException(e);}//通知,当前任务已经执行完毕countDownLatch.countDown();});t.start();}countDownLatch.await();System.out.println("所有任务都完成了");}}

线程安全的集合类

数据结构中大部分的集合类都是线程不安全的,早期Vector,Stack,HashTable线程安全(自带synchronized)。

那么,如何保证线程安全呢?
1.加锁。
2.标准库也提供了一些搭配的组件,保证线程安全。

多环境使用ArrayList

  1. 使用同步机制(synchronized或ReentrantLock)。
  2. Collections.synchronizedList(new ArrayList):会返回一个新的对象,这个新的对象相当于给ArrayList套了一层壳,这层壳就是在方法上直接使用synchronized。
  3. 使用CopyOnWriteArrayList:写时拷贝
    (1)比如两个线程使用同一个ArrayList,可能会读,可能会写;两个线程读,就直接读取;若某个线程需要进行修改,就把ArrayList复制出来一个副本,该线程的修改操作就是在修改这个副本,与此同时,另一个线程仍然可以读取数据,是在原来的数据上读取,一旦这边修改完毕,就会使用修改好的副本替代掉原来的数据(往往就是一个引用赋值)。
    (2)当前操作的ArrayList不能太大,拷贝成本太高。
    (3)更适合于一个线程修改,多个线程读取的情况。
    (4)适合于服务器的配置更新;可以通过配置文件来描述配置的详细内容,配置的内容会被读到内存中,再由其他的线程读取这里的内容,但是修改这个配置内容,往往只有一个线程来修改。⇒ 使用某个命令让服务器重新加载配置,就可以使用写时拷贝的方式了。

多线程环境使用队列

  1. ArrayBlockingQueue:基于数组实现的阻塞队列。
  2. LinkedBlockingQueue:基于链表实现的阻塞队列。
  3. PriorityBlockingQueue:基于堆实现的带优先级的阻塞队列。
  4. TransferQueue:最多只包含一个元素的阻塞队列。

多线程环境使用哈希表

HashMap本身不是线程安全的

Hashtable

Hashtable 保证线程安全,主要就是给关键方法加上synchronized,直接加到方法上,相当于给this加锁。
只要两个线程在操作同一个Hashtable就会出现锁冲突。


1.在不考虑触发扩容的前提下,操作不同的链表是线程安全的,比如A和C。
2.相比之下,若两个线程操作的是同一个链表,才容易出现问题,比如A和B。
3.若两个线程操作的不是同一个链表,就不用加锁,否则,需要加锁。

ConcurrentHashMap

ConcurrentHashMap 最核心的改进:把一个全局的大锁,改进成了每个链表独立的一把小锁(把每个链表的头结点作为锁对象,synchronized可以使用任意对象作为锁对象),大幅度降低了锁冲突概率。
一个哈希表有很多这样的链表,两个线程恰好同时访问同一个链表的情况较少。

ConcurrentHashMap 充分利用了CAS特性,把一些不必要加锁的环节省略加锁了。
比如,使用变量记录hash表中的元素个数,就可以使用原子操作(CAS)修改元素个数

ConcurrentHashMap,针对读操作没有加锁。ConcurrentHashMap 在底层编码过程在=中,处理了一些细节,修改时会避免使用++、–这种非原子的操作,而是使用=进行修改,本身就是原子的,因此读的时候,要么读到写之前的旧值,要么读到写之后的新值,不会出现读到一个修改到一半的值。
写和写之间还是要加锁的。

ConcurrentHashMap针对扩容操作,做出了单独的优化。
本身HashMap和Hashtable在扩容时都需要把所有的元素都拷贝一遍,若元素很多,拷贝就比较耗时。
化整为零:一旦需要扩容,确实需要搬运,但不是在一次操作中搬运完成,而是分成多次来搬运,每次只搬运一部分数据,避免单次操作过于卡顿。

在Java8之前,ConcurrentHashMap 是基于分段锁(多个链表共用一把锁)的方式实现的,Java8之后就是直接在链表头结点加锁的形式。

全文总结

  1. Callable+FutureTask 实现带返回值线程;
  2. ReentrantLock、Semaphore、CountDownLatch 开发场景与底层特性;
  3. 各类线程安全 List、阻塞队列、哈希表对比,重点掌握 ConcurrentHashMap JDK8 优化细节。

至此 Java 并发编程系列全部知识点讲解完毕!整套专栏从基础线程、同步机制、锁、CAS、JUC 工具到并发容器完整闭环,全部代码均可直接运行。欢迎点赞收藏整套专栏,评论区交流面试问题!

🔗 系列文章导航

本篇是「Java并发编程系列」的连载内容,点击链接查看完整系列:

🔹 上一篇:Java 并发编程(六)|并发进阶高频:CAS、锁升级
🔹 下一篇:Java IO|File 文件操作 + 字节流 / 字符流完整笔记 + 递归删除文件实战
👉 点击直达「Java并发编程」专栏合集