JUC并发编程详解
JUC => java.util.concurrent,这个包里放的东西都是和多线程相关的!
一、Callable接口
Callable是一个interface,相当于把线程封装了一个 “返回值”,方便程序猿借助多线程的方式计算结果。
需要用到FutureTask类:
代码示例: 创建线程计算 1 + 2 + 3 + … + 1000,使用 Callable 版本
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Demo {
// 创建线程, 通过线程来计算 1 + 2 + 3 + ... + 1000
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 使用 Callable 定义一个任务,类型参数即为返回值类型!!!
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 的构造方法, 不能直接传 callable, 还需要一个中间的类.
Thread t = new Thread(futureTask);
t.start();
// 获取线程的计算结果.
// get 方法会阻塞, 直到 call 方法计算完毕, get 才会返回.
System.out.println(futureTask.get());
}
}
若使用Thread类,需要一个辅助类,还需要使用一系列的加锁和 wait notify 操作,代码复杂、容易出错!
因此如果当前多线程完成的任务希望带上结果,使用 Callable 就比较好!
目前为止:
二、ReentrantLock类
可重入互斥锁。和 synchronized 定位类似,都是用来实现互斥效果,保证线程安全。
ReentrantLock 也是可重入锁,“Reentrant” 这个单词的意思就是 “可重入”。
ReentrantLock 的用法:
- lock():加锁,如果获取不到锁就死等
- tryLock(超时时间):加锁,如果获取不到锁,等待一定的时间之后就放弃加锁
- unlock():解锁
代码:
import java.util.concurrent.locks.ReentrantLock;
public class Demo30 {
public static void main(String[] args) {
ReentrantLock locker = new ReentrantLock(true);
try {
// 加锁
locker.lock();
} finally {
// 解锁
locker.unlock();
}
}
}
常见面试题:
synchronized 和 ReentrantLock 的区别?
区别 = 缺点 + 优势! 参考以上内容~~
三、原子类
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个:
(Java里已经封装好了,可以直接来使用~)
代码:
以 AtomicInteger 举例,常见方法有:
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;
import java.util.concurrent.atomic.AtomicInteger;
public class Demo31 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger count = new AtomicInteger(0);
// // 相当于 ++count
// count.incrementAndGet();
// // 相当于 count--
// count.getAndDecrement();
// // 相当于 --count
// count.decrementAndGet();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
// 相当于 count++
count.getAndIncrement();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
// 相当于 count++
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// get 获取到内部的值.
System.out.println(count.get());
}
}
四、线程池
五、信号量Semaphore
信号量,用来表示 “可用资源的个数”,本质上就是一个计数器。
理解信号量
可以把信号量想象成是停车场的展示牌:当前有车位100个,表示有100个可用资源.。
当有车开进去的时候,就相当于申请一个可用资源,可用车位就 -1 (这个称为信号量的 P 操作)
当有车开出来的时候,就相当于释放一个可用资源,可用车位就 +1 (这个称为信号量的 V 操作)
如果计数器的值已经为 0 了,还尝试申请资源,就会阻塞等待,直到有其他线程释放资源!!!
基于信号量也是可以实现生产者消费者模型的~~
信号量可以视为是一个更广义的锁;锁就是一个特殊的信号量 (可用资源只有1的信号量)!
把互斥锁也看作是计数为1的信号量 (取值只有1和0,也叫做二元信号量)
Java标准库提供了Semaphore这个类,其实就是把操作系统提供的信号量封装了一下~~
当需求中有多个可用资源的时候,就要记得使用信号量!!!(目的也是为了控制线程安全~~)
代码示例:
import java.util.concurrent.Semaphore;
public class Demo32 {
public static void main(String[] args) throws InterruptedException {
// 构造的时候需要指定初始值, 计数器的初始值. 表示有几个可用资源
Semaphore semaphore = new Semaphore(4);
// 这是 P 操作, 申请资源, 计数器 - 1
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 操作");
// 这是 V 操作, 释放资源, 计数器 + 1
semaphore.release();
}
}
信号量这个概念是荷兰数学家迪杰斯特拉提出来的,"求图的最短路径"就是他提出的!
P、V 就是荷兰语中的申请和释放的首字母~~
例题: 编写代码实现两个线程增加同一个变量 (使用 Semphore 来控制线程安全)
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadDemo5 {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(1);
AtomicInteger count = new AtomicInteger();
Thread t1 = new Thread(() -> {
try {
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < 100; i++) {
count.getAndIncrement();
}
semaphore.release();
});
Thread t2 = new Thread(() -> {
try {
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < 100; i++) {
count.getAndIncrement();
}
semaphore.release();
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count:" + count);
}
}
六、闭锁CountDownLatch
类似于一个跑步比赛:
代码:
import java.util.concurrent.CountDownLatch;
public class Demo {
public static void main(String[] args) throws InterruptedException {
// 有 10 个选手参加了比赛
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
// 创建 10 个线程来执行一批任务.
Thread t = new Thread(() -> {
System.out.println("选手出发! " + Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("选手到达! " + Thread.currentThread().getName());
// 撞线
countDownLatch.countDown();
});
t.start();
}
// await 是进行阻塞等待. 会等到所有的选手都撞线之后, 才解除阻塞
countDownLatch.await();
System.out.println("比赛结束!");
}
}
七、线程安全的集合类
原来的集合类,大部分都不是线程安全的。
Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的!
7.1 多线程环境使用 ArrayList
1)自己使用同步机制 (synchronized 或者 ReentrantLock)
2)Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List,
synchronizedList 的关键操作上都带有 synchronized
(套了一层壳,壳上加锁了)
3)使用 CopyOnWriteArrayList
CopyOnWrite容器即写时拷贝的容器。
- 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,
- 添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:
在读多写少的场景下,性能很高,不需要加锁竞争
缺点:
1.占用内存较多
2.新写的数据不能被第一时间读取到
这种写时拷贝的思想很多地方都会用到!一个典型的,显卡给显示器渲染画面,也是类似的操作~~
7.2 多线程环境使用队列
1)ArrayBlockingQueue
基于数组实现的阻塞队列
2)LinkedBlockingQueue
基于链表实现的阻塞队列
3)PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
4)TransferQueue
最多只包含一个元素的阻塞队列
7.3 多线程环境使用哈希表
HashMap 本身不是线程安全的。
在多线程环境下使用哈希表可以使用:
- Hashtable (不推荐使用,是属于无脑给各种方法加synchronized)
- ConcurrentHashMap (推荐使用!背后做了很多的优化策略~~)
HashTable是官方明确提出不建议使用的!上古时期就有了,当时设计得不是很好,确实现在就没啥优势~~