《Java并发编程的艺术》笔记 ------ 第一章
1、上下文切换
单核处理器 也是能够支持多线程执行代码的,CPU通过时间片机制来实现
时间片是CPU分配给各个线程的时间,由于时间片的时间非常短(大概一般几十毫秒),因此CPU通过不停切换线程 执行,让我们感觉好像是多个线程同时在执行
CPU通过时间片分配算法 循环执行任务,在任务用完分配的时间片时间后,会切换到下一个任务执行,但是再次之前会保存上一个没执行完的任务的状态 ,以便于再次分配到时间片的时候,可以从上次结束的地方接着向下执行
这个将前一个任务状态保存再切换到保存点加载的过程就是一次上下文切换
这就像我们正在刷视频,然后外卖到了,此时我们不想错过精彩,因此暂停视频,然后出门去拿外卖,回来后我们从刚刚暂停的位置继续看,这便是上下文切换,而上下文切换会有额外开销,就像我们需要伸手按下暂停,放下耳机,因此上下文切换会影响多线程的执行速度
1.1、多线程一定快吗
接下来,简单对比一下串行和并发执行任务的耗时情况
java
/**
* 并发执行
*/
private static void concurrency() throws InterruptedException {
long start = System.currentTimeMillis();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
int a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
}
});
thread.start();
int b = 0;
for (long i = 0; i < count; i ++) {
b--;
}
long time = System.currentTimeMillis() - start;
thread.join(); // 等待thread结束
System.out.println("concurrency: " + time + "ms, b=" + b);
}
这里使用for循环对于a和b进行的操作是用来模拟两个线程中的耗时任务
这两个线程分别是创建的Thread对象thread和主线程,两个线程分别承担对变量a和变量b的操作任务
而System.currentTimeMillis()
用来计算整体的耗时,在执行前和执行后进行记录
注意,这里调用Thread
的join()
方法是为了等待thread线程执行结束,这样两个线程任务都结束,再一起计算耗时
java
/**
* 串行执行
*/
private static void serial() {
long start = System.currentTimeMillis();
int a = 0; // 任务1
for (long i = 0; i < count; i++) {
a += 5;
}
int b = 0; // 任务2
for (long i = 0; i < count; i++) {
b --;
}
long time = System.currentTimeMillis() - start;
System.out.println("serial: " + time + "ms, b=" + b + ", a=" + a);
}
以上这是串行执行两个任务
java
private static final long count = 100; // 指定循环次数
public static void main(String[] args) throws InterruptedException {
concurrency(); // 并发方式
serial(); // 串行方式
}
当count足够大时,并发优势就很明显了
其实可以看出并发并不一定快于串行,原因是创建线程和切换上下文会产生一定开销,在任务耗时本身不大的情况下反而容易产生较大影响,随着任务耗时大幅增加,创建线程和切换上下文的开销几乎可以忽略不计,此时并发的优势逐渐明显
1.2、测试上下文切换次数和时长
- 使用Lmbench3测量上下文切换的时长
https://sourceforge.net/projects/lmbench/
https://blog.csdn.net/qq_36393978/article/details/125989992 - 使用vmstat可以测量上下文切换的次数
https://blog.csdn.net/qq_40132294/article/details/121445765
1.3、如何减少上下文切换
- 无锁并发编程
- CAS算法
- 使用最少线程
- 协程
1.4、减少上下文切换实战
使用Process Explorer进行线程查看线程上下文切换的情况
https://learn.microsoft.com/en-us/sysinternals/downloads/process-explorer
使用for循环创建大量线程并启动,第一次创建数量比较多,有1000个,那么势必会有大量线程处于等待状态,那么在线程从阻塞、等待状态进行切换时,就会产生上下文切换,相对数量越多
而当我大量减少创建线程数量时,一次执行的线程相对整体就会占比越大,需要从等待状态进行切换的就会越少
那么这便是通过减少等待线程的数量,进而减少上下文切换的损耗
2、死锁
锁在多线程的场景中运用还是十分广泛的,但是它也会带来诸如死锁 这样的问题
一旦发生了死锁,就会导致系统功能不可用
java
public class DeadLockDemo {
private static String A = "A";
private static String B = "B";
public static void main(String[] args) {
deadLock();
}
private static void deadLock() {
Thread t1 = new Thread(() -> {
synchronized (A) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B) {
System.out.println("1");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (B) {
synchronized (A) {
System.out.println("2");
}
}
});
t1.start();
t2.start();
}
}
运行完什么都看不到,但是直觉是有问题的
输入jps
命令查一下进程编号
然后使用jstack
命令导出堆栈信息
好了,从堆栈中可以看到系统已经发现有一个死锁了,
根据堆栈信息分析,第一个线程卡在获取B的地方,在等待获取B的锁,显然是没能等到啊,但是线程1持有了A的锁,没有释放
与此同时,第二个线程持有B的锁没有释放,又在等待A释放锁,这不就巧了吗,双方僵持不下,谁都没有办法继续下去
虽然正常编写业务不会出现这么明显的死锁,但是却很有可能由于一些异常或是分支处理时考虑不全面,导致该释放的锁没有释放掉
那么,就需要在编写代码时,避免一些场景的出现:
- 避免一个线程同时获取多个锁
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
- 尝试使用定时锁,使用
lock.tryLock(timeout)
来代替使用内部锁机制 - 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况
3、资源限制的挑战
3.1、什么是资源限制
资源限制是指在进行并发编程 时,程序的执行速度受限于计算机硬件资源或软件资源
比如服务器带宽、读写速度、数据库连接数等
3.2、资源限制引发的问题
需要根据具体情况决定是否采用并发执行,不能盲目使用并发,串行在特定场景下更有优势
3.3、如何解决资源限制的问题
对于硬件资源的限制,考虑使用集群并行执行,使用多台计算机分工合作
对于软件资源的限制,考虑使用资源池将资源复用
3.4、在资源限制情况下进行并发编程
根据不同的资源限制调整程序的并发度