Java并发编程基础
程序员文章站
2022-04-03 14:52:04
...
说明:本篇文章是在阅读《Java 并发编程艺术》过程中的一些笔记和分析,由于本人能力有限,如果有书写错误的地方,欢迎各位大佬批评指正!我们互相交流,学习,共同进步!
该项目的地址:https://github.com/xiaoheng1/concurrent-programming
1.现代操作系统调度的最小单位是线程(轻量级进程)
2.为什么要使用多线程了?肯定是为了快. 目前电脑的 CPU 核心数越来越多,如果将没有数据依赖的程序拆分成多个线程,然后放到多个核心上跑,是不是
比程序在单核上跑耗时更短了?
但是值得注意的是:**一个线程在一个时刻,只能运行在一个处理器核心上**.
3.现代操作系统一般采用时分的形式调度,操作系统会分出一个一个的时间片,线程会分配到若干个时间片,当线程的时间片用完了就会发生线程调度,
并且等着下次分配.
换句话说,一个线程能分配到的时间片越多,那么就能占用更长的 CPU 资源,就能干跟多的活. 那什么决定一个线程能够分配多少时间片了?
答案是线程的优先级. 但是我在网上看到有人说,线程的优先级在更大的可能上让优先级高的线程先执行,低优先级的线程在大概率上后执行. 也有人
说线程的优先级高的获得更多的 CPU 资源(获得更多的时间片). 其实这两种说法是不矛盾的. 获得很多的时间片,也就意味着在大概率上先执行.
在 java 线程中,通过一个 priority 变量来控制优先级,优先级的范围是从 1~10. 可以在线程构造时设置,也可以通过 setPriority() 方法
设置线程优先级. 默认的线程优先级是 5.
在设置线程优先级时,针对平凡阻塞(休眠或者I/O操作)的线程需要设置较高优先级,而偏重计算(需要较多CPU时间或者偏运算)的线程则设置较低的
优先级,确保处理器不会被独占. 如果理解了?可以这么理解,I/O操作一般执行时间较短,而偏重计算型的任务一般需要大量的时间. 如果将本来就
耗时的操作还分配跟多的时间片,那么 CPU 将会被偏重计算的线程占有,其他很快能够执行完的线程一直等待(不能及时响应). 现在反过来,让耗时
短的任务先执行,那么偏重计算型的任务则不需要等待那么长的时间(能够更快的响应).
但是需要注意的是:**在不同的 JVM 以及操作系统上,有些系统会忽略对线程优先级的设定**.
4.线程的状态
关于这点可能有的小伙伴有疑问了?线程状态不是只有 5 种状态吗?为啥会有 6 种状态了?其实 5 种状态是早期进程的状态.
1.创建状态:创建进程过程非常复杂,首先需要申请一个空白的 PCB,并向线程中写入用于控制和管理进程的信息. 然后为其分配必要的资源,
最后把线程加入到就绪队列中
2.就绪状态:就绪状态就是说该线程已经准备好运行,只要给我时间片,我就能运行.
3.运行状态:指的是已经获取 CPU 的进程,目前处于执行状态.
4.阻塞状态:指的是正在执行的进程由于发生某件事,例如 I/O 操作等,暂时无法执行,这个状态称为阻塞状态.
5.终止状态:终止状态指的是线程的最终状态,要么线程执行完,要么由于无法解决的错误,被操作系统终止,或者被有权限终止的线程终止.
转换规则:
1.创建状态 -> 就绪状态
获得时间片
2.就绪状态 -> 运行状态
时间片用完
3.运行状态 <- 就绪状态
I/O操作
4.运行状态 -> 阻塞状态
I/O操作完成
5.阻塞状态 -> 就绪状态
线程的 6 大状态
1.初始状态 —— NEW(线程刚被创建,但是还没有调用 start 方法)
2.运行状态 —— RUNNABLE(Java 线程将就绪和运行两种状态统称为运行中)
3.阻塞状态 —— BLOCKED(阻塞状态,表示线程阻塞于锁)
4.等待状态 —— WAITING(表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定的动作,通知或者中断)
5.超时等待状态 —— TIME_WAITING(该状态不同于 WAITING,它可以在指定的时间自行返回)
6.终止状态 —— TERMINATED(终止状态,表示当前线程已经执行完毕)
转换规则:
调用 start 方法
1.创建状态 -> 运行状态(就绪)
获得时间片
2.运行状态(就绪) -> 运行状态(运行中)
等待进入 sync
3.运行状态 -> 阻塞
获取到锁
4.阻塞 -> 运行状态
sleep(100)/Object.wait(100)等
5.运行状态 -> 超时等待状态
notify/unpark
6.超时等待状态 -> 运行状态
wait/join/park
7.运行状态 -> 等待状态
notify/unpark
8.等待状态 -> 运行状态
参考:https://www.zhihu.com/question/56494969
注意的是:只有进入 synchronized 关键字修饰的方法或代码块时,才会是阻塞状态,其他状态都是等待状态. 这样很好理解,Java 中 Lock 的
实现在没有获取到锁的情况下,调用的是 park 方法进行阻塞,这个就是等待状态.
5.守护线程
(1)当不存在非守护线程的时候,java 虚拟机将退出.
(2)可以使用 setDaemon(true) 来设置守护线程,但是要在线程启动前设置,如果线程启动后设置会报错.
(3)Daemon 线程中的 finally 块在 Java 虚拟机退出的时候并不一定会执行. 换句话说,当不存在守护线程的时候,所有的守护线程
**立即终止**,所以,守护线程中的 finally 块可能来不及执行.
6.新线程的创建
(1)新线程是有其 parent 线程来构造的,子线程继承父线程的 daemon, 优先级,contextClassLoader 以及可继承的 ThreadLocal,同时
还会分配一个唯一的 ID 来标识此线程.
7.中断
(1)中断可以理解为线程的一个标识位属性,它标识一个运行中的线程是否被其他线程进行了中断操作. 中断线程好比其他线程对这个线程打了个招呼,
其他线程调用该线程的 interrupt() 方法对其进行中断操作.
(2)被中断线程通过检查自己是否被中断来进行响应. 可以通过 isInterrupted() 方法来进行检查.
(3)可以通过调用 Thread.isInterrupted() 来对当前线程的中断标志进行复位.
注意:**如果该线程已经处于终结状态,即使该线程被中断过,调用 isInterrupted() 方法依旧会返回 false**.
但是我们经常会看到如下写法:就是一个方法在抛出异常前,会将该线程的中断标志位清除,然后抛出异常. 例如 Thread.sleep() 方法. 为什么
要这么设计了?可以这么想,如果说只要中断了,Thread.sleep() 方法就直接抛出异常(不进行复位操作), 那么下一次其他线程在进行中断,该
方法是不是就不能响应中断了?所以,你会发现,JDK 在实现的时候,会有好多的小细节值得我们思考.
8.suspend & resume & stop
(1)suspend 挂起
(2)resume 恢复
(3)stop 终止
这三个 API 已经过期,不建议使用. 为什么了?suspend 调用后,线程不会释放已经占有的资源. 如果先执行了 resume,然后再调用 suspend,
那么将会造成线程无限挂起. 为什么弃用 stop 了?因为 stop() 方法在终结一个线程时不会保证线程的资源正常释放,通常没有给线程释放资源
的聚会,因此导致程序可能工作在不确定的状态下.
上面的方法可以通过 wait/notify 或 lock/conditon 来代替.
9.安全的终止线程
中断只是一个标志,如果程序不响应中断的话,那么你调用 interrupt() 方法是不起作用的. 中断一般用来取消任务,同时也可以使用一个标志位
来停止或者终止任务.
例如:
class A {
private static class Count implements Runnable {
private volatile boolean on = true;
private int i;
public void run(){
while(on && !Thread.currentThread().isInterrupted()){
i++;
}
System.out.println("i= " + i);
}
public void cancle(){
if(on){
on = false;
}
}
}
}
注意:**前提是程序要响应中断,如果程序中没有使用 Thread.isInterrupted 来判断,则中断将会不起作用,一定要理解中断只是一个标志**.
同时这种方式将会更加的优雅(相比较 stop 而言)
10.线程间的通信
(1) 使用 volatile
(2) 使用 synchronized
(3) wait/notify
(4) lock/condition
在使用 wait/notify 时有一些注意事项:
(1) wait/notify/notifyAll 必须位于 synchronized 中
(2) 调用 wait 方法后,线程状态由 RUNNING -> WAITING
(3) notify/notifyAll 方法调用后,等待线程依旧不会从 wait() 方法返回,需要等待调用 notify/notifyAll 的线程释放锁后,等待线程
才有机会从 wait 返回.
(4) notify 方法将一个等待线程从等待队列中移到同步队列中,被移动的线程状态由 WAITING -> BLOCKED.
(5) 从 wait 返回的条件是获得锁.
具体步骤:
A: synchronized(obj){
obj.wait();
}
B: synchronized(obj){
obj.notify();
}
(1)首先线程 A 获得锁,在同步代码块中调用 obj.wait 方法,释放锁,进入到等待队列,线程状态由 RUNNING -> WAITING.
(2)线程B获得锁,在同步代码块中调用 obj.notify 方法,唤醒 A. A 从等待队列移动到同步队列,且线程状态由 WAITING -> BLOCKED.
(3)线程A获得锁,并从 wait 方法返回.
管道输入/输出流
PipedOutputStream/PipedInputStream/PipedReader/PipedWriter 用于线程间的数据传递. 方式:内存为媒介.
如何实现的了?
(1) PipedWriter 和 PipedReader 连接.
(2) 一个线程在 PipedWriter 写入,一个线程从 PipedReader 读取,这样就可以实现通讯了.
注意:**Piped 类型的流,必须先进行绑定(调用 connect) 方法,如果没有进行绑定,则会在使用的过程中抛出异常**.
这样很好理解,比如说我们在读写文件的时候,input 和 output 是如何进行关联了?答案是不是文件?所以此处进行绑定的道理是一样的.
11.Thread.join
Thread.join 的含义是:当前线程等待 thread 线程终止后,才从 join 方法返回. 同时 thread 中的共享变量对当前线程可见.
12.ThreadLocal
每个线程内部持有一个 ThreadLocalMap 的东西,而我发现 ThreadLocal 内中 nextHashCode 为静态变量,这就意味着该变量为所有 ThreadLocal 锁共有.
现在考虑一种极端情况,有两个 ThreadLocal 实例:ThreadLocalA 和 ThreadLocalB. 两个线程:ThreadA 和 ThreadB.
ThreadLocalA 存有线程A和线程B的数据. 反应到底层的数据结构是:
ThreadA.ThreadLocalMap<ThreadLocalA, Value>
ThreadB.ThreadLocalMap<ThreadLocalA, Value>
现在 ThreadLocalB 也存有线程A和线程B的数据. 反应到底层的数据结构是:
ThreadA.ThreadLocalMap<ThreadLocalB, Value>
ThreadB.ThreadLocalMap<ThreadLocalB, Value>
这时候,nextHashCode 就起作用了,每个 threadLocal 的 threadLocalHashCode 不同(存在一个神奇的 hash 值:0x61c88647),具体情况自行百度.
所以ThreadA 和 ThreadB 中存放这两个值的时候很大概率不会出现冲突. 这也是为啥 threadLocalHashCode 是 final 修饰,而 nextHashCode 是 static 修饰的原因.
13.线程应用实例
ps:
public void synchronized get(long timeout) {
long future = Thread.currentTimeMills() + timeout;
long remaining = timeout;
while(remaining > 0){
wait(timeout);
remaining = future - Thread.currentTimeMills();
}
}
**这进行系统设计的时候,特别是针对稀缺资源的获取,例如数据库连接,应该使用超时获取这样的设计,这样在获取不到的时候,线程不会一直挂在
连接获取的操作上,而是按时返回,并告知客户端连接获取出现问题,这是一种系统的自我保护机制**.
线程不是创建的越多越好,线程越多,那么系统在进行上下文切换的时候,已经线程的创建和销毁,都会耗费大量的时间,所以需要使用到线程池
技术.
那么如何设计线程池了?
1.创建的线程数量应当有限
2.应当有一个任务队列,用于存放提交的任务.
3.应当有一个工人队列,用于执行提交的任务.
所以线程池的设计很清晰了,1创建工人(Worker & Thread),启动,并从任务队列中读取任务,执行. 2用户将任务提交到线程池.当任务队列中
没有任务的时候,该如何处理了?答案是 Worker 休眠