理解Java多线程, 需要深入理解`线程状态和锁`
[TOC]
线程
由于每个时钟周期内, CPU 实际上只能执行一条指令. CPU每一个时刻只能做一件事, 多线程是通过任务调度给CPU分配任务实现的, 多线程的目的是为了最大限度的利用CPU资源.
操作系统负责管理进程和线程, 轮流(没有固定的顺序)分配每个进程很短的时间(不一定是均分), 然后在每个线程内部, 程序代码自己处理该进程内部线程的时间分配, 多个线程之间相互的切换去执行, 这个切换时间也是非常短的.
程序、进程、线程之间的关系
- 程序是一段静态的代码,是应用软件执行的蓝本。
- 进程是程序一次动态执行的过程,它对应了从代码加载、执行完毕的一个完整过程,这也是进程开始到消亡的过程。
- 线程是进程中独立、可调度的执行单元,是执行中最小单位。
- 一个程序一般是一个进程,但一个程序中也可以有多个进程。
- 一个进程中可以有多个线程,但只有一个主线程。
- Java应用程序中默认的主线程是main方法,如果main方法中创建了其他线程,JVM就会执行其他的线程。
Java 进程
Java编写的程序是运行在JVM中的, 启动一个Java应用程序, 就会启动一个JVM进程. 在同一个JVM进程中, 有且只有一个进程, 就是它自己. 因此, 所有的程序代码的运行都是以线程运行的. 同一个进程中的所有线程共享一块内存块, 因此线程间通信很容易且速度很快.
- Java 中的线程是一个对象, 与其他 Java 中的对象一样, 具有变量和方法, 生死于堆上.
调用栈
- Java 中的每个线程都有一个调用栈, 即使不创建任何新的线程, 线程也在后台运行着.
- 一旦创建一个新的线程, 就产生一个新的调用栈.
主线程
在JVM上运行一个应用程序时, JVM首先寻找程序入口的main()方法, 然后运行main()方法, 此时就产生了一个Java线程, 这个线程就是主线程. 当main方法结束后, 主线程运行完成, 如果不存在额外的线程运行, JVM进程随即退出.
调度的方式有两种:分时调度和抢占式调度, Java中采用的是抢占式调度
线程优先级
1 | static int MAX_PRIORITY |
当线程池中线程都具有相同的优先级, 调度程序的JVM实现自由选择它喜欢的线程. 这时候调度程序的操作有两种可能:
一是选择一个线程运行, 直到它阻塞或者运行完成为止.
二是时间分片, 为池内的每个线程提供均等的运行机会.
1~10之间的值是没有保证的. 一些JVM可能不能识别10个不同的值, 而将这些优先级进行每两个或多个合并, 变成少于10个的优先级, 则两个或多个优先级的线程可能被映射为一个优先级.
与线程休眠类似, 线程的优先级仍然无法保障线程的执行次序. 只不过, 优先级高的线程获取CPU资源的概率较大, 优先级低的并非没机会执行.
线程的生命周期
创建–运行–中断–死亡
- 创建:线程构造
- 运行:调用start()方法,进入run()方法
- 中断:sleep()、wait()
- 死亡:执行完run()方法或强制run()方法结束,线程死亡
线程的状态与转换

