【JavaEE】多线程 -- JUC常见类和线程安全的集合类
目录
- JUC(java.util.concurrent)常见类
- Callable接口
- 总结线程创建面试题
- ReentrantLock(可重入锁)
- ReentrantLock和sychronized区别面试题
- 信号量 Semaphore
- CountDownLatch
- 线程安全的集合类
- 线程安全下使用ArrayList
- 线程安全下使用队列
- 线程安全下使用哈希表
- HashTable和ConcurrentHashMap对比
JUC(java.util.concurrent)常见类
Callable接口
-
这个接口和Runnable接口是一样的作用. 就是描述线程执行的任务. 只不过Runnable接口的run方法并不返回结果, 而Callable接口的call方法可以返回结果. 如果我们想知道线程执行任务的结果, 就可以通过使用Callable接口来实现
-
如图我们如果需要任务返回1+…100的结果
-
此时我们要执行这个任务还需要创建一个线程来执行
-
但是我们看到图中, 我们直接和Runnable给创建线程的Thread类构造方法去创建线程并不支持, Thread类并没有对应这个接口的构造函数.
-
这里就需要我们使用callable对象通过FutureTask类创建一个对象出来, 把这个FutureTask对象通过Thread类的构造函数创建线程出来. 这里的FutureTask就是相当于一个号码牌, 标识着线程对应的任务.
总结线程创建面试题
ReentrantLock(可重入锁)
- ReentrantLock是一把可重入锁. 他和synchronized这把锁的用法有些类似. 一样可以通过加锁和解锁来解决线程安全问题.
ReentrantLock和sychronized区别面试题
-
sychronized通过{}花括号来自动加锁和解锁. ReentrantLock沟通过lock和unlock方法来手动加锁和解锁(这里就要注意unlock方法一定要被调用到, 不能因为return和抛异常提前结束方法等原因没调用到unlock方法)
-
sychronized是关键字(内部实现是JVM内部通过C++代码来实现的), ReentrantLock是Java标准库的类.
-
synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的⽅式等待⼀段时间就放弃.在规定时间内加锁成功返回true, 超过等待时间并且加锁失败, 返回false
-
ReentrantLock提供了公平锁的实现, 在创建锁的时候给构造方法传参true, 就是公平锁
-
更强⼤的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是⼀个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.
-
如何选择使用哪个锁?
信号量 Semaphore
-
信号量就是表示可用资源个数, 本质上就是一个计数器. 能够协调多个进程之间的资源分配, 也能协调同个进程不同线程之间的资源分配.
-
其实我们信号量有点类似锁. 比如可用资源只有1个的时候, 就相当于一把锁. 此时一个线程拿到了这个可用资源. 另外一个线程也想拿一个可用资源. 由于可用资源已经为0, 所以这个线程只能阻塞等待. 等待拿到可用资源的线程把这个可用资源释放(类似解锁). 才能拿到这个可以资源.
CountDownLatch
- 我们使用多线程的原因通常是为了完成某一个很大的任务但是一个线程完成的效率太低了. 所以我们把这个大任务拆分成各个小任务, 让多个线程去执行这些小任务. 当所有小任务完成的时候, 那么这个大任务就完成了.
- 那么这个时候就涉及到一个问题 , 如何判定所有的小任务都完成了呢? 这个时候就要使用我们的CountDownLatch了
- 下面是如何使用CountDownLatch的方法
- 使用构造方法的时候指定参数, 描述把大任务拆解成了多少个小任务
- 每个任务执行完毕后, 都调用一次 CountDown方法
- 主线程中调用await方法, 等待所有任务执行完毕的时候, await方法就会返回. 没执行完的时候阻塞等待
线程安全的集合类
线程安全下使用ArrayList
- 第一种方法就是对ArrayList的方法进行自行加锁操作. 这个方法是推荐的. 但是我们需要自己分析哪里要加锁保证线程安全.
- 第二种方法就是通过这个方法来对ArrayList的关键方法套一层锁. 这个是对整个方法加锁.也就是对this加锁. 有可能在不该加锁的地方也直接加锁会降低代码性能. 这个方法是不推荐的.
- 第三种方法就是使用写时拷贝, 出这个方法就是通过对ArrayList加元素的时候(实质上就是修改共享数据)把容器进行整体复制一份(这个复制操作一定是原子性的), 后面的线程在复制完成之前读取旧的数据, 复制完成之后. 读取新的数据. 这样读取到的要么是旧数据,要么就是新的数据. 不会出现只更新一半的数据.
- 缺点不适合容器非常大的复制.并且在多个线程同时修改的时候, 也容易出问题. 修改操作不是原子性的.
线程安全下使用队列
- 阻塞队列内置了锁. 天生线程安全. 所以直接使用阻塞队列
线程安全下使用哈希表
- HashMap类的方法没有内置锁什么的, 天生不是线程安全的. 所以只适合在单线程的场景下使用
- HashTable是线程安全的他的所以方法都加了锁(this加锁, 也就是整体方法). 可以看到整体加锁有可能导致不该加锁的地方也加了锁. 导致了代码效率不是很高.
- 这个时候就有了ConcurrentHashMap这个类, 他也是线程安全的. 不过加锁的方法就和HashTable不一样了
HashTable和ConcurrentHashMap对比
- 导致我们HashTable效率低的根本原因就是因为他对整个方法都加上了锁. 也就是当我们访问哈希数组中不同位置的两个元素. 都会产生锁竞争
- 这里我们访问一个线程访问0, 一个线程访问2两个不同元素位置, 都会产生锁竞争. 但是我们知道, 对不同数据修改是不会导致线程安全问题的.
- 但是如果是同一个位置(这里每个元素是一个链表, 哈希冲突的解决方法), 那么就会产生线程安全问题. 这个时候我们让每个元素(哈希桶)加锁. 那么在同一个位置(同一个哈希桶), 那么多个线程就行同位置修改不会产生线程安全问题
- 还有一个优化点就是我们在哈希表模拟实现中, 知道他底层是一个数组来实现的. 那么由于一个统计有效元素个数的成员变量size, 如果我们往哈希表里面添加元素就会对这个成员变量size进行自增操作. 这个时候多线程添加元素就线程不安全了. 所以Java官方通过原子类(基于CAS机制) 来保证了多线程添加元素, size自增就是通过原子类来实现. 就是线程安全的
- 对于哈希表的扩容, 如果我们需要扩容就要创建比原来哈希数组大的数组. 这个时候要把旧的哈希数组数据拷贝过去, 这时候由于元素很多, 拷贝时间比较长. 这个时候加锁的时间也长了.
- 所以我们采用分批次拷贝的方式来完成扩容, 每次拷贝数据只拷贝一部分, 那么加锁时间就比较短了