背景
JUC是java.util.concurrent的简称,这个包里面存放的都是和多线程相关的类,在面试中非常的重要
目录
1.Callable接口
2.ReentrantLock
3.信号量Semaphore
4.CountDownLatch
5.集合的线程安全问题
1.Callable接口
1.1.认识Callable接口
(1)Callable接口,是用于描述一个线程任务。
(2)Callable接口和Runnable接口非常的类似。用来描述线程任务的时候,都是使用匿名内部类,但是Runnable接口是重写run方法,而Calleble接口是重写call方法
(3)Runnale描述的任务没有返回值,但是Callable描述的接口是有返回值的。所以Calleble的存在就会有它特别的意义,也能干一些Runnale不能干的事情
看完上面的内容,你就大概了解了Callable接口大概是干什么用的,下面就来学会如果使用吧。
1.2.使用Callable接口创造线程
通过线程完成的任务:将一个变量从0累加到5000,并且在线程外面打印出来。
为了凸显Callable接口的独特优势,我们先使用Runnable接口完成任务。
(1)Runnable接口
有下面两种写法,都是ok的;但是还是推荐第一种,第二种是为了和Callable做对比
第一种:
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<5000;i++) {
count++;
}
}
});
t.start();
t.join();
System.out.println(count);
}
第二种:
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
for(int i=0;i<5000;i++) {
count++;
}
}
};
Thread t = new Thread(runnable);
t.start();
t.join();
System.out.println(count);
}
Runnale接口完成任务的逻辑:
局限性非常的明显,就是只能使用全局变量,并且线程内的变量外部无法获取,提高了耦合。
所以,我们的Callable接口就登场了。
(2)Callable接口
代码:
public static void main(String[] args) throws InterruptedException, ExecutionException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int count = 0;
for(int i=0;i<5000;i++) {
count++;
}
return count;
}
};
FutureTask futureTask = new FutureTask(callable);
Thread t = new Thread(futureTask);
t.start();
t.join();
System.out.println(futureTask.get());
}
虽然这个代码相比Runnable的代码看起来复杂了一点,但是降低了耦合,提高了可阅读性,也称为“异步编程”
我们解释一下代码:
举我们生活中吃饭的例子:futureTask就类似我们拿到的待餐的小票,而小票里面的内容(参数)就描述了我们要吃的东西(要完成的任务);小票有两份,一份会交给后厨,一份则在你手中,过后就可以根据小票取餐(拿到返回值)。
这就是使用Callable接口创造线程的方式。
2.ReentrantLock
这是一个可重入锁,学习新知识第一步,先认识发音
下面只是简单的介绍即可,点到为止
2.1.ReentrantLock的使用
(1)不靠谱的加锁
public static void main(String[] args) {
ReentrantLock locker = new ReentrantLock();
//1.加锁
locker.lock();
//2.内容
//3.解锁
locker.unlock();
}
代码解释:
为什么说这种加锁的写法是不靠谱的呢?因为在中间写代码的内容里面,可能会出现一些意外,比如直接程序退出,或者抛出异常,而导致没有解锁。
所以,都是采取下面的写法
(2)靠谱的加锁
public static void main(String[] args) {
ReentrantLock locker = new ReentrantLock();//创建锁对象
try {
//1.加锁
locker.lock();
//2.内容
}finally {
//3.解锁
locker.unlock();
}
}
我们借鉴异常章节的写法,将关锁操作写在finally内部;于是,无论代码怎么执行,finally内部的代码是一定会被执行到的,于是,就不会担心不解锁的操作了。
2.2.ReentratLock的优势
这里介绍的优势是相对sychronized来说的,但是我们日常写代码还是用sychronized就可以了,准没有问题。
(1)提供公平锁的选择
sychronized锁,是非公平锁;而ReentratLock却可以选择是否公平。
public static void main(String[] args) {
ReentrantLock locker1 = new ReentrantLock();//非公平锁
ReentrantLock locker2 = new ReentrantLock(true);//公平锁
}
通过参数,就可以选择是否公平
(2)tryLock操作
这是一个什么操作呢?比如我们的sychronized锁,当A线程拿到锁之后,B线程再想获取锁,就会阻塞等待,这个等待是死等。但是ReentratLock中的tryLock操作,本质上也是加锁操作,如果该锁被占用了,此时就会直接返回失败,不会阻塞等待,如果该锁没有被占用,则会加锁成功。下面介绍tryLock的使用。
public static void main(String[] args) {
ReentrantLock locker = new ReentrantLock();
Thread t1 = new Thread(()->{
if(locker.tryLock()) {
System.out.println("A线程获取到锁");
}else {
System.out.println("A线程没有获取到锁");
}
},"A");
Thread t2 = new Thread(()->{
System.out.println(locker.tryLock());
if(locker.tryLock()) {
System.out.println("B线程获取到锁");
}else {
System.out.println("B线程没有获取到锁");
}
},"B");
t1.start();
t2.start();
}
很明显,线程B抢不过线程A,但是不会阻塞等待,而是直接退出了。
这就是tryLock的操作,还有一种带参数的tryLock,也就是等待一定的时间获取不到锁,就返回。
public static void main(String[] args) throws InterruptedException {
ReentrantLock locker = new ReentrantLock();
Thread t1 = new Thread(()->{
if(locker.tryLock()) {
System.out.println("A线程获取到锁");
}else {
System.out.println("A线程没有获取到锁");
}
},"A");
Thread t2 = new Thread(()->{
try {
if (locker.tryLock(300, TimeUnit.MILLISECONDS)) {
System.out.println("B线程没有获取到锁");
} else {
System.out.println("B线程获取到锁");
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
},"B");
t1.start();
t2.start();
}
A线程先拿到锁,此时B没有拿到,等待锁的释放;300ms内,A线程释放了锁,B线程也就拿到了锁。
(3)可唤醒指定线程
在sychronized中,是搭配wait和notify来等待和唤醒线程,这种唤醒是随机的;
但是ReentratLock中,是搭配Condition接口来指定唤醒线程的,这样就比上述的随机唤醒更强一些。
3.信号量Semaphore
3.1.信号量概念
(1)信号量,本质上就是一个计数器
(2)举例:例如底下停车场,当有一辆车进去后,门口牌子里面的内容:车位剩余容量就会-1;当有一辆车出库之后,车位容量+1,当没有车位时,就会阻塞等待。
(3)标准库中,提供了Semaphore这个类,来操作信号量。P操作:计数器-1,申请资源;V操作:计数器+1,释放资源。
3.2.信号量代码使用
(1)创建信号量对象
Semaphore semaphore = new Semaphore(5);//设置计数器的容量
这就是创造了一个计数器,参数是这个计数器的最大容量(比如只能停五辆车);接下来就是要对这个就是计数器进行+1或者-1操作。
(2)类方法
方法签名 | 说明 |
void acquire() | 使计数器+1 |
void release() | 使计数器-1 |
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(5);//设置计数器的容量
int i=0;
semaphore.acquire();
System.out.println("P操作"+": "+i++);
semaphore.acquire();
System.out.println("P操作"+": "+i++);
semaphore.acquire();
System.out.println("P操作"+": "+i++);
semaphore.acquire();
System.out.println("P操作"+": "+i++);
semaphore.acquire();
System.out.println("P操作"+": "+i++);
semaphore.acquire();
System.out.println("P操作"+": "+i++);
}
计数器容量只有5,但是此时进行了六次P操作,最后一次P操作就会阻塞等待
这就是计数器的简单使用。
因为,当计数器的容量只有1的时候,其实可以当成锁来使用,换句话说,锁就是一种特殊的信号量
使用信号量保证线程安全:
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();//计数器+1
count++;
semaphore.release();//计数器-1
}
} 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();
t1.join();
System.out.println(count);
}
4.CountDownLatch
4.1.概念
(1)CountDownLatch是一个非常使用的工具类
(2)举例:有一些下载速度非常的快工具(比如:IDM),会将一个下载任务分成多个部分,每个部分由一个线程去执行,当所有的线程都完成任务之后,下载任务才算完成。用来判别所有线程任务完成的这个行为,就是countDownLach所做的。
(3)在代码中,我们可以使用该类去操作一些相关的代码
4.2.代码使用
(1)类对象的的创建
CountDownLatch downLatch = new CountDownLatch(10);//参数表示拆分成的任务数
这样就创造出来了对象
(2)代码主体
public static void main(String[] args) throws InterruptedException {
CountDownLatch downLatch = new CountDownLatch(10);//参数表示拆分成的任务数
for (int i = 0; i < 10; i++) {
int id = i;
Thread t = new Thread(()->{
System.out.println(id+"线程开始执行");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(id+"任务执行完毕");
downLatch.countDown();//执行一次,相当于完成一个任务
});
t.start();
}
downLatch.await();//等待10个任务完成,才执行后续的逻辑
System.out.println("10个任务执行完毕!");
}
5.集合的线程安全问题
这里介绍的集合,就是我们之前在数据结构部分学习到的各种集合,如:顺序表、链表等等
5.1.背景知识
(1)不安全的集合
ArrayList、LinkedList、Queue、HashMap等,大多数的集合在多线程下使用,都是不安全的
(2)比较安全的集合
Vector、Stack、Hashtable自带sychronized、所以在多线程下,大部分是安全的。
为什么说不是一定安全的呢?原因是:这些集合加锁的位置是在每个方法中,比如两个线程同时插入/删除数据时,是会阻塞等待,属于线程安全的;但是一个线程取数据,一个线程删除数据,是线程不安全的。
所以,线程是否安全,还是要分场景和情况讨论。
下面介绍一下集合在多线程下该如何使用
5.2.多线程下的ArrayList
我们知道,ArraryList是没有任何锁的,但是想要在多线程中去使用它,该如何做呢?我们只需要使用标准库提供封装好的ArrayList即可,当然,也可以自己加锁,但是下面不介绍了
(1)Collections.sychronizedList(new ArrayList)
public static void main(String[] args) {
ArrayList<Integer> arrayList = new ArrayList<>();
Collections.synchronizedList(arrayList);
arrayList.add(10);
}
这样就将普通的ArrayList进行了一定的加锁操作
(2)CopyOnWriteArrayList
字面意思:在写数据的时候复制新的容器
写时拷贝的操作:当我们往一个容器里面添加元素时,不是向旧的容器添加;而是新创造出一个新的容器,把原始数据拷贝进行,最后添加新的原始。当添加完原始之后,再将旧容器的引用指向新的容器。
优点:在读多写少的场景下,性能很高,不需要加锁竞争
缺点:占用内存多、新写的数据不能第一时间被读取到
多线程下使用队列,我们这里也不介绍了,而是直接使用前面学习到的阻塞队列即可
5.3.多线程下的哈希表
(1)背景
在多线程下,HashMap是线程不安全的,Hashtable虽然有锁,但是线程也不一定安全,并且不推荐使用
所以标准库中引入了:ConcurrentHashMap,这个哈希表就是在Hashtable的基础上进行的优化。
Hashtable:对每个方法都加锁。
ConcurrentHashMap:使用锁桶的方式。
(2)ConcurrentHashMap的三处优化
第一:加锁方式,采取锁桶的方式
哈希表里面的第一层是一个”数组“,每个数组背后是一个链表;而前面的Hashtable是对数组加锁,这里的ConcurrentHashMap是对每个链表加锁。
如果多个线程同时插入数据,并且在不同的链表上,则就相当于没有加锁,这样极大的提高了插入的效率。
第二:操作size,采取CAS的方式
在哈希表的容量上面,即使操作的不是同一个链表,size却是同一个,所以在操作size时,采取CAS的方式
第三:扩容,采取拷贝到新空间
扩容一般发生在插入数据之后,如果数据量巨大,在插入数据后进行普通的扩容,效率就会非常的慢,就显得非常的卡顿。所以这里采取特殊的扩容手段。
ConcurrentHashMap在扩容的时候,搞两份空间,一份是扩容前的,另一份是扩容后的;接下来就会每次从旧的空间搬运一部分数据到新的空间。
在般的过程中,如果发生插入操作:则会插入到新的空间中;发生删除操作:新的空间和旧的空间都删除;查找操作:新的空间和旧的空间都要查找。
以上就是ConcurrentHashMap做出的三个优化操作