程序开发中并发的场景还是比较常见的,特别是当下分布式环环境开发大行其道的情况下,从前端处理,到服务调用、缓存处理、数据库处理、文件处理、消息处理等等,无不需要并发的知识。
从今天开始,我要写一个关于Java并发的系列文章,希望各位可以从中受益。
我们先从基础的线程开始说起!
一、线程基础知识
从宏观来看,简单说一下进程:所谓进程就是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
线程是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。一个进程中一般会有多个线程。
正式学习之前,最好先了解一下线程的生命周期,做到观其大略:
- 新建(NEW):新创建了一个线程对象。
- 可运行(RUNNABLE):线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取cpu 的使用权 。
- 运行(RUNNING):可运行状态(runnable)的线程获得了cpu 时间片(timeslice),执行程序代码。
- 阻塞(BLOCKED):阻塞状态是指线程因为某种原因放弃了cpu 使用权,也即让出了cpu timeslice,暂时停止运行。直到线程进入可运行(runnable)状态,才有机会再次获得cpu timeslice 转到运行(running)状态。阻塞的情况分三种:
(一). 等待阻塞:运行(running)的线程执行o.wait()方法,JVM会把该线程放入等待队列(waitting queue)中。
(二). 同步阻塞:运行(running)的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池(lock pool)中。
(三). 其他阻塞:运行(running)的线程执行Thread.sleep(long ms)或t.join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入可运行(runnable)状态。- 死亡(DEAD):线程run()、main() 方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。死亡的线程不可再次复生。
读完之后,可能还是会有一部分不太理解,问题不大,留个印象即可。
二、线程基本操作
1、新建并启动
Java中实现线程有两种方式,一种是继承Thread,一种是实现Runnable。直接看代码:
public class HelloThread extends Thread{
@Override
public void run() {
System.out.println("hello");
}
}
//和
public class HelloRunnable implements Runnable{
@Override
public void run() {
System.out.println("hello runnable");
}
}
有了线程的主体,来看看如何新建并启动线程:
new HelloThread().start();
//和
new Thread(new HelloRunnable()).start();
继承的Thread可以直接新建,并用start()方法启动;至于Runnable接口,则需要传入Thread来启动。
2、终止
原来线程中是有一个stop()方法的,现在已经被废弃,不建议使用。
原因呢,是因为stop()方法会强制终止线程,可能造成数据不一致的情况。
可以考虑通过另外的方式来终止线程:
class HelloThread extends Thread{
private boolean isStop;
@Override
public void run() {
try {
sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
if (!isStop) {
System.out.println("hello thread");
}
}
public void stopMe() {
isStop = true;
}
}
//main中的代码
HelloThread thread = new HelloThread();
thread.start();
thread.stopMe();
3、中断
因为stop()方法被弃用了,那么JDK中有没有更好的终止线程的方法呢?
当然有,就是线程中断。
线程中断的工作方式是:给执行中的线程发送一个通知,告诉他有人希望你退出了。执行线程收到通知后怎么做,取决于他自己的逻辑实现。
所谓的中断就是收到了停止的通知,请立刻停下你的魔爪!
Thread中有两个实例方法和一个静态方法,分别是:
- interrupt() 实例方法,它用来通知目标线程该停止了,请听指挥
- isInterrupted() 实例方法,判断当前线程是否收到了停止通知
- interrupted() 静态方法,也是判断当前线程是否收到了停止通知,但同时会清除当前线程的中断标志位状态
先来一个简单的例子,演示一下前两个方法:
class HelloThread extends Thread {
@Override
public void run() {
while (true) {
if (Thread.currentThread().isInterrupted()) {
System.out.println("stop!");
break;
}
}
}
}
//main中执行
HelloThread thread = new HelloThread();
thread.start();
thread.interrupt();
主要逻辑是:有人希望某个线程退出,然后调用了那个线程的interrupt()方法,执行中的目标线程会判断自己是否收到了这个通知,如果收到,则听党指挥、能打胜仗,鸣金收兵了!当然,也可以不对中断进行处理,那样不太规范,最好不要那样做。
因为线程的阻塞方式有三种,所以关于interrupt()方法的逻辑会稍微复杂一点,JDK文档上是这样说的:
如果当前线程被阻塞在wait系列方法、sleep系列方法或join系列方法时,则当前线程的中断状态被清除,并抛出InterruptedException异常;
如果当前线程被阻塞在InterruptibleChannel的I/O上,则channel将被关闭,并为线程设置中断状态,线程还会收到一个ClosedByInterruptException;
如果当前线程被阻塞在nio的Selector 上,则会为线程设置中断状态,并立马返回selection operation;
如果没有出现上述阻塞情况,则只会为线程设置中断状态
根据开头我们说的线程的三种阻塞状态来看,等待阻塞和其他阻塞中断机制可以很好的解决,那么同步阻塞呢?也就是遇见了synchronized或Lock.lock()的时候,中断机制又是如何运行呢?
答案是如果遇到同步阻塞,收到中断通知的线程并不会抛出异常。
这该如何是好?也许只好在某些节点用Thread.currentThread().isInterrupted()方法来判断了。
还好幸运的是,后面我们会讲到的Java并发工具包中的那些工具类,对于中断机制的支持还是比较完善,比如对于Lock类来说,可以改用Lock.lockInterruptibly()来加锁,至于用到synchronized关键字的话则需要注意处理方式了。
4、等待和通知
如果需要当前线程等待某个事件,事件完成获得通知之后再运行,就需要用到wait()和notify()方法了。
比如,线程A中,调用了obj.wait()方法,那么线程A就会停止继续执行,而转为等待状态。等到线程B调用了obj.notify()方法为止。这时,obj对象就俨然成为了多个线程之间的有效通信手段。 直接看代码:
public class Demo {
final static Object object = new Object();
public static class T1 extends Thread {
public void run() {
synchronized (object) {
try {
object.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static class T2 extends Thread {
public void run() {
synchronized (object) {
object.notify();
}
}
}
}
调用代码的逻辑可以是:启动T1,然后阻塞在T1;启动T2之后,T1和T2都运行中状态,然后终止。
obj.wait()并不是可以随便调用的,它必须包含在对应的synchronized语句中,无论是wait()或notify()都需要首先获得目标对象的一个监视器。这一点可以在代码中得到体现。
在唤醒线程层面,obj.notify()的时候,会随机选择一个线程来唤醒;另外还有一个notifyAll()方法,会唤起所有等待的线程,而不是随机一个。
5、join和yield
这两个方法分别用来让另一个线程加入和自身线程的谦让。
- join:当前线程A调用线程B的join()方法,线程A将进入到阻塞状态,直到线程B运行结束,A才会继续运行。通俗说,就是A和B关系好,A让B插队了
- yield:当前线程A执行了yield()方法,如果具有相同优先级的线程处于就绪状态,那么当前线程会让出CPU给相同优先级的线程运行的机会。如果没有相同优先级的线程,那么什么都不做。通俗说,就是线程A比较谦虚,功劳都让别人先领
三、synchronized关键字
上面只讲到线程的基础,包括线程的建立和一些简单的协作。
如果你要开始编写一个多线程的程序,那么就会面对很多问题。为了便于程序员之间的沟通,需要先了解一下下面的概念:
- 临界区:如果有一个共享资源(或称公共区域),多个线程都要访问它。但是为保证数据一致,每次都仅能有一个线程在使用这片公共区域,那么这个公共区域就叫做临界区
- 阻塞与非阻塞:阻塞这个词很多地方都能看到,不同场景下的解释应该略有差异。比如I/O阻塞,指的是系统调用的read或write被阻塞了,需要等待;比如线程阻塞,指的是线程访问临界区时,已有其他线程占用了临界区,本线程被阻塞。
接下来思考一个问题,也可以自己动手实验一下:如果多个线程对一个int数字进行修改,不做同步处理的情况下,这个数字的最终结果会符合我们预期么?
public class Demo {
static int num = 0;
public static class T1 extends Thread {
public void run() {
num++;
}
}
public static void main(String[] args) throws InterruptedException {
int step = 0;
while (true) {
num = 0;
test();
System.out.println(++step);
if (num != 5) {
System.out.println("num: " + num);
break;
}
}
}
public static void test() throws InterruptedException {
T1 t1 = new T1();
T1 t2 = new T1();
T1 t3 = new T1();
T1 t4 = new T1();
T1 t5 = new T1();
t1.start();
t2.start();
t3.start();
t4.start();
t5.start();
t1.join();
t2.join();
t3.join();
t4.join();
t5.join();
}
}
同时开五个线程,期待结果是五个线程运行后,num的结果为5。如果多次运行的话,就会体现出没有同步措施的可怕性。在我本机上,运行到5000次左右,会出现i=4的情况,所谓千里之堤毁于蚁穴,在计算机这么严谨的世界,小误差可能出现大问题。
Java中自然提供了同步机制,我们先从最简单的synchronized关键字说起。
synchronized关键字为Java提供了强制原子性的内部锁,什么是原子性呢?简单说,就是单独的,不可分割的操作。
一个synchronized块有两部分:锁对象的引用,以及这个锁保护的代码块。
先把上面的例子改成线程安全的,在分析synchronized的用法:
public static class T1 extends Thread {
public void run() {
synchronized(this)
num++;
}
}
可以看到这里的锁对象是this,也就是当前的实例对象,而代码块是num++
。
第一次看到这种写法,虽然大概知道他是干啥的,不过还是会有点朦胧。可以这么理解,synchronized关键字的隐含逻辑是:num++
之前当前线程对这片区域(代码块)加锁,这就告诉了其他线程,这一片暂时由我管控,诸位请稍等;num++
之后,当前线程释放了区域控制权,请其他大佬们获得控制权。当然,这种逻辑在计算机世界中切换的会非常快速。
回到专业方面,锁对象也是可以是其他对象,比如如果写成synchronized(T1.class)
也可以,那么锁对象就变成了是T1的Class对象。
还有另外两种方式,分别是指定一个独立的锁对象或synchronized直接修饰方法:
private static Object object = new Object();
public static class T1 extends Thread {
public void run() {
synchronized (object) {
num++;
}
}
}
//或
public static class T1 extends Thread {
public synchronized void run() {
num++;
}
}
用synchronized修饰方法的话,锁对象也就是当前的实例对象。
synchronized内部锁扮演了互斥锁的角色,意味着至多有一个线程可以拥有锁。
如果感兴趣,可以了解一下synchronized的实现原理,推荐文章:死磕Java并发:深入分析synchronized的实现原理
1、可重入
关于synchronized关键字,还有多介绍一个可重入的概念,先看代码:
class Widget {
public synchronized void doSomething() {
...
}
}
class LoggingWidget extends Widget {
@Override
public synchronized void doSomething() {
System.out.println("logging do something");
super.doSomething();
}
}
调用LoggingWidget .doSomething()的时候,线程获得了Widget 实例对象的锁,LoggingWidget .doSomething()中又调用了Widget .doSomething(),因为Widget 实例对象的锁已经被占用,那么这时候会不会发生死锁呢,有可能编程了永久等待的情况。
为了解决上面的问题,引入可重入的概念:内部锁是可重进入的,线程在试图获得它自己占有的锁时,请求会成功。
重进入是基于每线程,而不是每调用的。