文章目录
- Callable接口
- ReentrantLock
- ReentrantLock 和 synchronized 的区别:
- 如何选择使用哪个锁?
- 信号量Semaphore
- CountDownLatch
- 多线程环境使用ArrayList
- 多线程使用 哈希表
- 相关面试题
JUC放了和多线程有关的组件
Callable接口
和Runnable一样是描述一个任务,但是有返回值,表示这个线程执行结束要得到的结果是啥
Callable 通常需要搭配 FutureTask 来使用. FutureTask用来保存 Callable 的返回结果. 因为Callable 往往是在另⼀个线程中执行的, 啥时候执行完并不确定.FutureTask 就可以负责等待结果出来的工作
理解 FutureTask
想象去吃麻辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你⼀张 “小票” . 这个小票就是FutureTask. 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没
public class Test {
private static int sum = 0;
public static void main(String[] args) throws InterruptedException {
//创建一个线程,让这个线程实现 1+2+3+...+1000
Thread t = new Thread(new Runnable() {
@Override
public void run() {
int result = 0;
for (int i = 1; i <= 1000; i++) {
result += i;
}
//此处为了把result告知主线程,需要通过成员变量
sum = result;
}
});
t.start();
t.join();
System.out.println(sum);
}
}
这个代码让主线程和t 线程耦合太大了
Callable就是为了降低耦合度的
• 创建⼀个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型.
• 重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果.
• 把 callable 实例使用 FutureTask 包装一下.
• 创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
• 在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Test {
public static void main(String[] args) throws InterruptedException, ExecutionException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int result = 0;
for(int i = 1; i <= 1000; i++) {
result += i;
}
return result;
}
};
//创建线程,把callable搭载到线程内部执行
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
System.out.println(futureTask.get());
}
}
ReentrantLock
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.
ReentrantLock 的用法:
- lock(): 加锁, 如果获取不到锁就死等.
- trylock(): 尝试加锁,如果锁已经被占用了,直接返回失败,而不会继续等待。还可以指定等待超时时间加锁, 如果获取不到锁, 等待⼀定的时间之后就放弃加锁.
- unlock(): 解锁
ReentrantLock 和 synchronized 的区别:
- synchronized 是⼀个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准库的⼀个类, 在 JVM 外实现的(基于 Java 实现).
- synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入⼀个 true 开启公平锁模式
ReentrantLock的参数是true就是公平锁,false或者不写就是非公平锁
- synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock.
import java.util.concurrent.locks.ReentrantLock;
public class Test {
public static void main(String[] args) {
ReentrantLock locker = new ReentrantLock(true);
try {
//加锁
locker.lock();
} finally {
//解锁
locker.unlock();
}
}
}
-
synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待⼀段时间就放弃.
-
更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是⼀个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.
如何选择使用哪个锁?
• 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
• 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
• 如果需要使用公平锁, 使用 ReentrantLock
信号量Semaphore
信号量就是一个计数器,描述了可用资源的个数
围绕信号量有两个基本操作
- P操作:计数器-1,申请资源
- V操作:计数器+1,释放资源
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用
代码示例
• 创建 Semaphore 实例, 初始化为 4, 表示有 4 个可用资源.
• acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
import java.util.concurrent.Semaphore;
public class Test {
public static void main(String[] args) throws InterruptedException {
//4个可用资源
Semaphore semaphore = new Semaphore(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.release();
}
}
总共四个可用资源,进行第五次P操作会阻塞直到其他线程执行V 操作
import java.util.concurrent.Semaphore;
public class Test {
public static void main(String[] args) throws InterruptedException {
//4个可用资源
Semaphore semaphore = new Semaphore(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();
}
}
锁其实是特殊的信号量
如果信号量只有0 , 1两个取值,此时就称为"二元信号量",本质上就是一把锁
import java.util.concurrent.Semaphore;
public class Test {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(1);
Thread t1 = new Thread(()-> {
try {
for (int i = 0; i < 50000; i++) {
semaphore.acquire();
count++;
semaphore.release();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
Thread t2 = new Thread(()-> {
try {
for (int i = 0; i < 50000; i++) {
semaphore.acquire();
count++;
semaphore.release();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
CountDownLatch
当我们把一个任务拆分成多个的时候,可以通过这个工具类识别任务是否整体执行完毕了
IDM这种比较专业的下载工具就是多线程下载,把一个大的文件拆分成多个部分,每个线程都独立和人家服务器建立连接,分多个连接进行下载,等所有线程下载完毕之后,再对结果进行合并。
这时候就需要识别出所有线程是否都执行完毕了,此处就可以使用CountDownLatch
代码示例
- 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
- 每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
- 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了
import java.util.concurrent.CountDownLatch;
public class Test {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(10);//有10个线程
for (int i = 0; i < 10; i++) {
int id = i;
Thread t = new Thread(()-> {
try {
//假设这里进行一些"下载"这样的耗时操作
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException();
}
System.out.println("线程结束" + id);
latch.countDown();
});
t.start();
}
//通过 await 等待所有的线程调用countDown
//await 会阻塞等待到countDown调用的次数和构造方法指定的次数一致的时候,await才会返回
latch.await();
System.out.println("所有线程结束");
}
}
await 不仅仅能替代 join,还可以判断任务是否全部完成
多线程环境使用ArrayList
Vector每个方法都有synchronized加锁
如果ArrayList这样没加锁的集合类想达到类似于Vector的效果就可以用Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的⼀个基于 synchronized 进行线程同步的 List.
CopyOnWriteArrayList
(写时拷贝)也是一种解决线程安全问题的做法
假设有个数组有1,2,3,4这四个数据
多个线程读取,一个线程将2改为200,这样就有可能读取不到2,这是bug
我们就可以用原来的数组去读,新建一个数组去修改,写完之后用新的数组的引用代替旧的数组的引用(引用赋值的操作是原子的)
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。CopyOnWrite容器也是⼀种读写分离的思想,读和写不同的容器。
- 优点:
在读多写少的场景下, 性能很高, 不需要加锁竞争.
- 缺点:
- 占用内存较多.
- 新写的数据不能被第⼀时间读取到.
上述过程,没有任何加锁和阻塞等待,也能确保读线程不会读出"错误的数据"
有些服务器程序,需要更新配置文件/数据文件,就可以采取上述策略
显卡渲染画面到显示器就是按照写时拷贝的方式,在显示上一个画面的时候,在背后用额外的空间生成下一个画面,生成完毕就用下一个画面代替上一个画面
多线程使用 哈希表
HashMap 是不带锁的
Hashtable 虽然带锁,但线程不一定更安全,只是简单的把关键方法加上了 synchronized 关键字.
- 一个Hashtable就只有一把锁,如果多线程访问同⼀个 Hashtable 就会直接造成锁冲突.
- size 属性也是通过 synchronized 来控制同步, 也是比较慢的.
- ⼀旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低
标准库提供了更好的代替: ConcurrentHashMap
- 读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁. 加锁的方式仍然是是用 synchronized, 但是不是"一把全局锁", 而是 “锁桶” (用每个链表的头结点作为锁对象), 大大降低了锁冲突的概率。不同线程针对不同的链表进行操作是不涉及锁冲突的(不涉及修改"公共变量",也就不涉及到线程安全问题),这样大部分的操作没有锁冲突,就只是偏向锁
- 像size方法,即使插入/删除的元素是不同链表上的元素,也会涉及到多线程修改同一个变量。引入CAS的方式来修改size,提高了效率也避免了加锁的操作
- 优化了扩容方式: 化整为零
HashMap要在一次put的过程中完成整个扩容的过程,就会使put操作效率变得很低。 ConcurrentHashMap在扩容的时候就会搞两份空间,一份是扩容之前的空间,一份是扩容之后的空间。后续每个来操作 ConcurrentHashMap 的线程,都会把一部分数据从旧空间搬运到新空间,分多次搬运。
搬的过程中:
- 插入操作就插入到新的空间里面
- 删除操作就是新的旧的空间里面的都要删除掉
- 查找就是新的旧的空间都要查找
相关面试题
- 线程同步的方式有哪些?
synchronized, ReentrantLock, Semaphore 等都可以用于线程同步.
- 为什么有了 synchronized 还需要 juc 下的 lock?
以 juc 的 ReentrantLock 为例,
- synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活,
- synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待⼀段时间就放弃.
- synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入⼀个 true 开启公平锁模式.
- synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是⼀个随机等待的线程.ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.
- 信号量听说过么?之前都用在过哪些场景下?
信号量,用来表示 “可用资源的个数”. 本质上就是⼀个计数器.
使用信号量可以实现 “共享锁”, 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待, 直到前面的线程执行了 V 操作.
- 谈谈 volatile关键字的用法?
volatile 能够保证内存可见性. 强制从主内存中读取数据. 此时如果有其他线程修改被 volatile 修饰的变量, 可以第⼀时间读取到最新的值.
- Java多线程是如何实现数据共享的?
JVM 把内存分成了这几个区域:方法区, 堆区, 栈区, 程序计数器.
其中堆区这个内存区域是多个线程之间共享的.
只要把某个数据放到堆内存中, 就可以让多个线程都能访问到.
- 在多线程下,如果对⼀个数进行叠加,该怎么做?
- 使用 synchronized / ReentrantLock 加锁
- 使用 AtomInteger 原子操作.