目录
ReentrantLock 和 synchronized 的区别:
这篇博客之前还有一篇 多线程进行 的一篇博客:
直达地址:
欢迎观看~让我们共同进步!!!💓💓💓💓💓💓
一、JUC 的常见类
1. Callable 接口
Callable 是一个 interface。相当于把线程封装了一个 "返回值"。
直接上代码看如何使用 这个 Callable接口:
创建线程 1+2+3+.....+1000,使用Callable版本
• 创建一个匿名内部类,实现 Callable 接口,Callable 带有泛型参数。泛型参数表示返回值的类型
• 重写 Callable 的 call 方法,完成累加的过程。直接通过返回值返回计算结果。
• 把 callable 实例使用FutureTask包装⼀下。
• 创建线程,线程的构造方法传入FutureTask。此时新线程就会执行FutureTask内部的Callable的 call 方法,完成计算。计算结果就放到了FutureTask对象中。
• 在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕。并获取到FutureTask中的结果。
代码如下:
public class Test {
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 = 1; i <= 1000; i++) {
sum += i;
}
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
int result = futureTask.get();
System.out.println(result);
}
}
可以看到,使用 Callable 和 FutureTask 之后,代码简化了很多,也不必手动写线程同步代码了。
理解 Callable
Callable 和 Runnable 相对,都是描述一个 "任务"。Callable 描述的是带有返回值的任务,Runnable描述的是不带返回值的任务。
Callable 通常需要搭配 FutureTask 来使用。FutureTask 用来保存 Callable 的返回保存。因为Callable往往是另一个线程中执行的,啥时候执行完并不确定。
FutureTask 就可以负责这个等待结果出来的工作。
理解 FutureTask
当去吃一些带有号码牌的食品的时候,比如麻辣烫,当选好菜之后,会给你一个 小牌或者是一个 号码。这个 小牌或者号码就是 FutureTask。后面需要凭借这个 小牌或者号码 取餐。
2. ReentrantLock
可重入互斥锁。和 synchronized 定位类似,都是用来实现互斥效果,保证线程安全。
ReentrantLock 也是可重入锁。
ReentrantLock 的用法:
• lock(): 加锁,如果获取不到锁就死等。
• trylock(超时时间): 加锁,如果获取不到锁,等待一定的时间之后就放弃加锁。
• unlock(): 解锁。
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// working
} finally {
lock.unlock();
}
ReentrantLock 和 synchronized 的区别:
• synchronized 是一个关键字,是 JVM 内部实现的。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。
3. 原子类
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个:
• AtomicBoolean
• AtomicInteger
• AtomicIntegerArray
• AtomicLong
• AtomicReference
• AtomicStampedRefernce
以 AtomicInteger 举例,常用方法有:
1. addAndGet(int delta); i += delta;
2. decrementAndGet(); --i;
3. getAndDecrement(); i--;
4. incrementAndGet(); ++i;
5. getAndIncrement(); i++;
4. 线程池
虽然创建销毁线程比创建销毁进程更轻量,但是在频繁创建销毁线程的时候还是会比较抵消。
线程池就是为了解决这个问题。如果某个线程不在使用了,就不是真正把线程释放,而是放到一个 "池子" 中,下次如果需要用到线程就直接从池子中取,不必通过系统来创建了。
ExecutorService 和 Executors
代码示例:
• ExecutorService 表示一个线程池实例。
• Executors 是一个工厂类,能够常见出几种不同风格的线程池。
• ExecutorService 的submit 方法能够向线程池中提交若干个任务。
public class Test {
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
}
}
Executors 创建线程池的几种方式
• newFixedThreadPool:创建固定线程数的线程池。
• newCachedThreadPool:创建线程数目动态增长的线程池。
• newSingleThreadExecutor:创建质保函单个线程的线程池。
• newScheduledThreadPool:设定 延迟时间后执行命令,或者定期执行命令。是进阶版的 Timer
Executors 本质上是 ThreadPoolExector 类的封装。
ThreadPoolExecutor
ThreadPoolExecutor 提供了更多的可选参数,可以进一步细化线程池的行为设定
ThreadPoolExecutor 的构造方法:
这里的 构造方法 中参数的理解是非常重要的。
corePoolSize: 核心线程数。(至少有多少个线程。线程池一创建,这些线程也要随之创建。直至整个线程池销毁,这些线程才会销毁)
maximumPoolSize: 最大线程数。(核心线程数 + 非核心线程数),(对于非核心线程数是自适应的在不繁忙时候就销毁,繁忙就再进行创建)
keepAliveTime: 非核心线程允许空闲的最大时间
unit: 枚举类型,枚举的是keepAliveTime 的时间单位,是秒、分钟花式其他值
workQueue: 传递任务的阻塞队列。(选择使用数组/链表,指定capacity,指定是否带有优先级/比较规则) (线程池,本质上也是 生产者消费者模型。调用 submit 就是在生产任务,线程池里的线程就是在消费任务)
threadFactory: 创建线程的工厂,参与具体的创建线程工作。(这是一种设计模式——工厂模式,统一的构造并初始化线程,用来弥补构造方法的缺陷的) (和单例模式是并列的关系)
工厂模式:
其工厂方法的核心就是通过 静态方法,把构造对象 new 的过程,各种属性初始化的过程,封装起来了。其 提供了多组静态方法,实现不同情况的构造。(提供工厂方法的类,就称之为 "工厂类")
RejectedExecutionHandler: 拒绝策略。
这个是线程池七个参数中,最重要的,最复杂的,在面试中也是最想要听到的。
submit 把任务添加到任务队列中,任务队列是阻塞队列,队列满了,再添加,进行阻塞,一般来说不希望程序阻塞太多。对于线程池来说,发现入队列操作是,队列满了不会触发"入队列策略",不会真阻塞,而是执行拒绝策略相关的代码。
Java标准库,也提供了另一组类,针对 ThreadPoolExecutor 进行了进一步封装,简化线程池的使用。也是基于 工厂设计模式。
代码如下:
public class Test {
public static void main(String[] args) {
//ExecutorService threadPool = Executors.newFixedThreadPool(3);// 固定线程数
ExecutorService threadPool = Executors.newCachedThreadPool();// 不固定,自动增加
for (int i = 0; i < 1000; i++) {
int id = i;
threadPool.submit(() -> {
System.out.println("hello " + id + ", " + Thread.currentThread().getName());
});
}
// shutdown 能够把线程池里的线程全部关闭,但是不能保证线程池内的任务一定能全部执行完毕。
// 所以,如果需要等待线程池内的任务全部执行完毕,需要调用 awaitTermination 方法。
threadPool.shutdown();
}
}
5. 信号量 Semaphore
信号量,用来表示 "可用资源的个数"。本质上就是一个计数器。
信号量的理解:
可以把信号量想象成是停车场的展示牌,当前有100个车位,表示存在100个可用资源。
当有车开进去的时候,就相当于申请一个可用资源,可用车位就-1(这个称为信号量的P操作)
当有车开出来的时候,就相当于释放一个可用资源,可用车位就+1(这个称为信号量的V操作)
如果计数器的值已经是0了,还在尝试申请资源,就会阻塞等待,直到有其他线程释放资源。
Semaphore 的 PV 操作中的加减计数器操作都是原子的,可以在多线程环境下直接使用。
代码示例:
• 创建 Semaphore 示例,初始化为4,表示存在4个可用资源
• acqurie 放啊表示申请资源(P 操作),release 方法表示释放资源(V 操作)
• 创建 20 个线程,每个县城都尝试申请资源,sleep 1秒之后,释放资源
public class Test {
public static void main(String[] args) {
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();
}
}
}
6. CountDownLatch
同时等待 N 个任务执行结束。
比如:跑步比赛,选手们只有当哨声响才能同时出发,当所有选手都通过终点,才能公布成绩。
• 构造 CountDownLatch 示例,初始化10 表示有 10 个任务需要完成
• 每个任务执行完毕,都调用 latch.countDown()。在 CountDownLatch 内部的计数器同时自减
• 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕,相当于计数器等于0时
public class Test {
public static void main(String[] args) throws InterruptedException {
// 现在把整个任务拆成 10 个部分. 每个部分视为是一个 "子任务".
// 可以把这 10 个子任务丢到线程池中, 让线程池执行.
// 当然也可以安排 10 个独立的线程执行.
// 构造方法中传入的 10 表示任务的个数.
CountDownLatch latch = new CountDownLatch(10);
ExecutorService executor = Executors.newFixedThreadPool(4);
for (int i = 0; i < 10; i++) {
int id = i;
executor.submit(() -> {
System.out.println("子任务开始执行: " + id);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("子任务结束执行: " + id);
latch.countDown();
});
}
// 这个方法阻塞等待所有的任务结束
// 此处的 a => all
latch.await();
System.out.println("所有任务执行完毕");
executor.shutdown();
}
}
7. 相关面试题
1)线程同步的方式有哪些?
synchronized、ReentrantLock、Semaphone 等都可以用于线程同步。
2)为什么有了 synchronized 还需要 juc下的 lock?
以ReentrantLock 为例:
• synchronized 使用是不需要手动释放锁。ReentrantLock 使用时需要手动释放,使用起来更灵活
• synchronized 在申请锁失败时,会死等。ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃。
• synchronized 是非公平锁,ReetrantLock 默认是非公平锁,可以通过构造方法传入一个 true 开启公平锁模式。
• synchronized 是通过 Objec 的wait / notfiy 实现 等待-唤醒。每次唤醒的是一个随机等待的线程。ReentrantLock 代培 Condaition 类实现 等待-唤醒,可以更精确控制唤醒某个指定的线程。
3)信号量是否听过?在哪些场景下使用过?
信号量,用来表示 "可用资源的个数" 本质上就是一个计数器。
使用信号量可以实现 "共享锁" 比如某个资源允许 3 个线程同时使用,那么就可以使用 P 操作作为加锁,V 操作作为解锁,前三个线程的 P 操作都能顺利返回,后序线程在进行 P 操作就会阻塞等待,直至前面的线程执行了 V 操作。
二、线程安全的集合类
原来的集合类,大部分都不是线程安全的。
Vector、Stack、HashTable,是线程安全的(但不建议使用),其他集合类不是线程安全的
1. 多线程环境使用 ArrayList
1)自行加锁。「推荐」
使用 synchronized 或者 ReentrantLock。但是要分析清楚,要把哪些代码打包到一起,成为一个 "原子" 操作。
2)Collections.synchronizedList(new ArrayList); 「不是很推荐」(因为加锁有代价)
返回的 List 的各种关键方法都带有 synchronized。是基于 synchronized 进行线程同步的 List.synchronizedList 的关键操作。
3)使用 CopyOnWriteArrayList
CopyOnWrite容器即写时复刻的容器。
• 当往一个容器添加元素的时候,不直接王当前容器添加,而是现将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素。
• 添加完元素之后,在将原容器的引用指向新的容器。
这样做的好处就是可以对 CopyOnWrite容器进行并发的读,并不需要加锁,因为当前容器不会添加任何元素。
所以 CopyOnWrite容器是一种读写分离的思想,读和写在不同的容器中。
缺点:
1.数组非常的大,非常低效,占用内存较多。
2.新写的数据不能被第一时间读取到。如果多个线程同时修改,也容易出现问题。
优点:
在读多写少的场景下,性能很高,不需要加锁竞争。比如:服务器进行重现加载配置的时候。
2. 多线程环境使用队列
1)ArrayBlockingQueue
基于数组实现的阻塞队列
2)LinkedBlockingQueue
基于链表实现的阻塞队列
3)PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
4)TransferQueue
最多只包含一个元素的阻塞队列
3. 多线程环境使用哈希表
HashMap 本身不是线程安全的。
在多线程环境下使用哈希表可以使用:
• Hashtable
• ConcurrentHashMap
1) Hashtable
Hashtable只是简单的把关键方法加上了 synchronized 关键字。
上面展示的只是一部分方法。
这就相当于直接针对 Hashtable 对象本身加锁。
• 如果多线程访问同一个 Hashtable 就会直接造成锁冲突。
• size 属性也是通过 synchronized 来控制同步的,所以是比较慢的。
• 一旦触发扩容,就由多线程完成整个扩容过程。这个过程会涉及到大量的元素拷贝,效率会非常低。
2) ConcurrentHashMap
相比于 Hashtable 做出了一系列的改进和优化。
• 读操作没有加锁(但是使用了 volatile 保证从内存读取结果),只对写操作进行加锁。加锁的方式任然是使用 synchronized,但是不是锁整个对象,而是 "锁桶" (用每个链表的头结点作为锁对象),大大降低了锁冲突的概率。
• 充分利用了 CAS 特性。比如 size 属性通过 CAS 来更新。避免出现重量级锁的情况。
• 优化了扩容方式: 化整为零
○ 发现组要扩容的线程,只需要创建一个新的数组,同时值搬几个元素过去。
○ 扩容期间,新老数组同时存在。
○ 后续每个来操作 ConcurrentHashMap 的线程,都会参与搬家的过程。每个操作负责搬运一小部分元素。
○ 搬完最后一个元素再把老数组删掉
○ 这个期间,插入只往新数组加。查找需要同时查 新数组和老数组。
这样之后:
如果修改的两个元素,在不同的链表(哈希桶)上,本身就不涉及线程安全问题(修改不同的变量)
如果修改同一个链表上的两个元素,可能存在线程安全问题,比如把这俩元素插入到同一个元素后面,就可能产生竞争。
ConcurrentHashMap 核心优化点;
(1) 把锁整个表 变成 锁桶
(2) 使用 原子类 针对 size 进行维护
(3) 针对哈希扩容的场景
化整为零
确保每个操作的加锁时间不要太长
4. 相关面试题
1)ConcurrentHashMap的读是否要加锁,为什么?
读操作没有加锁。目的是为了进一步降低锁冲突的概率。为了保证读到刚修改的数据,搭配了 volatile 关键字。
2)介绍下 ConcurrentHashMap的锁分段技术?
就是把若干个 哈希桶 分成一个 "段",针对每个段分别加锁。目的是为了降低锁竞争的概率。当然两个线程访问的数据恰好在同一个段上的时候,才会触发锁竞争。
3)Hashtable、HashMap和ConcurrentHashMap 之间的区别?
HashMap:线程不安全。key 允许为 null
Hashtable:线程安全。使用 synchronized 锁 Hashtable 对象,效率较低。key 不允许为null
ConcurrentHashMap:线程安全。使用 synchronized 锁每个链表头结点,锁冲突概率低,充分利用CAS机制。优化了扩容方式。key 不允许为 null。
感觉文章不错的话,期待你的一键三连哦,你的鼓励就是我的动力,让我们一起加油,顶峰相见。拜拜喽~~我们下次再见💓💓💓💓💓💓💓💓💓💓💓💓