说明:本篇文章是在阅读《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
ThreadB.ThreadLocalMap
现在 ThreadLocalB 也存有线程A和线程B的数据. 反应到底层的数据结构是:
ThreadA.ThreadLocalMap
ThreadB.ThreadLocalMap
这时候,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 休眠