new与新建状态
新建线程有两个方法: 继承Thread类 和 实现Runnable接口.
Runnable 接口的定义如下:
1 | public interface Runnable { |
实现Runnable接口就需要实现run方法. run方法的内容就是线程要执行的任务.
Thread类, 是实现了Runable接口的类, 因此在Thread类中也存在run方法.
新建线程有若干种重载方法:
1 | public Thread(Runnable target) { |
start 启动与就绪状态(即可运行状态)
调用Thread.start()方法, 该线程进入Runable(可运行)状态, 等待分配 CPU 资源, 当抢占到CPU资源时,
该线程启动,开始执行run方法.
Thread.start()是唯一可以新建线程的方法. 执行 Thread.run() 和 Runable.run() 只会执行run方法, 不会启动新的线程.
一旦线程启动, 它就永远不能再重新启动. 只有一个新的线程可以被启动, 并且只能一次. 一个可运行的线程或死线程可以被重新启动.
线程的调度是JVM的一部分, 在一个CPU的机器上, 实际上一次只能运行一个线程. 一次只有一个线程栈执行. JVM线程调度程序决定实际运行哪个处于可运行状态的线程. 众多可运行线程中的某一个会被选中作为当前线程. 可运行线程被选择运行的顺序是没有保障的. 尽管通常采用队列形式, 但这是没有保障的. 队列形式是指当一个线程完成“一轮”时, 它移到可运行队列的尾部等待, 直到它最终排队到该队列的前端为止, 它才能被再次选中. 事实上, 我们把它称为可运行池而不是一个可运行队列, 目的是帮助认识线程并不都是以某种有保障的顺序排列成一个队列的事实.
Running 运行
运行状态, 执行run()方法的内容.
当 Java 虚拟机继续执行线程, 直到下面任一情况出现为止:
- 调用 Runtime的 exit 方法
System.exit() - 非守护线程全部停止运行, 无论是从
run方法返回还是通过抛出一个传播到run方法之外的异常.
几种特殊情况可能使线程离开运行状态:
- 线程的
run()方法完成. - 在对象上调用
wait()方法(不是在线程上调用). - 线程不能在对象上获得锁定, 它正试图运行该对象的方法代码.
- 线程调度程序可以决定将当前运行状态移动到可运行状态, 以便让另一个线程获得运行机会, 而不需要任何理由.
sleep() 休眠
1 | try{ |
调用 Thread 的静态方法可以进入休眠, sleep()作用:
- 执行带锁的代码时, 不会释放锁
- 进入Blocked状态
- 线程在mills时间内不会醒来
- mills时间到了, 线程变为
Runnable状态 - 再次进入运行状态, 继续执行 sleep() 后面的代码
Thread.sleep(long millis)和Thread.sleep(long millis, int nanos)静态方法强制当前正在执行的线程休眠(暂停执行), 以“减慢线程”.
当线程睡眠时, 它入睡在某个地方, 在苏醒之前不会返回到可运行状态. 当睡眠时间到期, 则返回到可运行状态.
线程睡眠的原因:线程执行太快, 或者需要强制进入下一轮, 因为Java规范不保证合理的轮换.
当休眠一定时间后, 线程会苏醒, 进入准备状态等待执行.
Blocked 阻塞状态
阻塞状态是线程因为某种原因放弃CPU使用权, 暂时停止运行. 直到线程进入就绪状态, 才有机会转到运行状态. 阻塞的情况分三种:
- 等待阻塞:运行的线程执行
wait()方法, JVM会把该线程放入等待池中. - 同步阻塞:运行的线程在获取对象的同步锁时, 若该同步锁被别的线程占用, 则JVM会把该线程放入锁池中.
- 其他阻塞:运行的线程执行
sleep()或join()方法, 或者发出了I/O请求时, JVM会把该线程置为阻塞状态.
当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时, 线程重新转入就绪状态.
Synchronized锁与同步
在 Java 中每个对象都有一个锁,(问题来了: Java对象锁信息保存在哪里?) 并且对象的锁同时只能被一个线程使用, 因此当某个线程得到对象的锁时, 其他线程也就没办法获得锁.
利用对象的锁, 可以实现只允许一个线程访问, 即同步.
当线程运行到 synchronized 时, 首先检测是否可以获得对象的锁, 如果可以获得,则马上获取锁. 如果不能获取锁, 线程阻塞, 开始等待其他线程释放锁.
当同步锁被释放时, 线程重新进入 Runnable 可运行状态.
需要同步时,一定要搞清楚加锁的对象是什么
关于锁和同步, 有一下几个要点:
- 只能同步方法, 而不能同步变量和类;
- 每个对象只有一个锁;当提到同步时, 应该清楚在什么上同步?也就是说, 在哪个对象上同步?
- 不必同步类中所有的方法, 类可以同时拥有同步和非同步方法.
- 线程睡眠(执行
sleep)时, 它所持的任何锁都不会释放. - 线程可以获得多个锁. 比如, 在一个对象的同步方法里面调用另外一个对象的同步方法, 则获取了两个对象的同步锁.
同步方法
在方法名称前面增加 synchronized 关键字, 此时相当于以类的对象(即this)作为对象锁.
1 | public synchronized void function(){ |
同步代码块
1 | synchronized(object1){ |
以 object1作为对象锁
同步静态方法
静态方法同步是以方法所在的class对象作为锁的.
要同步静态方法, 需要一个用于整个类对象的锁, 这个对象是就是这个类(XXX.class).
例如:1
2
3
4
5
6
7
8
9public static synchronized int setName(String name){
Xxx.name = name;
}
// 等价于
public static int setName(String name){
synchronized(Xxx.class){
Xxx.name = name;
}
}实质上, 线程进入该对象的的一种池中, 必须在那里等待, 直到其锁被释放
调用同一个类中的静态同步方法的线程将彼此阻塞, 它们都是锁定在相同的Class对象上.
静态同步方法和非静态同步方法将永远不会彼此阻塞, 因为静态方法锁定在Class对象上, 非静态方法锁定在该类的对象上.
对于非静态字段中可更改的数据, 通常使用非静态方法访问.
对于静态字段中可更改的数据, 通常使用静态方法访问.
减少锁定时间
线程同步的目的是为了保护多个线程访问一个资源时对资源的破坏. 线程同步方法是通过锁来实现, 每个对象都有且仅有一个锁, 这个锁与一个特定的对象关联, 线程一旦获取了对象锁, 其他访问该对象的线程就无法再访问该对象的其他同步方法.
synchronized中慎用sleep和yield
在使用synchronized关键字时候, 应该尽可能避免在synchronized方法或synchronized块中使用sleep或者yield方法:
因为synchronized程序块占有着对象锁, 你休息那么其他的线程只能一边等着你醒来执行完了才能执行. 不但严重影响效率, 也不合逻辑. 同样, 在同步程序块内调用yield方法让出CPU资源也没有意义, 因为你占用着锁, 其他互斥线程还是无法访问同步程序块. 当然与同步程序块无关的线程可以获得更多的执行时间.
wait 等待 与 notify/notifyAll 通知
wait 让本线程等待, notify 通知某个线程不再等待
wait()作用主要有:
- 释放锁
- 不继续执行 wait 后面的代码
- 本线程进入等待阻塞
A thread can also wake up without being notified, interrupted, or timing out, a so-called spurious wakeup. While this will rarely occur in practice, applications must guard against it by testing for the condition that should have caused the thread to be awakened, and continuing to wait if the condition is not satisfied. In other words, waits should always occur in loops, like this one:
线程也可以在没有被通知,中断或超时的情况下唤醒,即所谓的虚假唤醒。 虽然这在实践中很少发生,但应用程序必须通过测试应该导致线程被唤醒的条件来防范它,并且如果条件不满足则继续等待。 换句话说,等待应该总是出现在循环中
1 | while (!condition) { |
notify()与 notifyAll()
针对同一个对象锁上的线程, 主要的作用是:
- 不再等待, 从等待 Blocked 中转出
- 进入 Runnable 状态, 等待获取 CPU 资源. ??
线程唤醒 notify
Object类中的notify()方法, 唤醒在此对象监视器上等待的单个线程. 如果所有线程都在此对象上等待, 则会选择唤醒其中一个线程. 选择是任意性的, 并在对实现做出决定时发生. 线程通过调用其中一个 wait 方法, 在对象的监视器上等待. 直到当前的线程放弃此对象上的锁定, 才能继续执行被唤醒的线程. 被唤醒的线程将以常规方式与在该对象上主动同步的其他所有线程进行竞争;例如, 唤醒的线程在作为锁定此对象的下一个线程方面没有可靠的特权或劣势. 类似的方法还有一个notifyAll(), 唤醒在此对象监视器上等待的所有线程.
wait()、notify()、notifyAll()都是Object的实例方法. 与每个对象具有锁一样, 每个对象可以有一个线程列表, 他们等待来自该信号(通知). 线程通过执行对象上的wait()方法获得这个等待列表.
这3个方法必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为这几个方法必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象,
千万注意
当在对象上调用wait()方法时, 执行该代码的线程立即放弃它在对象上的锁. 然而调用notify()时, 并不意味着这时线程会放弃其锁. 如果线程仍然在完成同步代码, 则线程在移出之前不会放弃锁. 因此, 只要调用notify()并不意味着这时该锁变得可用.
notifyAll() 方法, 起到的是一个通知作用,不释放锁, 也不获取锁. 只是告诉该对象上等待的线程“可以竞争执行了, 都醒来去执行吧”
yield() 让步
- 当前线程让出, 进入
Runnable可执行状态 - 但是继续占着锁
- 同级别或较高级别的开始竞争 CPU 资源
Thread.yield()方法作用是:暂停当前正在执行的线程对象, 并执行其他线程.
yield()应该做的是让当前运行线程回到可运行状态, 以允许具有相同优先级的其他线程获得运行机会.
yield()从未导致线程转到等待/睡眠/阻塞状态. 在大多数情况下, yield()将导致线程从运行状态转到可运行状态, 但有可能没有效果.
// TODO 使用场景
join() 合并
假设在 A 线程中,执行B.join() , B 线程放到 A 线程前面执行. A 线程转入阻塞状态首先执行 B 线程,
直到执行完 B 线程后, A 线程转入可运行状态就绪, 获取到 CPU 资源后再继续执行 join 后面的代码
还有 join() 的重载形式:
1 | B.join(1000); |
首先执行 B 1000毫秒, 1000毫秒后, 即使是没有执行完, 也会停止执行 B 线程, 开始执行join 语句后面的代码
请看join的原始定义
1 | /** |
其实Join 方法实现是通过wait实现的.
当main 线程调用t.join 时候, main 线程会获得线程对象t 的锁 (wait 意味着拿到该对象的锁), 调用该对象的wait( 等待时间) ,直到该对象唤醒main 线程, 比如退出后.
stop 停止
避免使用stop(),是因为它不安全。它会解除由线程获取的所有锁定,而且如果对象处于一种不连贯状态,那么其他线程能在那种状态下检查和修改它们。结果很难检查出真正的问题所在。
suspend 暂停
suspend()方法容易发生死锁。调用suspend()的时候,目标线程会停下来,但却仍然持有在这之前获得的锁定。此时,其他任何线程都不能访问锁定的资源,除非被”挂起”的线程恢复运行。对任何线程来说,如果它们想恢复目标线程,同时又试图使用任何一个锁定的资源,就会造成死锁。所以不应该使用suspend(),而应在自己的Thread类中置入一个标志,指出线程应该活动还是挂起。若标志指出线程应该挂起,便用wait()命其进入等待状态。若标志指出线程应当恢复,则用一个notify()重新启动线程。
Thread
1 | static Thread currentThread() |
线程中断与synchronized
线程中断
正如中断二字所表达的意义,在线程运行(run方法)中间打断它,在Java中,提供了以下3个有关线程中断的方法
1 | //中断线程(实例方法) |
当一个线程处于被阻塞状态或者试图执行一个阻塞操作时,使用Thread.interrupt()方式中断该线程,注意此时将会抛出一个InterruptedException的异常,同时中断状态将会被复位(由中断状态改为非中断状态),如下代码将演示该过程:
1 | public class InterruptSleepThread3 { |
如上述代码所示,我们创建一个线程,并在线程中调用了sleep方法从而使线程进入阻塞状态,启动线程后,调用线程实例对象的interrupt方法中断阻塞异常,并抛出InterruptedException异常,此时中断状态也将被复位。除了阻塞中断的情景,我们还可能会遇到处于运行期且非阻塞的状态的线程,这种情况下,直接调用Thread.interrupt()中断线程是不会得到任响应的,如下代码,将无法中断非阻塞状态下的线程:
1 | public class InterruputThread { |
虽然我们调用了interrupt方法,但线程t1并未被中断,因为处于非阻塞状态的线程需要我们手动进行中断检测并结束程序,改进后代码如下:
1 | public class InterruputThread { |
- 一种是当线程处于阻塞状态或者试图执行一个阻塞操作时,我们可以使用实例方法interrupt()进行线程中断,执行中断操作后将会抛出interruptException异常(该异常必须捕捉无法向外抛出)并将中断状态复位,
- 另外一种是当线程处于运行状态时,我们也可调用实例方法interrupt()进行线程中断,但同时必须手动判断中断状态,并编写中断线程的代码(其实就是结束run方法体的代码)。有时我们在编码时可能需要兼顾以上两种情况,那么就可以如下编写:
1 | public void run(){ |
事实上线程的中断操作对于正在等待获取的锁对象的synchronized方法或者代码块并不起作用,也就是对于synchronized来说,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效。演示代码如下
1 | /** |
我们在SynchronizedBlocked构造函数中创建一个新线程并启动获取调用f()获取到当前实例锁,由于SynchronizedBlocked自身也是线程,启动后在其run方法中也调用了f(),但由于对象锁被其他线程占用,导致t线程只能等到锁,此时我们调用了t.interrupt();但并不能中断线程。
线程死锁
线程A当前持有互斥所锁 lock1 ,线程B当前持有互斥锁 lock2 。 接下来,当线程A仍然持有lock1时,它试图获取lock2,因为线程B正持有lock2,因此线程A会阻塞等待线程B对lock2的释放。如果此时线程B在持有lock2的时候,也在试图获取lock1,因为线程A正持有lock1,因此线程B会阻塞等待A对lock1的释放。二者都在等待对方所持有锁的释放,而二者却又都没释放自己所持有的锁,这时二者便会一直阻塞下去。这种情形称为死锁。
规避死锁:
- 只在必要的最短时间内持有锁,考虑使用同步语句块代替整个同步方法;
- 尽量编写不在同一时刻需要持有多个锁的代码,如果不可避免,则确保线程持有第二个锁的时间尽量短暂;
- 创建和使用一个大锁来代替若干小锁,并把这个锁用于互斥,而不是用作单个对象的对象级别锁;
同步方案
在需要同步的时候,第一选择应该是synchronized关键字,这是最安全的方式,尝试其他任何方式都是有风险的。尤其在jdK1.5之后,对synchronized同步机制做了很多优化,如:自适应的自旋锁、锁粗化、锁消除、轻量级锁等,使得它的性能明显有了很大的提升。
[volatile][a-volatile] 也是确保可见性的方法之一,但是不能实现原子性
互斥锁
如果同一个方法内同时有两个或更多线程,则每个线程有自己的局部变量拷贝, 采用 synchronized 修饰符实现的同步机制叫做互斥锁机制,它所获得的锁叫做互斥锁。 每个对象都有一个monitor(锁标记),当线程拥有这个锁标记时才能访问这个资源,没有锁标记便进入锁池。 任何一个对象系统都会为其创建一个互斥锁,这个锁是为了分配给线程的,防止打断原子操作。每个对象的锁只能分配给一个线程,因此叫做互斥锁。
互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。synchronized 关键字经过编译后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令。根据虚拟机规范的要求,在执行monitorenter 指令时,首先要尝试获取对象的锁,如果获得了锁,把锁的计数器加1,相应地,在执行 monitorexit 指令时会将锁计数器减1,当计数器为0时,锁便被释放了。由于synchronized同步块对同一个线程是可重入的,因此一个线程可以多次获得同一个对象的互斥锁,同样,要释放相应次数的该互斥锁,才能最终释放掉该锁。
当一个线程进入一个对象的一个synchronized方法后,其它线程是否可进入此对象的其它方法? 分几种情况:
- 其他方法前是否加了synchronized关键字,如果没加,则能。
- 如果这个方法内部调用了wait,则可以进入其他synchronized方法。
- 如果其他个方法都加了synchronized关键字,并且内部没有调用wait,则不能。
- 如果其他方法是static,它用的同步锁是当前类的字节码,与非静态的方法不能同步,因为非静态的方法用的是this。
线程安全类
请查看 Java集合
守护线程
线程总体分两类:用户线程和守候线程.
当所有用户线程执行完毕的时候, JVM自动关闭. 但是守候线程却不独立于JVM, 守候线程一般是由操作系统或者用户自己创建的.
举例来说, JVM的垃圾回收、内存管理等线程都是守护线程.
还有就是在做数据库应用时候, 使用的数据库连接池, 连接池本身也包含着很多后台线程, 监控连接个数、超时时间、状态等等.
Timer
有一个后台运行的线程, 按照指定的时间执行定时任务. Timer.schedual()方法向后台线程添加定时任务,
后台线程按照的既定的时间执行定时任务.
调用Timer.cancel()取消所有已安排的定时任务, 正在的执行的任务不会被取消.
调用构造方法, 后台线程就已经启动.
[参考文献]: