2.1线程与进程
进程:程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。
线程:一个进程之内可以分为一到多个线程
对比:
进程间的通信较为复杂:同一台计算机的进程通信称为 IPC(Inter-process communication)。不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP。
线程通信简单,因为他们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量。
线程更轻量,线程上下文切换成本一般上要比进程上下文切换低
2.2并行与并发:
单核 cpu 下,线程实际还是 串行执行 的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片(windows 下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感 觉是 同时运行的 。总结为一句话就是: 微观串行,宏观并行 ,一般会将这种 线程轮流使用 CPU 的做法称为并发。
多核 cpu下,每个 核(core) 都可以调度运行线程,这时候线程可以是并行的。
-
并发(concurrent)是同一时间应对多件事情的能力
-
并行(parallel)是同一时间动手做多件事情的能力
例子 家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发 家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一 个人用锅时,另一个人就得等待) 北京市昌平区建材城西路金燕龙办公楼一层 电话:400-618-9090 雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行
2.3应用
以调用方角度来讲,如果
-
需要等待返回结果,才能继续运行就是同步
-
不需要等待返回结果,才能继续运行就是异步
比如在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程 tomcat 的异步 servlet 也是类似的目的,让用户线程处理耗时较长的操作,避免阻塞 tomcat 的工作线程 ui 程序中,开线程进行其他操作,避免阻塞 ui 线程
结论:多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任 务都能拆分(参考后文的【阿姆达尔定律】) 也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义
3.4 原理之线程运行
线程上下文切换(Thread Context Switch)
因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码:
-
线程的 cpu 时间片用完
-
垃圾回收
-
有更高优先级的线程需要运行
-
线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法
3.6start与run
直接调用 run 是在主线程中执行了 run,没有启动新的线程使用 start 是启动新的线程,通过新的线程间接执行 run 中的代码
3.7 sleep 与 yield sleep
sleep:调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞) 2. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException 3. 睡眠结束后的线程未必会立刻得到执行 4. 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性
yield : 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程,允许其他具有相同或更高优先级的线程获得执行机会。
yield方法不会释放锁,当一个线程调用yield
方法时,它仅是暂停当前线程的执行并让出CPU资源给其他具有相同或更高优先级的线程。但是,该线程仍然继续持有它之前已经获取的锁。
只有当线程退出了同步块或同步方法,并且释放了锁时,其他线程才能获得该锁并执行相应的同步代码。
如果你希望在执行yield
期间释放锁,可以使用关键字synchronized
来包裹需要释放锁的代码块或方法。这样,在调用yield
后,线程将释放锁,并允许其他线程进入同步块或同步方法。
3.8 join方法详解
有时效的join
线程结束会导致join结束
3.9 interrupt方法详解
interrupt可以打断睡眠、阻塞和运行状态的线程。
-
打断sleep、wait、join的线程,在被断后会清空打断标记,t.isInterrupted()=false;
-
打断正常运行的线程,不会清空打断标记,t.isInterrupted=true。依靠打断打断标记线程才能停下来。靠自己决定,线程是继续运行还是停下来。
LockSupport.park()方法是Java中的一个线程阻塞工具。它允许当前线程进入休眠状态,直到被其他线程显示地唤醒。当调用park()方法时,当前线程将被挂起,不会占用CPU资源,直到满足下列条件之一:
-
其它线程调用了该线程的unpark()犯法,解除了挂起状态
-
其他线程中断了该线程
打断park线程
打断park线程不会清除打断标记
如果打断标记已经是true,则park会失效
可以用Thread.interrupted清楚打断状态(isInterrupted=false)
3.11 主线程与守护线程
默认情况下,Java 进程需要等待所有线程都运行结束,才会结束。有一种特殊的线程叫做守护线程,只要其它非守护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。
// 设置该线程为守护线程 t1.setDaemon(true);
-
垃圾回收器线程就是一种守护线程
-
Tomcat 中的 Acceptor 和 Poller 线程都是守护线程,所以 Tomcat 接收到 shutdown 命令后,不会等 待它们处理完当前请求
3.13六种状态
从JAVA API层面来描述
-
NEW 线程刚被创建,但是还没有调用 start() 方法
-
RUNNABLE 当调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的 【可运行状态】、【运行状态】和【阻塞状态】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为 是可运行)
-
BLOCKED , WAITING , TIMED_WAITING 都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节 详述
-
TERMINATED 当线程代码运行结束
常见状态:
在Java中,线程可以具有以下几种状态:
-
新建(New): 当线程对象被创建时,它处于新建状态。此时线程尚未启动。
-
就绪(Runnable): 在调用线程的
start()
方法后,线程进入就绪状态。此时线程已经准备好运行,并等待获取CPU资源。 -
运行(Running): 当线程获得CPU资源时,进入运行状态并开始执行线程体内的代码。
-
阻塞(Blocked): 在某些情况下,线程可能会因为等待某个条件的满足或者试图获取一个被其他线程持有的锁而无法继续执行。这时,线程进入阻塞状态。
-
等待(Waiting): 线程在某些特定的条件下进入等待状态。例如,通过调用
Object.wait()
方法、Thread.join()
方法或者LockSupport.park()
方法等。 -
计时等待(Timed Waiting): 类似于等待状态,但是在一定时间后会自动返回。例如,通过调用带有超时参数的
Thread.sleep()
方法、Object.wait(long timeout)
方法或者LockSupport.parkNanos()
方法等。 -
终止(Terminated): 线程完成了它的任务或者出现了异常,进入终止状态。线程不可再次启动。
4.1临界区
临界区 Critical Section (对共享资源有读写操作的)
-
一个程序运行多个线程本身是没有问题的
-
问题出在多个线程访问共享资源
-
多个线程读共享资源其实也没有问题
-
在多个线程对共享资源读写操作时发生指令交错,就会出现问
-
-
一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
4.2 synchronized 解决方案
为了减少锁和释放锁带来的性能消耗,引入偏向锁和轻量级锁,锁的状态变成了4种。
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
-
阻塞式的解决方案:synchronized,Lock
-
非阻塞式的解决方案:原子变量
本次课使用阻塞式的解决方案:synchronized,来解决上述问题,即俗称的【对象锁】,它采用互斥的方式让同一 时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁 的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切 换所打断。
如果把 synchronized(obj) 放在 for 循环的外面,如何理解?-- 原子性
如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?-- 锁对象
如果 t1 synchronized(obj) 而 t2 没有加会怎么样?如何理解?-- 锁对象
4.3 方法上的 synchronized
当一个线程调用 Thread.sleep()
方法时,它会进入阻塞状态,但仍然持有锁。这意味着其他线程无法获得同一把锁,并且不能进入被 synchronized
保护的临界区域。
synchronized放在方法上锁的是当前对象
synchronized放在静态方法上锁的是整个对象
每个线程调用 test1() 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享
4.4 线程安全性分析
常见线程安全类
String
Integer
StringBuffer
Random
Vector
Hashtable
java.util.concurrent 包下的类
它们的每个方法是原子的 但注意它们多个方法的组合不是原子的
4.6 Monitor概念
重量级锁的实现是基于monitor的 当一个对象获取到syn锁时,这个对象的mark word会有指向monitor的指针
阻塞会发生上下文切换
4.7 wait notify
syn原理进阶
1.轻量级锁
轻量级锁 轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以 使用轻量级锁来优化。 轻量级锁对使用者是透明的,即语法仍然是 synchronized
2.锁膨胀
API 介绍
-
obj.wait() 让进入 object 监视器的线程到 waitSet 等待
-
obj.notify() 在 object 上正在 waitSet 等待的线程中挑一个唤醒
-
obj.notifyAll() 让 object 上正在 waitSet 等待的线程全部唤醒
它们都是线程之间进行协作的手段,都属于 Object 对象的方法。必须获得此对象的锁,才能调用这几个方法
wait()方法会释放对象的锁,进入WaitSet等待区,从而让其它线程有机会获得对象的锁。无限制等待,知道notify为止。
wait(long n)有时限的等待,到n毫秒后结束等待,或是被notify
4.8 wait notify 的正确姿势
sleep(long n) 和 wait(long n) 的区别:
1.sleep是Thread的方法,而wait是Object类的方法
2.sleep不需要强制和syn配合使用,但wait需要和syn一起使用
3.sleep在睡眠的同时,不会释放对象锁,而wait在等待的时候会释放对象的锁
4.它们的状态是TIMED_WAITING
4.9 Park&Unpark
它们是 LockSupport 类中的方法
-
LockSupport.park(); // 暂停当前线程
-
LockSupport.unpark(暂停线程对象) // 恢复某个线程的运行
特点:与 Object 的 wait & notify 相比
-
wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park,unpark 不必
-
park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程,就不那么【精确】
-
park & unpark 可以先 unpark,而 wait & notify 不能先 notify
总结来说,park
是一种主动等待的机制,通过调用 park
方法来阻塞线程,直到满足某些条件。而 wait
是被动等待的机制,通过调用对象的 wait
方法使线程进入等待状态,并且只能被其他线程唤醒。在具体应用中,它们的使用场景和语义略有不同。
4.10重新理解线程转换
4.12 活跃性
死锁
有这样的情况:一个线程需要同时获取多把锁,这时就容易发生死锁
t1线程获得A对象锁,接下来想获取B对象的锁t2线程获得B对象锁,接下来想获取A对象的锁 例如
活锁
活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,例如
饥饿
很多教程中把饥饿定义为,一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束,饥饿的情况不 易演示,讲读写锁时会涉及饥饿问题
下面我讲一下我遇到的一个线程饥饿的例子,先来看看使用顺序加锁的方式解决之前的死锁问题
4.13 ReentrantLock
相对于 synchronized 它具备如下特点
-
可中断
-
可以设置超时时间
-
可以设置为公平锁(默认为非公平锁)
-
支持多个条件变量
与 synchronized 一样,都支持可重入
可重入
可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁(同一个线程先后多次获取同一把锁)
如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住
可打断
锁超时
公平锁(多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁)
ReentrantLock 默认是不公平的