Callable interface
// 也是一种创建线程的方式
// Runable 能表示一个任务 (run 方法), 返回值 void ; Callable 也能表示一个任务 (call 方法), 返回值是一个具体的值, 类型可以通过泛型参数来指定 (Object)
// 如果进行多线程操作, 且只关心多线程执行的过程 (像 线程池, 定时器等), 使用 Runable 即可; 如果是关心多线程的计算结果, 使用 Callable 更合适
// 使用 Callable 不能直接作为 Thread 的构造方法参数, 需要用 FutureTask 过渡一下
// 代码实现
java
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += i;
}
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
Integer result = futureTask.get();
System.out.println(result);
}
// get 类似于 join, 如果 call 方法没执行完, 会阻塞等待
ReentrantLock (可重入锁)
// reentrant : 可重入的
// 这个锁没有 synchronized 那么常用, 但是也是一个可选的加锁组件, 这个锁在使用上更接近于 C++ 里的锁
1. lock() 加锁
2. unlock() 解锁
// 因为是两步锁, 那么就容易出现 unlock 调用不到的情况, 比如 中间 return 了或者 抛出异常了
// unlock 容易遗漏, 所以用 finally 来执行 unlock
3.ReentrantLock 具有一些 synchronized 不具备的功能(优势)
3.1. 提供了一个 tryLock 方法进行加锁,对于 lock 操作, 如果加锁不成功就会阻塞等待 (死等), 对于 tryLock, 如果加锁失败, 直接返回 false 或可以设定等待时间, 这样就给加锁操作提供了更多的可操作空间
3.2. ReentrantLock 有两种模式, 可以工作在公平锁状态下, 也可以工作在非公平锁的状态下. 构造方法中通过参数设定的 公平/非公平 模式
3.3. ReentrantLock 也有等待通知机制, 搭配 Condition 这样的类来完成, 这里的等待通知要比 wait notify 功能更强
// synchronized 锁对象是任意对象, ReentrantLock 锁对象就是自己本身, 如果你对多个线程针对不同的 ReentrantLock 调用 lock 方法, 此时不会产生锁竞争的.
// 实际开发中, 进行多线程开发, 用到的锁依旧首选 synchronized
原子类的应用场景有哪些
// 可以使线程既不需要加锁又可以保证线程安全
1. 计数需求
// 例如 : 视频播放中的一些数据 (播放量, 点赞量, 转发量等等)
2. 统计效果
// 统计出现错误的请求数目,请求总数(衡量服务器的压力)或每个请求的响应时间 -> 平均响应时间, (衡量服务器的运行效率), 使用原子类, 记录出错的请求数目, 另外写一个监控服务器, 获取到线上服务器的错误计数, 再通过曲线图绘制到页面上, 我们就可以及时发现 版本BUG
// 线上服务器通过这些统计内容, 进行简单计数 => 实现监控服务器, 获取/ 统计/ 展示/ 报警
信号量 Semaphore
// 在操作系统中, 也经常出现, Semaphore 是并发编程中的一个重要的概念/ 组件; 准确来说, Semaphore 是一个计数器 (变量), 描述了 "可用资源的个数", 描述的是当前线程是否"有临界资源可以用"
//临界资源: 多个线程/ 进程等并发执行的实体可以公共使用到的资源
// P / V 操作: P 操作是计数器减少(申请资源 acquire), V 操作是计数器增加(释放资源 release)
// 当计数器数值为 0 的时候, 继续 P 操作, 就会阻塞等待, 一直等待到其他线程执行了 V 操作, 释放了一个空闲资源为止
// 锁, 本质上是一种特殊的信号量 (里面的值, 非 0 即 1, 二元信号量)
// 信号量比锁更广义, 不仅仅可以描述一个资源, 还可以描述 N 个资源, 虽然概念上更广泛, 但是实际开发中还是锁更多一些 (二元信号量是最常用的)
// 伪代码 展示一下 P / V 操作
java
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(4);
semaphore.acquire();
System.out.println("执行 P 操作");
semaphore.release();
System.out.println("执行 V 操作");
}
CountDownLatch
// 针对特定场景一个组件, 当我们需要将一个任务拆分成多个部分, 每个部分用线程分别执行的时候就可以使用这个组件, 来判定出当前是否所有的任务都执行完了
// 主要用于下载方面
// 伪代码展示一下
java
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
int id = i;
Thread t = new Thread(() -> {
System.out.println("线程" + id + "正在工作");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("线程" + id + "结束工作");
countDownLatch.countDown();
});
t.start();
}
countDownLatch.await();
System.out.println("这个线程的所有任务都执行完了");
}
线程安全的集合类
// 集合类哪些是线程安全的
// Vector, Stack, HashTable 是线程安全的, 但不建议用, 其他的集合类不是线程安全的, 关键方法中, 使用 synchronized
// Vector / HashTable 这样的集合类, 虽然加了 synchronized 也不能保证一定是线程安全的, 同时, 在单线程的情况下, 又可能因为 synchronized 影响到执行效率
1. 多线程环境使用 ArrayList
1.1 自己使用同步机制 (synchronized 或者 ReentrantLock)
1.2 Collections.synchronizedList(new ArrayList);
// ArrayList 本身没有使用 synchronized 但是你又不想自己加锁, 那么就可以使用上面这个
// 相当于让 ArrayList 像 Vector 一样工作
1.3 使用 CopyOnWriteArrayList (写时复制)
// 多线程同时修改同一个变量
// 如果多个线程去读取, 本身不会有任何线程安全问题, 一旦有线程修改, 就会把自身复制一份, 尤其修改如果比较耗时的话, 其他线程还是从旧的数据上读取, 一旦修改成功, 使用新的 ArrayList (本质上就是引用的重新赋值, 速度极快, 并且又是原子的), 这个过程中没有引入任何的加锁操作, 使用了 创建副本 => 修改副本 => 使用副本替换, 类似于显卡渲染画面
2. 多线程使用哈希表
// HashMap 本身不是线程安全的
2.1 HashTable 是线程安全的, 关键方法都提供了 synchronized
// HashTable 是在方法上直接加上 synchronized, 就相当于针对 this 加锁
2.2 ConcurrentHashMap 是线程安全的 Hash 表
2.2.1 [核心] 减小了锁的粒度, 每个链表有一把锁, 大部分情况下都不会涉及到锁冲突
2.2.2 广泛使用 CAS 操作, 像 size++ 这种操作就不会产生锁冲突
2.2.3 写操作进行了加锁(链表级), 读操作, 不加锁了
2.2.4 针对扩容操作进行了优化, 渐进式扩容
// 化整为零, 当需要扩容的时候, 会创建出另一个更大的数组, 然后把旧的数组上的数据逐渐的往新的数组上搬运. 会出现一段时间, 旧数组和新数组同时存在
2.2.4.1 新增元素, 往新数组上插入
2.2.4.2 删除元素, 把旧数组的元素给删掉即可
2.2.4.3 查找元素, 新旧数组都要查找
2.2.4.4 修改元素, 统一把这个元素给搞到新的数组上
// 与此同时, 每个操作都会触发一定程度搬运, 每次搬运一点, 就可以保证整体的时间不是很长, 积少成多之后, 逐渐完成搬运了, 也就可以把之前的旧数组彻底销毁了
// HashMap 和 ConcurrentHashMap 之间的区别在于: 线程不安全和线程安全之间的区别
// Java 8 之前, ConcurrentHashMap 是使用分段锁, 之后都是每个链表一把锁
创建新线程的几种方式
1. 直接继承 Thread
2. 实现 Runable
3. 使用 lambda
4. 使用线程池
// 线程池构造方法中的 ThreadFactory 也可以构造线程
5. 使用 Callable
// 其中 1, 2, 5 这三种方法又可以搭配匿名内部类来使用