返回首页

Java 并发基础

Java 并发基础

Java并发编程基础

在Java日常开发中,并发是开发者无法回避的内容却也是十分重要的内容,日常的开发中已经存在相当多的实际业务问题,想写好业务逻辑并不简单,想写好并发逻辑就更为不易。因此,开发人员不仅要在语言层面熟悉编程接口,更应该去了解更加深入的概念与知识。

并发(Concurrency)的历史

在谈论具体的语言实现之前,我们先来看看并发的简单历史。

说到并发在计算方面的应用,可以追溯到19世纪与20世纪早期的铁路与电报领域。早期对于并发的理解是基于如何将铁路并行化,如何能通过电报线路同时发送多个信号等方面的实际应用。而到了计算机发展的初期,操作系统还未出现,更谈不上一个处理器同时运行多个程序。一台计算机的资源也只能提供给一个程序使用。以现在的眼光来看,这样的做法无疑是令人迷惑的。当我打开我的计算机时,我不太可能希望屏幕上显示一个文件下载进度条而我自己对此却无能为力,无法切换到其他程序,只能等待。然而在古早的年代里这就是事实。随着计算机与操作系统的发展,一台计算机上同时执行多个程序成为可能。

基本概念

什么是进程(Process)

对于不希望计算机屏幕上同一时刻只能显示等待进度条的人来说,进程貌似是他们的大救星。进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。简单而言,每个计算机中执行的程序就是一个进程,我们可以简单得认为当我启动了一个程序之后便开启了一个进程。相较于不同的操作系统,进程的概念可能会存在差异,在早期类UNIX系统中,进程是程序执行的实体。而在如今的大多数操作系统之中,进程则作为线程的容器。终于能在敲代码的同时去听五月天的专辑了!——来自倔强的五月天死忠粉的欢呼。

什么是线程(Thread)

从进程作为操作系统的基本单位之中走出来,我们还能不能将工作再细分一些?每个进程只能执行一个任务吗?当然不是!在压榨性能方面,我们可是老手了。进程还是不能满足我们的话,那继续往下分呗。线程缓缓向我们走了过来。

作为计算机操作系统的进行运算的最小调度单位,大部分情况下它被包含在进程之中,作为进程的实际运行单位。在一个进程之中可以并发执行多个线程,每个线程的任务可以不同。照这么看来,好像我使用多进程和多线程来在工作同时听专辑的性质差不多,都是同一时间段在做着不止一件事情。因此,线程有时也被成为轻量级的进程(Lightweight Process),并且在大多数当代计算机中,线程已经成为时序调度的基本单元。(从另一个方面来看,其实是在进程中又有了多个pc的指向,即一个进程有了多个执行点)。

生活中的并发

生活之中处处是并发,而作为人类的我们发挥着我们的聪明才智以至于不是很在意这些概念性的东西而更多倾向于一种习惯或者直觉。就拿洗衣服的例子来说,当下有一大把衣服等着我们洗,洗衣机是我们的唯一选择。可是我们不能穿越时间来到大约一小时之后的洗衣机之前拿衣服,虽然这挺让人沮丧的但到也是事实。那我们该怎么办呢?自然就是等待洗衣机做完他的工作呗。那在这段时间之中,我们能不能干点其他事情而不是干等着呢?(降智的提问)当然可以捏!傻等的话,是不是太傻了。我上次这么做还是上次呢。所以从中我们看出,对于并发这个事情,我们是稀松平常得以至于生活中都不会去在意这么个多任务念头产生的瞬间。

并发带来的好处

并发带给我们的好处也是显而易见的,我们生活的效率明显上升了!就上文而言,在洗衣服的间隙,我不必再等在洗衣机之前,我可以去写一写其他计算机语言的HelloWorld, 可以单纯闭上眼睛听听五月天,逛一逛京东选一选鼠标(最近倒是想买个鼠标)。以及当下的我正在一边下载游戏一边写这篇文章。现实中的例子举不胜举,所以了解为啥我们能摸鱼摸得很自然了吧。开个玩笑,生活中的并发处处可见,那计算机程序中呢?或者再精确点,就本文而言,在Java中的并发,线程又是怎的模样呢?

Java中的并发基础语法

学习伊始

好说歹说,终于把读者带到了这里,希望你们能保持那份好奇与热切的心和我一起来探究Java中的并发基础知识。在具体开始之前还是想对读者说一说,每个人对于同一个知识点的认识不同,会存在不同的理解,正如一千个读者,便有一千个哈姆雷特。对于知识点的理解上没有必要吹毛求疵以至于要辩个高下,对于知识点有自身独到的理解,能将这些运用在实际之中,对于其他观点保持一种谦逊但却不敌视的态度,不拘泥于一家之言这才是我认为在从他方学习知识时候的最佳状态。

如何创建线程

我们能在现实生活中理所应当地应用多线程,然而在代码中顺序的模型却是更自然的,对于我们写代码的同学来说,我希望在程序输出一段提示语句之后再使用Scanner类来接收命令行的输入,这很符合我们的预期,然而多线程的世界就不是那么单纯了。或许你的想法在编译器或者CPU看来十分不合逻辑,致使程序运行的结果并不是我们最开始的预期(在不了解线程运行的情况下)。那我们具体该如何使用线程呢?不急,万事万物皆有其本,我们先从创建线程开始。本节将分别从以下三个点出发,和读者一起探讨,我们该怎么创建线程。

  • 创建线程的方法种类
  • 如何选择创建线程的方法
  • 对于线程创建方法的不全面理解

创建线程的方法有几种?

对于这个问题我先按下不表,不如看到这的同学暂停一下 ,如果对Java线程有一定了解应该会有自己的答案,而对于并不是很了解的同学可以去百度或者bing抑或是Google等搜索引擎查一查。搜索的结果可能会千奇百怪,很快啊!我就搜了一下,没防住,被蹭了一下,下面是我使用某度搜索的结果

啊这。。。所以我该信谁的,当然有同学肯定会说,搜什么搜,直接去看官方文档啊。没错,学习一门技术最可靠的方式就是查阅官方文档,那我们就去康康。下面是Oracle的官方文档的描述。(two ways是我特意加粗)

There are two ways to create a new thread of execution. One is to declare a class to be a subclass of Thread.

The other way to create a thread is to declare a class that implements the Runnable interface.

破案了!官方大大向我们说明了,创建线程就是两种方法捏。但是为什么其他人会有如此多的对于线程创建的方式的理解呢?再次我先按下不表之后再探讨,有兴趣的同学可以先去看看刚刚搜索到的结果,看看他们的说法,再继续往下看也不迟。我们先就官方给出的两种方法来开启我们的Java线程之旅。

创建线程(官方)

继承Thread类

// 忽略包等相关  
public class ExtendsStyle extends Thread{  
    @Override  
    public void run() {  
        System.out.println("In Thread");  
    }  
    public static void main(String[] args) {  
    Thread subThread = new ExtendsStyle();  
    subThread.start();  
	}  
}
// 运行结果  
In Thread  

简单!我们自己的ExtendsStyle继承了Thread类,重写了Thread中的run方法,实现了在main线程中启动了一个子线程,但是这么看好像发现多线程和单线程貌似也没有什么区别,emmm确实令人难受,都看到这了,你就和我说这些?好吧,那我们再进一步,就以前文的洗衣服为例我们来写个例子吧,来看代码(打起精神,下面的字符可能会让人想睡觉o :) )

public class ExtendsStyle {  
    // 创建内部类  
static class Wash extends Thread {  
    @Override  
    public void run() {  
        System.out.println("洗衣机:诶 嘿嘿嘿嘿!衣服来喽,开始洗衣服喽");  
        try {  
            // 假设洗衣服的时间为5s  
            Thread.sleep(5000); // 参数单位为毫秒  
        } catch (InterruptedException e) {  
            e.printStackTrace();  
        }  
        System.out.println("洗衣机子线程执行完毕 洗衣机: 衣服洗完喽! 洗衣服真是一件美事");  
    }  
}  
  
static class Listen extends Thread {  
    @Override  
    public void run() {  
        System.out.println("我:《突然好想你》走起!");  
        try {  
            // 假设听歌时间为2s  
            Thread.sleep(2000);  
        } catch (InterruptedException e) {  
            e.printStackTrace();  
        }  
        System.out.println("歌曲子线程执行毕,歌曲播完了");  
    }  
}  
    public static void main(String[] args) throws InterruptedException {  
    // 创建听歌和洗衣服类的对象  
   	Listen listen = new Listen();  
   	Wash wash = new Wash();  
   	// 启动两个线程  
    long startTime = System.currentTimeMillis();  
    wash.start();  
    listen.start();  
    wash.join();   
    // join的用法在此处先不展开,先忽略,以  
	// 我们正常的单线程的思维去看待这个程序,一共用时应该是7s  
    listen.join();  
    long endTime = System.currentTimeMillis();  
    System.out.println("一共用时: " + (endTime - startTime) + " ms");  
    System.out.println("主线程执行完毕");  
	}  
}

// 执行结果  
洗衣机:诶 嘿嘿嘿嘿!衣服来喽,开始洗衣服喽  
我:《突然好想你》走起!  
歌曲子线程执行完毕,歌曲播完了  
洗衣机子线程执行完毕 洗衣机: 衣服洗完喽! 洗衣服真是一件美事  
一共用时: 5000 ms  
主线程执行完毕  

至此多线程也就体现出来了。我们在洗衣服的过程中并没有等待洗衣服这件事情完成再去听歌,而是开启另一个线程去听歌,最终的用时也只用5s。当然还有一点要注意,这个运行结果只是其中一种可能性,至于到底是先洗衣服还是先听歌则要看两个线程谁先获得CPU的运行资源,start方法只是_告知而非执行_。

接下来康康官方说的第二种方法

实现Runnable接口

在代码具体实现上其实二者是一样的,只是在语法上存在差异,因此为了防止大家睡着,以下就只展示差异部分了

public class RunnableStyle {  
    // ...  
    static class Wash implements Runnable{  
    	@Override  
        public void run(){  
            // ...  
        }  
	}  
static class Listen implements Runnable{  
	@Override  
    public void run(){  
        // ...  
    }  
}  
// ...  
public static void main(String[] args) {  
    Thread listen = new Thread(new Listen());  
    Thread wash = new Thread(new Wash());  
    // ... 下面代码与之前一样  
	}  
}

到此为止,我们知道了Oracle官方说的创建线程的方式,也使用了洗衣服的例子来证明多线程程序确实和单线程程序不一样,现在的你有没有想拿多线程做点什么新奇的程序的冲动呢?

这里说说我第一次知道这件事的想法吧,在我了解之后我想到去写一个联网版本的五子棋游戏,使用一台服务器作为数据的中转站,收发来自不同客户端的程序,而客户端,也就是实际玩家电脑上运行的五子棋程序会在主线程之中再开一个子线程用于不断监听服务器发送过来的数据以及向服务器发送数据。这只是我当时的个人想法,想必同学们会用更多的奇思妙想。

线程创建源码解析

emmm, 同学们到这有没有觉得哪里似曾相识的感觉,没有也没关系,我来告诉大家,大家有没有发现不论是继承Thread类还是实现Runnable接口,我们的代码里面都会写一个run方法,都会使用Thread类,也就是都会有类似以下代码

@Override  
public void run() {  

}  
new Thread().start();  
// 或者  
new Thread(new ClassImplementedRunnable()).start();  

我们好像冥冥之中在按照Java给我们设定的某个具体规则去创建线程不论是Thread还是Runnable,好像都是如此。

class A extends Thread {  
    @Override  
    public void run() {  
        // ...  
    }  
}  
new A().start();  

class B implements Runnable {  
    @Override  
    public void run() {  
        // ...  
    }  
}  
new Thread(new B()).start(); 

如果此时你有这样的想法,那说明你真的很细心o。所以我们就来康康这个run到底是怎么在两种方式中运行起来的,话说这两个run是同一个吗?

既然都是和Thread有关,那我们就去Thread类里面看看到底是啥情况。

我们直接在IDEA中ctrl+鼠标左键点击Thread类进入源码查看,以下为Thread类的部分源码

public class Thread implements Runnable {  
    /* Make sure registerNatives is the first thing <clinit> does. */  
    private static native void registerNatives();  
    static {  
        registerNatives();  
    }  
    private volatile String name;  
	private int priority;  
  
	/* Whether or not the thread is a daemon thread. */  
	private boolean daemon = false;  
  
	/* Interrupt state of the thread - read/written directly by JVM */  
	private volatile boolean interrupted;  
  
	/* Fields reserved for exclusive use by the JVM */  
	private boolean stillborn = false;  
	private long eetop;  
  
	/* What will be run. */  
	private Runnable target;  
	// ...  
}

大家有没有发现什么新奇的地方,诶?Thread类也实现了Runnable接口,那必须也得实现Runnable接口中的run方法吧,那Thread类的run方法都干了什么呢?还有为啥Thread类实现了Runnable接口之后还有私有的Runnable类型的成员变量呢?也就是下面这行代码

/* What will be run. */  
private Runnable target;  
// What will be run 很值得体味o  

先抛开这些问题,不然大脑要爆炸了。我们先了冷静分析一下,从前文来看,我们创建线程在调用start方法之前,我们都都调用了Thread类的构造函数,一个是无参构造函数,一个是接受Runnable接口类型的构造函数,下面的代码再来回顾一下

// 创建听歌和洗衣服类的对象  
/*  
* 无参构造调用,因为Listen类和Wash类都继承了Thread类,在new子类时,调用了  
* 子类的无参构造函数,而在调用子类的构造函数的第一句必然调用父类的构造函数,  
* 即使没有明确使用super();也会隐式调用父类无参构造函数  
**/  
Listen listen = new Listen();  
Wash wash = new Wash();  
// 也就是说这里调用了Thread类的无参构造函数:public Thread(){/*...*/}  
// 继续看实现Runnable的情况  
Thread thread = new Thread(new B());  
/*  
* 就前文而言B是实现了Runnable接口的,而此处则调用了Thread的有参构造函数  
* public Thread(Runnable target) {/* ... */}  
*/  

那我们就从构造函数看起,以下为Thread源码

/*  
*Subclasses of Thread should override this method.  
*/  
@Override  
public void run() {  
    if (target != null) {  
        target.run();  
    }  
}  

诶是不是明白了什么,也就是说,当我们使用自己写的类来继承Thread类时重写run方法,Thread类内的run方法会被我们写的所覆盖,而当我们使用Runnable接口时,我们向Thread对象内传递了一个名字为target的Runnable对象,这里的run方法再接着调用target内的run方法,也就是我们自己写的run方法。

看了这么多,实际上都是在执行run方法。希望读者对创建线程有了自己的理解。对于创建线程到底有几种方法这件事,我认为应该在不同层次去考虑,当我们看到源码之后,其实发现,线程实际都是由Thread创建,我们只是通过两种不同方式来实现run方法, 本质上是相同的。

该选择哪种方式创建线程?

对于上面这个问题我们也可以换个问法,或许哪天有人会问你,继承Thread类和实现Runnable接口来创建线程,那种方法更好呢?按照“本文作者”(也就是我自己)的看法是,更倾向于使用Runnable接口的形式,为啥捏?

首先我们将Runnable接口传入Thread对象,当再次需要使用线程来执行同样的任务时,我们可以复用Runnable中run的逻辑和原来已经创建好的线程(前提是你没把他销毁的话),而不是再去创建一个新的线程,增加销毁与创建线程所带来的的负担,其次既然我们已经了解到,无论是传入Runnable接口的方式还是继承Thread的方式,在本质上都是Thread来创建线程,并调用run方法,那传入Runnable的方式很好地将我们要具体执行的逻辑(run方法内的逻辑)与创建线程的逻辑分离开来(有点类似于单一职责原则),最后就是在语法层面上,Java不像C++存在多继承,Java只存在单继承,如果我们的类继承了Thread类,那将无法继承其他类,反倒是限制了我们的使用。

对于线程创建不全面的理解

讲了一大堆,总算是搞明白了官方给我们的两种创建线程的方法了,但是前面还埋着个坑呢,为什么会有那么多人对创建线程有几种方法这件事情有这么多的理解?

在我查看别人博客的同时我也翻阅了许多书籍,有些书籍上也存在着不至于两种的说法,比如有的书说有三种之类的。首先我们要明确的一件事情就是官方给我的文档应该是不会有问题的,也就是说存在两种创建线程的方式,而网上是许多博客将线程池,使lambda表达式等也算如了创建线程的方式中去了。而这些理论之中我想带大家来康康的就是创建线程的方式有三种的这个说法。

官方说两种,你来三种?有点飘了属于是。等等,**在遇见别人观点与自己观点不同时,首先不应该打压嘲讽,以此来显示自己的正确性与不可撼动,我们不如认真思考一下为什么他们会有这种想法。**不如我们就来看看分析一下三种的情况观点,下面是我在一本书上翻到的内容

这本书具体是啥名字我就不说了,有兴趣的同学可以来问我,在他说的三种方法之中,前两种是和官方一样的,也就是继承Thread和实现Runnable接口,而他的第三种是使用Callable接口与Future类来实现。

那我们就来具体康康他的代码,下面是我从书上摘抄下来的代码

import java.util.concurrent.Callable;  
import java.util.concurrent.ExecutionException;  
import java.util.concurrent.FutureTask;  

class ThreadC implements Callable<String> {
    @Override  
public String call() throws Exception{  
    try{  
        Thread.sleep(500L);  
    }catch (InterruptedException e){  
        e.printStackTrace();  
    }  
    System.out.println("线程B");  
    return "thread B";  
	}  
}  

public class TheThirdWay {  
    public static void main(String[] args){  
        ThreadC threadc = new ThreadC();  
        FutureTask<String> future = new FutureTask<>(threadc);  
        new Thread(future).start();  
        System.out.println("这是主线程");  
  
    try {  
        System.out.println("返回结果为:" + future.get());  
    } catch (InterruptedException e) {  
        e.printStackTrace();  
    } catch (ExecutionException e) {  
        e.printStackTrace();  
    }  
    System.out.println("主线程结束");  
	}  
}

欸?是不是也有点熟悉(这不是第一次出现了)。没错,这里的第三种创建方法也是用了Thread类,也是用了start方法。emmm,会不会和上面两种情况一样呢?只是同一种创建方式的不同实现呢?我们就从源码部分开始看起吧。

首先既然是将FutureTask对象传给了Thread类的构造函数,那我们先去FutureTask里面看看

public class FutureTask<V\> implements RunnableFuture<V\> {  
    // ...  
    private Callable<V> callable;  
	// ...  
    /*  
    * volatile 关键字的用法就在这就不和大家说了  
    * 后面的会向大家再具体聊聊  
    */   
    private volatile Thread runner;  
    // ...  
}  

ei, 我们发现FutureTask实现了RunnableFuture接口, 而且在FutureTask类里面存在Callable类型与Thread类型的成员变量,暂且我们先不谈论这两个变量,我们先继续往下看看RunnableTask里面存在什么,下面为RunnableTask的部分源码

public interface RunnableFuture<V\> extends Runnable, Future<V\> {  
    void run();  
}  

是不是看到我们熟悉的老朋友了,没错,其实看名字我们也可以才出来,RunnableFuture接口继承了Runnable与Future接口(这里带一句,Java之中实现的多继承是不被允许的,但是组件时可以多继承的)。

所以呢,在我们传入FutureTask对象给Thread类的构造函数的时候,实际上也是在传递一个Runnable的接口。那Callable又是什么情况呢,这就说到我们之前看到的FutureTask的成员变量Callable了。总的来说,我们将实现Callable接口的类的对象传入FutureTask,FutureTask将它赋值给成员变量callable。那我们写的call()在哪里执行呢?别急,我们继续看源代码

public void run() {  
    if (state != NEW ||  
        !RUNNER.compareAndSet(this, null, Thread.currentThread()))  
        return;  
    try {  
        Callable<V> c = callable;  
        if (c != null && state == NEW) {  
            V result;  
            boolean ran;  
            try {  
                result = c.call();  
                ran = true;  
            } catch (Throwable ex) {  
                result = null;  
                ran = false;  
                setException(ex);  
            }  
            if (ran)  
                set(result);  
        }  
    } finally {  
        // runner must be non-null until state is settled to  
        // prevent concurrent calls to run()  
        runner = null;  
        // state must be re-read after nulling runner to prevent  
        // leaked interrupts  
        int s = state;  
        if (s >= INTERRUPTING)  
            handlePossibleCancellationInterrupt(s);  
    }  
}  

看到这里是不是有点恍然大悟了呢,没有也没关系,听我来和大伙唠一唠,因为FutureTask实现了Runnable接口所以必须实现run方法,而在FutureTask的run方法中又调用了我们传入的callable的call方法(这个方法的具体实现是我们写的o),破案了!我们以为的Callable,无非也是调用run方法,在run方法内再调用call方法。最终的结果被set方法设置。

哎,bulabula一大堆,这些接口呀,类呀,实现来实现去,继承来继承去的,人都麻了。确实,如果第一次看,确实挺难受的,那为了方便大家看各种类之间关系,我就画个图给大家整体上的感觉吧,什么叫做一眼万年,这就是,哈哈哈。

到这里总算是稍稍讲清楚了关于创建线程的所谓“第三种方式“,本质上还是离不开run方法。为此我们也看到有些书不能尽信,对于我们自己没有去实践的知识,我认为应该保持一种敢于怀疑的态度。所以我们现在可以来回答最开始的几个问题了(毕竟自己挖的坑还是得自己来填),首先明确一点,关于创建线程的方式,经典答案也是官方答案是两种,但是我们可以再从本质上理解,实际上是一种方式的两种体现,即两种不同使用run方法的体现,本质上还是Thread类来创建线程。其次,为什么其他人会有如此多的对于线程创建方式的结论?因为他们对于线程创建方式的理解停留在代码的形式上,如果lambda表达式来简化Runnable接口的方式也算创建线程的方式的话,那创建线程的方式可就数不胜数了。

如何启动线程

本来不打算瞎扯这么多的呢,但是总想着既然来都来了,我尽量把我遇见的风景都带你们康一康,希望对同学们有帮助吧。

这一个小结我们会比较轻松捏,既然我们已经创建了线程,启动起来那还不是一个方法的事情,对吧。我们在之前的线程创建中已经使用过了,ei, 就是start方法。

//...  
Thread thread = new Thread();  
thread.start();  
//...  

真的是如此简单吗?真就这么简单!别想了,没蒙你。

但是呢,我还是得向同学们强调前文已经说过的一个问题,就是我们在调用start()方法之后,线程未必会按照我们的想法立即执行,我们只是在通知JVM,我们想要运行一个线程,而具体这个线程到底什么时候运行,还是得看CPU的心情,说不定他心情不好都不给你执行,哈哈哈哈(开个玩笑)。这个概率还是很小的,就上个我们洗衣机的例子,同学们可以在自己的电脑上运行一下,每次的结果不一定会一样,并不一定每次都会先洗衣服,也可能会先听歌。

start方法都干了啥

我们先来康康start的Java源码….(完蛋,又是无聊枯燥的猿妈环节)

public synchronized void start() {  
        /**  
         * This method is not invoked for the main method thread or "system"  
         * group threads created/set up by the VM. Any new functionality added  
         * to this method in the future may have to also be added to the VM.  
         *  
         * A zero status value corresponds to state "NEW".  
         */  
        if (threadStatus != 0)  
            throw new IllegalThreadStateException();  
    /* Notify the group that this thread is about to be started  
     * so that it can be added to the group's list of threads  
     * and the group's unstarted count can be decremented. */  
    group.add(this);  
  
    boolean started = false;  
    try {  
        start0();  
        started = true;  
    } finally {  
        try {  
            if (!started) {  
                group.threadStartFailed(this);  
            }  
        } catch (Throwable ignore) {  
            /* do nothing. If start0 threw a Throwable then  
              it will be passed up the call stack */  
        }  
    }  
}  
  
private native void start0();  

在方法标签上我们看到,start方法使用了synchronized关键字修饰,这个关键字的内容我们之后的篇幅再来聊,我们先看start里面都干了啥,首先是检查一个叫做threadStatus的变量是否为0, 也就是下面的几行代码

if (threadStatus != 0)  
      	throw new IllegalThreadStateException();  

其实官方的注释也说的很清楚了,在线程状态为0时,也就是“NEW”的状态下才能调用start方法,不然它会抛出一个非法线程状态异常。再往下,我们最终会发现,线程的start终止在start0方法上,而start0方法为native方法,也就是说这个方法使用的是C代码实现的。emmm, 算了这里就不带大家看C代码了,我自己都不想康。一点也不好康。总之,先让同学们有个概念就行。

为什么要使用run方法?他很特殊吗?

如标题这个问题也是我一开始学习时的问题,run很特殊吗?其实我们去看看Runnable这个接口就知道了。

// 以下为Runnable接口源码  
@FunctionalInterface  
public interface Runnable {  
    /**  
     * When an object implementing interface {@code Runnable} is used  
     * to create a thread, starting the thread causes the object's  
     * {@code run} method to be called in that separately executing  
     * thread.  
     * <p>  
     * The general contract of the method {@code run} is that it may  
     * take any action whatsoever.  
     *  
     * @see     java.lang.Thread#run()  
     */  
    public abstract void run();  
}  

就辣么几句,可以看出,他只是一个普通的接口罢了,也就是说run不是一定在创建线程时我们才可以用(注意o,我说的是可以,不是就随便用o,毕竟官方注释在那,还是悠着点比较好。)

如何停止线程

创建线程,启动线程,那我们怎么停止线程呢?这些问题就像人生三大提问一样难回答 :-) ,我还是希望同学们一步一步来,我们慢慢来探讨这第三个问题。

停止线程是”一厢情愿“吗?

说道一厢情愿这个成语不知道同学们对这个有啥想法或者回忆呢?对于我而言好像是没啥感觉,唯独的一厢情愿可能是在歌颂别人这件事情上吧。借吴青峰的歌词,“我想我很适合当一个歌颂者“。还是不跟大家瞎扯了,赶紧入正题,不然好不容易看到这里的同学都要跑了。

我们先来尝试想想下面这个场景:假如你现在正在王者峡谷遨游,现在突然一个QQ或者微信电话打进来,室友让你帮他拿个东西之类的要停止当前游戏才能做的操作,你会怎么做呢?下面是你的两个选择

  1. 我不玩了!hxd我来了!
  2. ”猥琐发育,别浪“

可能很多同学说,诶?我选第三个,我一边打游戏一边送东西。现实中当然可以啦,但是在这个场景下,你一个时间段只能做一件事情。但不管怎做,决定权在你,好兄弟只是通知你他现在需要你停止当前正在做的事情(which is 王者峡谷之旅)去帮他做事情。至于你接下来的行动完全就是你自己的决定了。其实中断线程也是如此,我们需要与被停止放的配合,而非我们的“一厢情愿”。

首先我们明确一件事情,目前的Java中已经不再使用stop方法来终止线程了,取而代之的是判断线程是否被中断这个信号来将中断的权利交给线程本身。我们先来看一个简单的中断线程的例子

// Thread中存stop方法,但是已经被弃用  
public class SimpleStopThread implements Runnable{  
    @Override  
    public void run() {  
        while (!Thread.currentThread().isInterrupted()) {  
            System.out.println("程序正在运行");  
        }  
        System.out.println("!!!!程序运行结束!!!!");  
    }  
    public static void main(String[] args) throws InterruptedException {  
    Thread thread = new Thread(new SimpleStopThread());  
    thread.start();  
    Thread.sleep(2000);  
    thread.interrupt();  
	}  
}
// 部分运行结果  
...  
程序正在运行  
程序正在运行  
程序正在运行  
程序正在运行  
程序正在运行  
程序正在运行  
程序正在运行  
程序正在运行  
程序正在运行  
!!!!程序运行结束!!!!  

在代码中我们看到,while循环是由一个当前线程的isInterrupted方法的返回值来控制的,这个方法返回的布尔值表示当前线程是否受到的中断信号,也正是在主函数中我们调用了该线程的interrupt方法,在while循环中的isInterrupted返回值为true,去反之后便跳出了整个循环,输出一行语句之后,线程运行结束。其实我们从中也能看到,如果我们不使用isInterrupted方法的返回值来控制循环的话也是可以的,举个例子,也就是下面这样的情况

while (true) {  
    System.out.println("程序正在运行");  
}  
// System.out.println("!!!!程序运行结束!!!!"); 这句就执行不到了  
// 一个死循环  

自然线程也就不会停止了。即使我们在main函数中使了对应线程的interrupt方法。

停止线程是“双向奔赴”呀

当然我们可以调用stop方法强制性地停止线程

// stop方法已经弃用  
Thread.stop();  

// 顺便带大家康康源码是怎么说的  
@Deprecated(since="1.2")  
    public final void stop() {  
        @SuppressWarnings("removal")  
        SecurityManager security = System.getSecurityManager();  
        if (security != null) {  
            checkAccess();  
            if (this != Thread.currentThread()) {  
                security.checkPermission(SecurityConstants.STOP\_THREAD\_PERMISSION);  
            }  
        }  
        // A zero status value corresponds to "NEW", it can't change to  
        // not-NEW because we hold the lock.  
        if (threadStatus != 0) {  
            resume(); // Wake up thread if it was suspended; no-op otherwise  
        } 
            // The VM can handle all thread states  
    stop0(new ThreadDeath());  
}  

都不用看stop方法具体干了啥,光看方法头上那个注解@Deprecated(since="1.2")就知道这玩意不能用,至少不建议使用。所以我们在中断线程时还是应该使用interrupt方法来传递信号,在被通知的线程中来处理这个信号或者因此而产生的异常(关于异常方面的线程中断,我们后面再聊捏)。

错误的停止方式

当我们在网上冲浪时,可能会有同学发现,停止线程可能不止一种方法,或许会有人和你说可以使用volatile关键字增加一个标记位来以此判断中断线程的时机。当然啦,我们先不管啥是volatile关键字,我们就只先关心标记位这件事。说不如做,直接上代码,大家应该会更明白我在讲什么

public class ThatWrongWay implements Runnable {  
    private volatile boolean canceled = false;  
    @Override  
	public void run() {  
    while (!canceled) {  
        System.out.println("我没被中断捏!!!!!");  
    }  
    System.out.println("我被中断啦AAAAAAA");  
	}  
  
	public static void main(String\[\] args) throws InterruptedException {  
    ThatWrongWay r = new ThatWrongWay();  
    Thread thread = new Thread(r);  
    thread.start();  
    Thread.sleep(1000);  
    r.canceled = true;  
	}  
}
// 下面是大约1s之后的部分运行结果  
...  
我没被中断捏!!!!!  
我没被中断捏!!!!!  
我没被中断捏!!!!!  
我没被中断捏!!!!!  
我没被中断捏!!!!!  
我没被中断捏!!!!!  
我没被中断捏!!!!!  
我没被中断捏!!!!!  
我被中断啦AAAAAAA   

看起来好像很正常呀,在主线程里面设置了canceled变量为true, 子线程也因此中断运行了,这不是很好吗?是的,但是只能说就目前而言是的。这样的线程终止操作是在Java代码的层面,而我们启动线程的过程,如果继续深入下去会找到native方法start0,也就是C的实现,那这样就会隐约存在一个问题,我们至少目前没法在Java中干预C代码的执行(如果硬要操作底层的话,可以康康Unsafe,以及Java9之后的VarHandle)。假如我们在Java代码的层面修改canceled变量而while循环走不到判断的语句呢?别说这个几率小o,可能真的会有”奇事“。我们直接康康下面的代码(代码可能会有点长,希望大家不要睡着,我尽量简化代码以及尽量在没有提及的地方增加注释)

public class WrongVolatileSample {  
    // 创建内部类  
static class A implements Runnable{  
  
    private volatile boolean canceled = false;  
  
    private ArrayBlockingQueue<Integer> s;  
  
    public A(ArrayBlockingQueue<Integer> s) {  
        this.s = s;  
    }  
  
    @Override  
    public void run() {  
        while (!canceled) {  
            try {  
                /**  
                 * 获取队列中的值并输出  
                 */  
                System.out.println(s.take());  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
        }  
        System.out.println("恭喜你,循环结束啦,看到这个是不是说明canceled的修改生效了?");  
    }  
}  
  
public static void main(String\[\] args) throws InterruptedException {  
    /**  
     * 创建阻塞队列,先不管这个具体怎么用,我们只用知道,如果没人往队列里面放东西的话  
     * 直接使用take方法来获取队列里面的成员的话,程序会卡住...暂且先这么说  
     */  
    ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<>(1);  
    /**  
     * 创建线程,传入A类的对象,并将队列传入A的对象  
     */  
    A a = new A(queue);  
    Thread thread = new Thread(a);  
    /**  
     * 按照我们上一个程序的逻辑  
     * 启动线程,在启动之后修改canceled的值,  
     * 希望能如我们所愿结束线程的运行吧  
     */  
    thread.start();  
    Thread.sleep(1000);  
    a.canceled = true;  
    System.out.println("main:我已经修改了a的canceled的值了,不信你看" + a.canceled);  
	}  
}
// 下面是运行结果  
main:我已经修改了a的canceled的值了,不信你看true  
// 然后就没有下文了...  

诶,奇怪按道理我修改了canceled, 子线程的循环应该退出了捏,应该要运行下面这句

System.out.println("恭喜你,循环结束啦,看到这个是不是说明canceled的修改生效了?");  

但是结果并没有,而是程序始终卡在了那里。等下到底有没有卡住捏。我只能说中国人不骗中国人呢,大家可以自己去运行一下这个程序。

我们来分析一下到底发生了肾磨石(没防住)

首先其他代码与先前的一个成功的例子没有什么区别,都是在while循环上判断canceled的值,进而终止循环的进行,然而这个例子之中,在循环内部的代码为

try {  
       /**  
       * 获取队列中的值并输出  
       */  
       System.out.println(s.take());  
    } catch (InterruptedException e) {  
      	break;  
      }  
   }  
System.out.println("恭喜你,循环结束啦,看到这个是不是说明canceled的修改生效了?");  

主要看s.take()这句话,结合前面的说明,因为s内不存在任何元素,此时take函数会使子线程卡住(我就不说是阻塞了捏,怕同学们觉得难接受)因此while循环始终卡在这里,即使在canceled的值已经改变了,但是while循环无法走到判断语句也就是 while(!canceled)这里,也就无法退出循环结束子线程了。

那同学可能要问了,如果遇到这种情况,isInterrupted方法能使线程结束吗?答案是肯定的,我在这干说你们是不会信的,我也不建议你们相信,更建议你们去自己试一试就比如吧while循环的判断条件改为

while (!Thread.currentThread().isInterrupted()) {  
    // ...  
}  

// 在主函数中使用  
thread.interrupt();  

至于最后是啥结果,还是得看你们自己的运行捏,这里我就不细说了到了异常的阶段再来带大家回顾这个例子,不然不能在有限的时间内和大家尽量多地覆盖基础知识了。

线程的状态与生命周期

回答完线程一生的三大问题(创建,启动,中断),我们再去了解了解线程的“一生”。不然哪天别人问起你,Java中你会不会创建和中止线程呢?线程这生命周期是怎么样的呢?有啥状态呢?是不是也有喜怒哀乐?你干巴巴得瞪着他,那得多尴尬,本来想秀一波,结果梦母猪(家园梗 绷不住)了。

回忆一下,我们之前在线程启动的篇幅了里看了看start大Java部分的代码,还记得这个方法的第一句干了啥吗?害,如果不记得也没关系,下面我带大家再来康康。

public synchronized void start() {  
    // ... 此处省去的是相当多的注释  
    if (threadStatus != 0)  
        throw new IllegalThreadStateException();  

这个start方法首先检查线程的当前状态。如果不是NEW状态,start方法是会抛异常的,那到底啥是NEW状态呢?除了NEW状态还有没有其他状态呢?ei, 来了来了,还真的有,下面我们就来聊聊线程的具体生命周期和六种状态。

这幅图倒是挺详细地告诉了我们线程的“一生”中到底经历了哪些“风雨”。首先就是NEW状态,这次算是NEW是啥了捏,在线程创建出来但是还未运行start方法之前,此时的线程状态就是处于NEW状态。而且我们在看图的时候注意一点,那就是途中每种状态的箭头方向,每个箭头都是一条“单行道”,如果两个状态之间有相互的两个箭头,说明这两个状态是可以相互转换的,而如果只有一个箭头,那代表这条路“有来无回”。我们注意到在线程NEW到达下一个状态(RUNNABLE)之后,这二者之间是只有一条单行箭头的,即从NEW状态转变为RUNNABLE后再也无法返回NEW状态,这也很好地解释了为什么start方法在一开始就检查线程的状态是否为NEW。如果不是NEW状态说明这个线程以前已经调用该start方法,不能再次调用。每种状态之间转换的方法也都在图中了,待会我们再来具体讲讲其中常用的几个捏,莫慌。

还要说明的一点就是,我们最开始是说线程的六种状态,而这幅途中貌似有7种。其实解决这个问题的关键还是在RUNNABEL状态上,这个图也说明了这点不知道细心的同学有没有发现,RUNNABLE是包在RUNNING和READY外的。也就是说在Java中,RUNNABLE包括了RUNNING与READY状态。

干讲多没意思啊,Talk is cheap,show me the code. OK, Here we go.

// 我们先来演示NEW -> RUNNABLE -> TERMINATED 这条单行道  
/**  
 * NEW RUNNABLE TERMINATED 状态  
 * 即使正在运行,也是RUNNABLE状态而不是RUNNING  
 */  
public class NewRunnableTerminated implements Runnable {  
    public static void main(String\[\] args) {  
    Thread thread = new Thread(new NewRunnableTerminated());  
    System.out.println(thread.getState()); // 打印出NEW的状态  
    thread.start();  
    System.out.println(thread.getState()); // 打印出RUNNABLE状态  
  
    try {  
        Thread.sleep(1000); // 注意这里主线程等待了1000ms  
    } catch (InterruptedException e) {  
        e.printStackTrace();  
    }  
  
    // 打印出RUNNABLE状态,即使在运行中  
    // 打印出TERMINATED状态  
    System.out.println(thread.getState());  
	}  
    @Override  
	public void run() {  
        try {  
            Thread.sleep(100); // 注意这里子线程等待了100ms  
        } catch (InterruptedException e) {  
            e.printStackTrace();  
        }  
	}  
}
// 下面是运行结果  
NEW  
RUNNABLE  
TERMINATED  

这里我就不分析程序了O,注释里面已经讲的很清楚了,大家也都应该看得懂。

下面继续来康康其他三个状态

public class BlockedWaitingTimedWaiting implements Runnable{  
    public static void main(String\[\] args) {  
    BlockedWaitingTimedWaiting runnable = new BlockedWaitingTimedWaiting();  
    Thread thread1 = new Thread(runnable);  
    Thread thread2 = new Thread(runnable);  
    thread1.start();  
    thread2.start();  
    System.out.println(thread1.getState());  
    System.out.println(thread2.getState());  
  
    try {  
        Thread.sleep(1300);  
    } catch (InterruptedException e) {  
        e.printStackTrace();  
    }  
    System.out.println(thread1.getState());  
}  
  
	@Override  
	public void run() {  
    	syn();  
	}  
  
	private synchronized void syn() {  
    try {  
        Thread.sleep(1000);  
        wait();  
    } catch (InterruptedException e) {  
        e.printStackTrace();  
    }  
	}  
}
// 下面是运行结果  
TIMED\_WAITING  
BLOCKED  
WAITING  

对于这个程序我还是来解释下吧,首先我们创建了两个线程,都通过了start方法启动,而在线程的run方法内我们使用了synchronized修饰的方法(这件事我们后面再聊)指明,同一时刻,只能有一个线程来执行此方法,在第一句答应thread1的状态时,thread1调用了sleep,所以输出了TIMED_WAITING,再继续,当thread2也调用start方法时发现run中synchronized修饰的syn方法已经有人在用了,便打印了自己此时的状态BLOCKED,而thread1在sleep 1s之后,调用了wait方法,自己也就进入了WAITING状态,最后在主线程中打印了出来。如果你不明白为啥我说这些,你可以回去康康那副图,是哪些函数的调用会致使线程到达这些状态,再回来看这里,你就会明白了。对了这里再提一句,对于BLOCKED这个状态,只有在进入synchronized关键字修饰的代码块时才会出现o 。:)

synchronized关键字使用讲解

前面多次提到synchronized这个关键字,我们就来看看这玩意到底怎么玩。

首先一句话,被这个关键字修饰的代码块在同一时刻,只能有一个线程访问。没了…..吗?其实从外在表现来说确实当前只用到了这个性质捏。我们先了解大体使用,再来具体康康怎么用。

为了加深大家的印象,我分别从语法上和语义上来对synchronized这关键字做分类介绍

语法上的分类
在方法标签中的使用
// 直接来看代码  
// 对于普通方法  
private synchronized void myFunc() {  
    // ...   
}  

直接在方法可见性修饰符的后面加上synchronized关键字即可,此时当有多个线程调用这个方法时,只能有一个线程来获取这个方法的调用资格,并且在执行方法之中,其他线程无法获得执行此方法的权限,即不能在中途切换到其他线程。这么说可能会有点干巴巴的,我们直接上代码

首先是不添加synchronized关键字的方法。

public class WithoutSynchronized implements Runnable{  
    private static int count = 0;  
  
public void add() {  
    for (int i = 0; i < 10000; i++) {  
        count++;  
    }  
}  
  
@Override  
public void run() {  
    add();  
}  
  
public static void main(String\[\] args) throws InterruptedException {  
    WithoutSynchronized mySelf = new WithoutSynchronized();  
    Thread thread1 = new Thread(mySelf);  
    Thread thread2 = new Thread(mySelf);  
    thread1.start();  
    thread2.start();  
    thread1.join(); // 主线程等待子线程执行完毕,此处为main所在线程等待thread1执行完毕  
    thread2.join();  
    System.out.println("count经过两轮 1w++操作的值为: " + count);  
	}  
}

大家对结果有啥想法吗?结果会是20000吗?毕竟thread1和thread2都执行完毕了。我们就来康康我的电脑上运行的结果

// 几个运行结果  
count经过两轮 1w++操作的值为: 10181  
count经过两轮 1w++操作的值为: 16664  
count经过两轮 1w++操作的值为: 20000  
count经过两轮 1w++操作的值为: 16725  

诶?不对吧,刘大队长你在给我开玩笑吧,我没在JVM里面下毒啊。其实这些结果倒是在预料之内呢。也直接体现了多线程程序在访问共享资源的同时带来的问题。对于安全问题还是那句话,之后再谈。我们先来关注为啥会出现这种情况。首先我没要明确的就是count++这个操作并不是一个操作,即不是原子操作(这么说可能会有点不妥,毕竟即使是一个操作也未必是原子的,(原子的表示不可再分,多线程的切换是无法打断的)在这暂且先这么说)count++其实可以分为三个步骤,第一步读取count当前的值,第二步计算count自加1之后的值,第三步将计算的新值加载到count之中。ok,其实到这里,大致也就了解了。

正是因为这三步导致结果的错乱,我们来一步步分析代码,加入第一个线程执行到count自增操作的时候,刚刚读取了count的旧值,此处我们假设是1099,不管计没计算,反正没有执行第三步,将新值写入count变量,而正当第一个线程要干这件事情的时候,第二个线程抢走了run的执行权限,他发现count还是之前1099,那他就不客气了,继续“三步走战略“,结果就是线程一和线程二都计算得到新的count的值为1100,最后不论谁赋值给count,他的值也都是1100,也就是说我执行了两次count++操作,但是两次结果都是1100。这也就解释了为啥明明执行了20000此自增操作,但是结果却没有20000,反倒是少了很多。当然偶尔也会有正常的情况。

为了解决这个问题,synchronized就派上用场了。将synchronized修饰方法,那每次执行该方法时,就不会有别的方法来打断了。我们来试试。

public class WithoutSynchronized implements Runnable{  
    private static int a = 0;  
  
public synchronized void add() {  
    for (int i = 0; i < 10000; i++) {  
        a++;  
    }  
}  
  
@Override  
public void run() {  
    add();  
}  
  
public static void main(String\[\] args) throws InterruptedException {  
    WithoutSynchronized mySelf = new WithoutSynchronized();  
    Thread thread1 = new Thread(mySelf);  
    Thread thread2 = new Thread(mySelf);  
    thread1.start();  
    thread2.start();  
    thread1.join();  
    thread2.join();  
    System.out.println("a经过两轮 1w++操作的值为: " + a);  
	}  
}
// 多次的运行结果为  
a经过两轮 1w++操作的值为: 20000  
a经过两轮 1w++操作的值为: 20000  
a经过两轮 1w++操作的值为: 20000  

问题解决了,先就此打住,大家知道了synchronized关键字是拿来干什么的了,也清楚怎么用在普通方法上,那还有不普通的方法呢,也就是static修饰的方法呢?没错,这就有点特殊了。既然都说到static方法了那我们就得先了解synchronized的锁的相关操作才能继续了。对于使用synchronized修饰的方法或者代码块,在运行时都会获取monitor锁,在获取锁之后,别的线程是无法获取同一个锁对象的,因此没有获取对应锁对象的线程是无法执行synchronized代码块内部的内容的,而在synchronized修饰类中成员方法时,synchronized得到的锁与类的实例有关,也就是说一个实例的锁与另一个实例的锁是不同的,在刚刚的代码中,创建线程我使用的都是同一个实例,所以才达到了想要的效果,如果切换成两个不同的实例,结果又会不同了,大家迷糊了的话,康康下面的代码

public class WithoutSynchronized implements Runnable{ 
    private static int count = 0;  
  
public synchronized void add() {  
    for (int i = 0; i < 10000; i++) {  
        count++;  
    }  
}  
  
@Override  
public void run() {  
    add();  
}  
  
public static void main(String\[\] args) throws InterruptedException {  
    WithoutSynchronized mySelf = new WithoutSynchronized();  
    WithoutSynchronized mySelf2 = new WithoutSynchronized();  
    Thread thread1 = new Thread(mySelf);  
    Thread thread2 = new Thread(mySelf2); // 注意这里,我使用了一个  
    // 新的实例  
    thread1.start();  
    thread2.start();  
    thread1.join();  
    thread2.join();  
    System.out.println("count经过两轮 1w++操作的值为: " + count);  
	}  
}
// 下面是多次运行结果  
count经过两轮 1w++操作的值为: 15976  
count经过两轮 1w++操作的值为: 13661  
count经过两轮 1w++操作的值为: 10492  

大家可以看到,结果又不正确了,因为我使用了两个不同的实例来创建两个线程,访问同一个变量,虽然方法有synchronized修饰,但是二者并不相互排斥,因为二者获取的锁不同,互不影响。

既然修饰普通成员方法获取的锁与类的实例有关,但是我仍然想创建两个对象,却又实现之前不出错的效果,怎么办呢?这个时候就可以在你要修饰的方法上增加static关键字啦,有过Java基础的同学都知道,static代表的是类属性。下面我们直接来看代码

// 还是之前的例子,我们因为创建了两个对象synchronized关键字在我们从结果来看  
// 仿佛失效了,为了解决这个问题,但是又保留我们就是想创建了两个对象的倔强,我们  
// 使用static关键字来修饰方法  
public class WithoutSynchronized implements Runnable{  
    private static int count = 0;  
  
public synchronized static void add() { // 除了此处增加了static,其他的  
    // 代码和上面一个完全一样  
    for (int i = 0; i < 10000; i++) {  
        count++;  
    }  
}  
  
@Override  
public void run() {  
    add();  
}  
  
public static void main(String[] args) throws InterruptedException {  
    WithoutSynchronized mySelf = new WithoutSynchronized();  
    WithoutSynchronized mySelf2 = new WithoutSynchronized();  
    Thread thread1 = new Thread(mySelf);  
    Thread thread2 = new Thread(mySelf2);  
    thread1.start();  
    thread2.start();  
    thread1.join();  
    thread2.join();  
    System.out.println("count经过两轮 1w++操作的值为: " + count);  
	}  
}
// 多次运行结果为  
count经过两轮 1w++操作的值为: 20000  
count经过两轮 1w++操作的值为: 20000  
count经过两轮 1w++操作的值为: 20000  

是不是问题解决了,因为方法使用了static关键字修饰,与类相关联,相当于全局synchronized部分代码只存在唯一的一把所,不管你是哪个对象来获取,都是唯一的,这个对象获取之后,别的对象要想进入synchronized部分代码块必须要等到当前正在运行synchronized代码块的程序结束。

到这里synchronized所谓修饰方法就说完了,修饰成员方法,获取的锁与具体对象关联,而修饰static方法则与类相关联。

在代码块中的使用

现在我们来康康代码块版本的,说”版本“是因为,这个与上面的意思是一样的,只不过作用域不再是整个方法,而是自己定义的代码块,还是拿上面的代码来做演示

public class WithoutSynchronized implements Runnable { 
    private static int count = 0;  
  
public void add() {  
    synchronized (this) { // 这里将代码包金synchronized修饰的代码块之中,  
        // ()之中放的是锁对象,不需要具体什么类型,只要是个类的具体实例即可  
        for (int i = 0; i < 10000; i++) {  
            count++;  
        }  
    }  
  
}  
  
@Override  
public void run() {  
    add();  
}  
  
public static void main(String\[\] args) throws InterruptedException {  
    WithoutSynchronized mySelf = new WithoutSynchronized();  
    //        WithoutSynchronized mySelf2 = new WithoutSynchronized();  
        Thread thread1 = new Thread(mySelf);  
        Thread thread2 = new Thread(mySelf);  
        thread1.start();  
        thread2.start();  
        thread1.join();  
        thread2.join();  
        System.out.println("count经过两轮 1w++操作的值为: " + count);  
    }  
}  
// 运行结果  
count经过两轮 1w++操作的值为: 20000  

以上的代码块修饰对应之前讲的修饰成员方法,下面的例子对应上面修饰static修饰的方法

public void add() {  
        synchronized (WithoutSynchronized.class) {  
            for (int i = 0; i < 10000; i++) {  
                count++;  
            }  
        }  
}
// 此时即使创建多个实例来创建不同线程,  
// synchronized也不会因此而看上去失效,此时synchronized获取到的锁与类相关,  
// 全局唯一,也就不会发上不同实例访问同一个变量而出现错误的问题了  
语义上的分类

经过了前面几轮的代码洗礼不知道大家对synchronized关键字有没有更进一步的理解呢?还是前面的样子,只不过下面我们给上面几个例子重新分类,从类和实例的角度将两种情况进行分类

类上的使用(锁,全局唯一)
  1. 使用synchronized修饰static修饰的方法

    public synchronized static void myFunc(){  
        // ...  
    }  
  2. 使用synchronized代码块,锁对象使用类对象

    synchronized(MyClass.class){  
        // ...  
    }  
上的使用(锁,不唯一,每个对象都有自己的锁)")实例(也就是具体类对象)上的使用(锁,不唯一,每个对象都有自己的锁)
  1. 使用synchronized修饰普通成员方法

    public synchronized void myFunc(){  
        // ...  
    }  
  2. 使用synchronized代码块,锁对象使用任意类对象实例

    Object o = new Object();  
    synchronized(o){  
        // ...  
    }  
性质
不可中断性

在看完基本使用之后,继续带大家来康康synchronized的性质部分,其实这部分的内有在之前也提及过一些,就是使用synchronized修饰的代码只有在运行完之后别的线程才能获取到对应的锁再来执行对应的代码,中途是不允许被打断的。

可重入性

而另一种性质就是可重入的特性,假设有这么个情况,同一个实例,在使用synchronized修饰的方法里面又调用了synchronized修饰的方法,前面说必须等到第一个进入synchronized的方法与运行完才能继续获取锁进行下一步操作,那这样嵌套了一下行不行呢?答案是可以的,不多说,我们先来康康代码

public class Reentry implements Runnable{  
    public synchronized void reentry0() {  
    System.out.println("进入了0");  
    reentry1();  
}  
  
public synchronized void reentry1() {  
    System.out.println("进入了1");  
}  
  
/**  
 * 上面的代码中,两个函数获得的锁都为同一个锁  
 */  
  
@Override  
public void run() {  
    reentry0();  
}  
  
public static void main(String\[\] args) {  
    new Thread(new Reentry()).start();  
	}  
}
// 运行结果  
进入了0  
进入了1  

:-) 发现了没,是可以这么玩的,也就是说,在外部函数获取了锁之后,内部函数是可以再次获取同一把锁的捏。

可见性

这里谈及可见性可能会有点早捏,但是还是提一句,在synchronized修饰的代码块内的内容在线程1执行完之后(假设修改了变量a=3),对于其他线程,比如这里有个线程2,a=3的操作是可见的,也就是说线程2是能够看见a的值为3的。这里就先提这么一句,在后面的篇幅中谈到Java内存模型时再来和大家掰扯掰扯。

wait/notify/notifyAll/sleep/join方法使用与区别

终于来到了常用方法的讲解了捏,在前面的文字中我们使用了其中几个方法,但是没有具体给读者聊聊这些方法到底有啥作用。接下来就来唠一唠。

join

在之前代码的注释中已经概括过这个函数的用法,防止大家忘记,我再来说一下,就是调用这个方法的线程,会使主线程等待该线程执行完毕。就一句话这么简单。想想以前的代码。

// ...  
Thread thread = new Thread(new ClassImplementedRunnable());  
thread.start();  
System.out.println("Hello");  

如上面代码,在没有调用join方法的时候,主线程中的Hello可能先于子线程内部的代码执行完,然而有时候我们就是需要先让主线程等待子线程完成任务再继续执行。join刚好就起到了这个作用。让主线程等待子线程执行完成。如下面的代码

public class Join {  
    public static void main(String\[\] args) throws InterruptedException {  
        Thread thread1 = new Thread(new Runnable() {  
            @Override  
            public void run() {  
                try {  
                    Thread.sleep(1000); // 子线程1执行1s  
                } catch (InterruptedException e) {  
                    e.printStackTrace();  
                }  
                System.out.println(Thread.currentThread().getName() + "执行完毕");  
            }  
        });  
       Thread thread2 = new Thread(new Runnable() {  
        @Override  
        public void run() {  
            try {  
                Thread.sleep(1000);// 子线程2执行1s  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
            System.out.println(Thread.currentThread().getName() + "执行完毕");  
        }  
    });  
  
    thread1.start();  
    thread2.start();  
    System.out.println("开始等待子线程运行完毕");  
    thread1.join();  
    thread2.join();  
    System.out.println("所有子线程执行完毕");  
	}  
}    
// 运行结果  
开始等待子线程运行完毕  
Thread-0执行完毕  
Thread-1执行完毕  
所有子线程执行完毕  

可以看出join恰到好处地完成了它的任务。

不知道大家有没有注意到,在前面的说法中,我把主线程“等待”两个字加粗了。为啥?因为主线程此时确实是在等待,当主线程调用子线程的join方法之后,主线程的状态就切换到了WAITING。来康康下面一段代码

public class JoinThreadState {  
    public static void main(String\[\] args) throws InterruptedException {  
        Thread mainThread = Thread.currentThread();  
        Thread thread = new Thread(new Runnable() {  
            @Override  
            public void run() {  
                try {  
                    Thread.sleep(3000);  
                    System.out.println(mainThread.getState());  
                    System.out.println("Thread-0运行结束 ");  
                } catch (InterruptedException e) {  
                    e.printStackTrace();  
                }  
            }  
        });  
        thread.start();  
        System.out.println("等待子线程运行完毕");  
        thread.join();  
        System.out.println("子线程运行完毕");  
    }  
}  
// 运行结果  
等待子线程运行完毕  
WAITING  
Thread-0运行结束   
子线程运行完毕  

在子线程运行中我们调用了主线程的getState方法(我们在一开始通过了Thread.currentThread()获得了当前线程也就是主线程的实例,以便在子线程中获取主线程的状态)最终打印出来可以看到是WAITING。

sleep

这个方法就很好理解啦,就是字面上的意思捏,睡觉!在执行sleep方法之后,该线程便进入的阻塞状态,不再占用CPU资源。

Thread thread = new Thread(new ClassImplementedRunnable());  
thread.start();  
thread.sleep(1000); // 睡1s  

// sleep的  

在执行sleep方法后,在sleep的时间参数之内,sleep后的代码没有被执行。总的来说sleep的使用就是这么点,但是还存在一些注意的地方,先不急,等我们了解完wait方法之后再好好聊聊join和sleep。

wait

顾名思义,这个方法就是等待,使线程进入WAITING状态。在之前的线程状态展示的时候我们也已经使用了,下面我们来继续康康代码。

private synchronized void syn() {  
        try {  
            Thread.sleep(1000);  
            wait();  
        } catch (InterruptedException e) {  
            e.printStackTrace();  
        }  
    }  

wait方法可以使线程进入等待状态,但是为什么呢?为什么需要进入等待状态啊?emmm,这么说吧,加入我要去奶茶店买奶茶,老板在得知我要买的具体名字之后开始做相应的饮料,但是这个时候我不可能说你不给我我就马上走,我应该等待老板完成之后再继续我下面的步骤,也就是和奶茶之类的操作。

wait也是如此,在需要的时候将线程进入等待状态,在外在条件成熟之后再继续执行之后的步骤。(此处存在唤醒操作,之后notify/notifyAll再细讲)

wait的使用上还存在几个注意点,首先就是这个方法必须在synchronized修饰的方法或者代码块中使用。为什么呢?我们来康康源码就明白了,以下为wait的Java源码

// 具体代码部分我们先不看,先看文档注释部分  
// wait方法在何时抛出异常做了以下说法  
/* @throws IllegalMonitorStateException if the current thread is not  
     *         the owner of the object's monitor  
     * @throws InterruptedException if any thread interrupted the current thread before or  
     *         while the current thread was waiting. The <em>interrupted status</em> of the  
     *         current thread is cleared when this exception is thrown.  
     */  
public final void wait() throws InterruptedException {  
        wait(0L);  
}  

重点是第一句话

IllegalMonitorStateException if the current thread is not the owner of the object’s monitor

如果当前线程不是该对象monitor锁的所有者,则抛出该异常。说到monitor是不是有点耳熟?没错,我们在讲解synchronized关键字的时候说过,当程序进入synchronized修饰的代码块或者方法时,对象会获取monitor锁。由此也引出了wait使用的第一个要注意的地方,wait方法必须在synchronized修饰的代码块或者方法中使用。我们继续看到InterruptedException这一行注释

InterruptedException – if any thread interrupted the current thread before or while the current thread was waiting. The interrupted status of the current thread is cleared when this exception is thrown.

重点是加粗的部分o。我们之前聊到怎么中断线程的时候说道要使用isInterrupt方法来获取线程是否获得中断信号来停止线程。而该方法返回的是一个布尔值,表示当前线程是否被通知中断,是一种状态。而在上面的文档注释中我们了解到,当使用wait方法之后如果遇到中断,则会清除中断状态,并且释放锁。其实在执行wait之后,原先获取到的锁会被释放,并且也只能释放当前调用wait方法的对象锁。这么说可能会让人很迷惑,还是代码来展示比较直接

// 我们先来看看释放锁的情况  
public class Wait {  
    public static Object object = new Object();  
    static class Thread1 extends Thread {  
    @Override  
    public void run() {  
        synchronized (object) {  
            System.out.println("线程" + Thread.currentThread().getName() + "开始执行");  
            try {  
                object.wait(); // wait 会释放锁  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
            System.out.println("线程" + Thread.currentThread().getName() + "获得到了锁");  
        }  
    }  
}  
  
static class Thread2 extends Thread {  
    @Override  
    public void run() {  
        synchronized (object) {  
            object.notify();  
            System.out.println("线程" + Thread.currentThread().getName() + "调用了notify()");  
        }  
    }  
}  
  
public static void main(String\[\] args) throws InterruptedException {  
    Thread1 thread1 = new Thread1();  
    Thread2 thread2 = new Thread2();  
  
    thread1.start();  
    Thread.sleep(200);  
    thread2.start();  
  
	}  
}
// 运行结果  
线程Thread-0开始执行  
线程Thread-1调用了notify()  
线程Thread-0获得到了锁  

看着这么多代码肯定有点迷惑了,先不慌,我们先来康康main函数里面做了什么,首先创建两个线程实例,将线程1启动等待200ms保证线程1先于线程2启动,之后再调用线程2的start方法启动线程2。

那线程1和2里面分别做了啥呢?首先看线程1

static class Thread1 extends Thread {  
        @Override  
        public void run() {  
            synchronized (object) {  
                System.out.println("线程" + Thread.currentThread().getName() + "开始执行");  
                try {  
                    object.wait(); // wait 会释放锁  
                } catch (InterruptedException e) {  
                    e.printStackTrace();  
                }  
                System.out.println("线程" + Thread.currentThread().getName() + "获得到了锁");  
            }  
        }  
    }  

synchronized修饰的代码块中我们调用了wait方法,此时线程进入WAITING状态,按照原先的逻辑,进入synchronized修饰的代码块会获取对象锁,此时如果还有别的线程想来获取锁并且执行代码是不行的,但是从控制台的输出可以看出,线程2成功获取到了锁,也就是

static class Thread2 extends Thread {  
        @Override  
        public void run() {  
            synchronized (object) {  
                object.notify();  
                System.out.println("线程" + Thread.currentThread().getName() + "调用了notify()");  
            }  
        }  
    }  

如果线程2没有获取到锁,那线程2里面就无法执行了。这也证明了在线程1调用了wait方法之后,释放掉了锁,而此时线程2获取到了线程1释放的锁,由此进入线程2的synchronized修饰的代码块。

在控制台的输出可以看出,最后线程1还执行了最后一个输出,并且是在线程2执行完成之后,(这么说可能有些不妥)线程2中的notify方法就是这个输出的原因,在线程1进入WAITING状态之后,线程2使用notify方法,使原先等待的线程1被唤醒,继续wait方法之后的代码,进而有了控制台的第三行输出。值得一提的是调用wait方法陷入等待的是当前线程而不是某个线程对象我们先来看看源码的部分

// Causes the current thread to wait until it is awakened, typically by being notified or interrupted.  
// ...  
    public final void wait() throws InterruptedException {  
        wait(0L);  
    }  

Causes the current thread to wait until it is awakened, typically by being notified or interrupted

使当前线程进入等待状态,也就是说即使我在主线程内使用的是thread1.wait(),进入等待状态的也并不是thread1子线程,而应该是主线程。

join与sleep方法挖的坑

在了解wait方法之后我们再来康康join和sleep方法,我们知道在锁对象调用wait方法之后,会释放获取到的锁,而sleep就比较霸道了,在调用sleep期间线程并不会释放当前锁并且在sleep期间也会检查线程是否被中断,如果检测到中断状态为中断,则抛出异常,并且重置中断状态而在join期间被异常打断线程中断状态也会被清除具体的代码示例再谈到异常的时候再来和大家聊聊。哎呀~~还有一件事成龙。既然我们了解了wait是干啥的。那大家有没有觉得join有些地方和wait有那么点神似呢?反正都是等待嘛,对吧。那我们就来康康join方法的源码

/*  
*Waits for this thread to die.  
*/  
public final void join() throws InterruptedException {  
        join(0);  
}  
// 我们再往里面走一层  
public final synchronized void join(final long millis)  
    throws InterruptedException {  
        if (millis > 0) {  
            if (isAlive()) {  
                final long startTime = System.nanoTime();  
                long delay = millis;  
                do {  
                    wait(delay);  
                } while (isAlive() && (delay = millis -  
                        TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime)) > 0);  
            }  
        } else if (millis == 0) {  
            while (isAlive()) {  
                wait(0);  
            }  
        } else {  
            throw new IllegalArgumentException("timeout value is negative");  
        }  
    }  

有没有发现什么新奇的地方?在join方法内部使用了wait方法,而当我们只是简单调用join方法的时候wait方法会被传递0作为参数,这代表着无限的等待,但是无限的等待没人唤醒的话,线程是怎么继续执行的呢?这也是我想给大家提醒的一点,在子线程内的run方法执行完成之后,Thread都会执行类似唤醒所有的操作。因此原先陷入WAITING状态的主线程继续执行。也就实现了主线程等待子线程结束再继续运行的操作。

notify/notifyAll

在这篇文章几行文字之前我们了解了wait方法会使线程进入WAITING状态并且会释放原先获得的锁。而在上面演示的代码中也提及了notify方法,即**唤醒处于WAITING状态的线程,使其继续执行wait方法之后的代码。**而notifyAll也很好理解啦,唤醒所有正在等待这把monitor锁的线程。碍于篇幅原因,同学们可以自己去玩玩这两个方法。在之前的代码示例中也有notify的演示,大家可以参考。但是虽然没有代码示例,但是使用注意点还是得和大家说一说。

首先就是notify方法,假设有这么个情况,如果有很多线程都在等待同意一把锁释放,然后锁对象调用了notify而不是notifyAll方法,那到底是谁被notify唤醒呢?

遇事不决看源码,我们来看一看源码给了我们哪些信息下面是notify的部分源码加文档注释

/**Wakes up a single thread that is waiting on this object's monitor. If any threads are waiting on this object, one of them is *chosen to be awakened. The choice is arbitrary and occurs at the discretion of the implementation. A thread waits on an object's *monitor by calling one of the wait methods.  
*/  
 @IntrinsicCandidate  
 public final native void notify();  

从方法标签中我们也可以看出,notify是一个native方法,即使用C来实现,但是我们仔细看注释中有这么一句话

If any threads are waiting on this object, one of them is chosen to be awakened. The choice is arbitrary and occurs at the discretion of the implementation.

这句话说明了多人等的的唤醒情况,即任意挑选一个唤醒,具体怎么任意有具体实现决定。而对于notifyAll描述就比较简单了

Wakes up all threads that are waiting on this object’s monitor

唤醒所有等待当前锁的线程

说到这里大家有没有好奇一个事情?当有很多线程处在WAITING状态时,如果有人notifyAll怎么办,大家都醒了,只有一个锁,谁抢到呢?答案是只有一个人能得到锁,其他同学虽然被唤醒,但仍然处于等待锁的状态。还想深入的同学可以去找找wait方法的源码,了解了解原理,这里就不细说了。

出现异常怎么办

Java中所有异常或者错误都继承自Throwable类,撇开错误不说,就异常类而言RuntimeException又是我们程序员很难检查到的,因此这里我们来关注那些我们力所能及的异常情况。

可能在但线程的编程中,大家对异常的处理可能就是捕获住或者抛出就不管了,但是在多线程之中,异常也是有其作用的,而且不正确的处理异常,会给程序带来很大的隐患,也可能给自己带来人身安全(因为错误代码被同事胖揍),开个玩笑。下面我们就来接触接触多线程中的异常。

异常处理的两种方式,哪种处理方式更好?

在Java中语法上存在两种处理异常的方式第一种便是

try{}catch(Exception e){}  

第二种是

public void Func() throws Exception {}  

这两种异常处理方式其实也表示着对待问题的两种方式,第一种在遇到问题时自己消化,第二种则是抛出自己遇到的问题,交给调用此方法的人来处理。

我们先来看第一中处理异常的方式。

试想,假如在我们的子线程中出现了异常。我们的主线程是否会中断呢?多说无益,直接上代码

public class ExceptionInSubThread implements Runnable{  
    public static void main(String\[\] args) {  
    new Thread(new ExceptionInSubThread()).start();  
  
    for (int i = 0; i < 10000; i++) {  
        System.out.println("主线程打印输出");  
    }  
}  
  
@Override  
public void run() {  
    throw new RuntimeException();  
	} 
}

// 下面是运行结果  
Exception in thread "Thread-0" java.lang.RuntimeException  
	at cn.edu.ncu.erlys.liu.uncaughtexception.ExceptionInSubThread.run(ExceptionInSubThread.java:15)  
	at java.base/java.lang.Thread.run(Thread.java:833)  
主线程打印输出  
主线程打印输出  
主线程打印输出  
主线程打印输出  
主线程打印输出  

在子线程的run方法中我们手动抛出一个RuntimeException,在主线程中我们不断打印,可以看出即使子线程抛出异常之后,主线程仍然不会停止。当然如果我们人为增加一些处理方式的话。

那在子线程中如果遇到异常的话我们该怎么处理呢?前面说到处理异常有两种方式,我们就一个一个看试试,首先便是try…catch的形式

@Override  
    public void run() {  
        try {  
            throw new RuntimeException();  
        }catch (RuntimeException e) {  
            System.out.println("!!!!!子线程发生异常!!!!!");  
        }  
    }  
// 运行结果  
主线程打印输出  
主线程打印输出  
主线程打印输出  
主线程打印输出  
!!!!!子线程发生异常!!!!!  

将run方法中的语句用try…catch包裹住,在子线程发生异常之后在catch代码块中处理异常,此处为输出一句话。既然try…catch可以处理,那在方法上添加throws行不行呢?emmm,其实懂的都懂,run方法是Runnable接口中已经定义好的方法,实现者是无法改变方法标签的。及run方法只能写成上面的形式,而不能使用下面这种形式

public void run() throws Exception {} // 不被允许 

既然这样全部run方法里面用try…catch不就好了,那为啥还要讨论throws的形式呢?不急,先来想想这种情况,假如你的run方法很复杂,存在很多层的函数调用,而发生异常的方法在比较里面。那这个时候我们该如何处理异常呢?我们先用try…catch试试看,看看能不能解决这个问题

public class ThreadException implements Runnable {  
    @Override  
    public void run() {  
        while (true) {  
            System.out.println("这里是相当多的日志信息");  
            throwInMethod();  
        }  
    }  
    private void throwInMethod() {  
    try {  
        Thread.sleep(2000);  
    } catch (InterruptedException e) {  
        e.printStackTrace();  
    }  
}  
  
public static void main(String\[\] args) throws InterruptedException {  
    Thread thread = new Thread(new RightWayStopThreadInProd2());  
    thread.start();  
    Thread.sleep(1000);  
    thread.interrupt();  
	}  
} 
// 运行结果  
这里是相当多的日志信息  
java.lang.InterruptedException: sleep interrupted  
	at java.base/java.lang.Thread.sleep(Native Method)  
	at cn.edu.ncu.erlys.liu.stopthread.RightWayStopThreadInProd2.throwInMethod(RightWayStopThreadInProd2.java:17)  
	at cn.edu.ncu.erlys.liu.stopthread.RightWayStopThreadInProd2.run(RightWayStopThreadInProd2.java:11)  
	at java.base/java.lang.Thread.run(Thread.java:833)  
这里是相当多的日志信息  
这里是相当多的日志信息  
这里是相当多的日志信息  
这里是相当多的日志信息  
这里是相当多的日志信息  
...  

从运行结果来看好像没啥问题,在我们打断子线程后,while进入下一次循环,控制台也输出了相应的异常信息,但是正如控制台里面输出的情况,当我们的信息相当多的时候,异常信息极有可能会被我们忽略,并且作为外层方法的实现者,假如我们并不知晓我们调用的方法是否会存在异常,我们只是简单调用却无法处理该方法产生的异常是非常无奈的一件事。

因此在处理异常这件事情上,应该让顶层方法来try…catch而不是”自我消化“隐藏了异常的信息。作为下层一些的方法应该使用throws方法标签将异常向上传递。那改一改吧,我们就使用throws来解决这个问题,我们在run方法中来解决异常问题

public class ThreadException implements Runnable {  
    @Override  
    public void run() {  
        while (true) {  
            System.out.println("程序运行起来啦!!!");  
            try {  
                throwInMethod();  
            } catch (InterruptedException e) {  
                System.out.print("发生异常保存日志");  
                System.out.println("或者停止程序");  
                e.printStackTrace();  
            }  
        }  
    }  
    private void throwInMethod() throws InterruptedException {  
    Thread.sleep(2000);  
}  
  
public static void main(String\[\] args) throws InterruptedException {  
    Thread thread = new Thread(new RightWayStopThreadInProd2());  
    thread.start();  
    Thread.sleep(1000);  
    thread.interrupt();  
	}  
}

由此可以看出,在处理异常时我们并不是随意选择try或者throws来处理。我们更应该考虑方法本身的运行的可能情况以及可能存在的调用情况来决定使用哪种异常处理方式,二者并没有绝对的好坏之说

线程中断问题(异常版本)

在之前的篇幅中我向同学们介绍了中断异常的方法,即使用interrupt方法,并且在相应的线程内使用isInterrupted方法来获取中断标志以此来中断线程。既然我们已经对线程处理异常有了一定的了解,那我们就能填上之前对于线程中断以及join等方法的坑。

首先我想对大叫说明的一点是,在sleep,join,wait方法执行期间,如果线程被打断,也就是使用了interrupt方法,该线程会抛出InterruptedException异常,但是至于异常之后的处理就完全看线程实际功能代码的实现者如何处理了。

我们先来看两个因为产生interrupt异常而中断线程的例子

public class InterruptedByException {  
    public static void main(String[] args) throws InterruptedException { 
        Runnable runnable = () -> {  
        int num = 0;  
        try {  
            while (!Thread.currentThread().isInterrupted()) {  
                System.out.println("迭代次数: " + num);  
                num++;  
                // 使用sleep被中断时Interrupt标记位被清除  
                Thread.sleep(10);  
  
            }  
        } catch (InterruptedException e) {  
            e.printStackTrace();  
        }  
    };  
    Thread thread = new Thread(runnable);  
    thread.start();  
    Thread.sleep(5000);  
    thread.interrupt();  
	}  
}

// 运行结果  
...  
迭代次数: 481  
迭代次数: 482  
迭代次数: 483  
迭代次数: 484  
迭代次数: 485  
java.lang.InterruptedException: sleep interrupted  
	at java.base/java.lang.Thread.sleep(Native Method)  
	at cn.edu.ncu.erlys.liu.stopthread.CannotInterrupt.lambda$main$0(CannotInterrupt.java:13)  
	at java.base/java.lang.Thread.run(Thread.java:833)  
      
Process finished with exit code 0  

从上面的代码示例中我们看出,当子线程被中断之后,while循环外得到try…catch语句捕获到了异常并且打印了出来,子线程也退出了。这貌似是最理想的状态,外部发送中断信号,在子线程受到信号之后,产生异常,最终被处理,子线程结束运行。但是正如代码中注释说的,如果sleep中断了之后,中断标记位会被清除,那while循环的判断条件不久永远为true了吗?那不就即使被打断也跳不出来了吗?还好我把try写在了while外面啊….emmm,假如真的就写在了里面呢?我们该怎么办?

也就是有下面这么个情况

public class CannotInterruptByException {  
    public static void main(String\[\] args) throws InterruptedException {  
        Runnable runnable = () -> {  
            int num = 0;  
            while(!Thread.currentThread().isInterrupted()) {  
                try {  
                    // 使用sleep被中断时Interrupt标记位被清除  
                    /**  
                     * 在每次迭代被中断之后 isInterrupted方法返回值应该为  
                     * true,但是sleep会将该标记位清除,也就是说该方法返回值  
                     * 在中断之后仍然为false,这样,每次迭代的条件始终为真  
                     * 循环无法退出  
                     */  
                    Thread.sleep(10);  
                    System.out.println("不被打断真是意见美逝");  
                } catch (InterruptedException e) {  
                    e.printStackTrace();  
                }  
            }  
        };  
        Thread thread = new Thread(runnable);  
        thread.start();  
        Thread.sleep(5000);  
        thread.interrupt();  
    }  

}  
// 下面是部分运行结果  
...  
不被打断真是意见美逝  
不被打断真是意见美逝  
不被打断真是意见美逝  
不被打断真是意见美逝  
不被打断真是意见美逝  
不被打断真是意见美逝  
不被打断真是意见美逝  
java.lang.InterruptedException: sleep interrupted  
	at java.base/java.lang.Thread.sleep(Native Method)  
	at cn.edu.ncu.erlys.liu.stopthread.CannotInterrupt.lambda$main$0(CannotInterrupt.java:16)  
	at java.base/java.lang.Thread.run(Thread.java:833)  
不被打断真是意见美逝  
不被打断真是意见美逝  
不被打断真是意见美逝  
不被打断真是意见美逝  
不被打断真是意见美逝  
不被打断真是意见美逝  ...  

这次循环可没有因为中断异常而停止,在答应了异常信息之后依然”我行我素“,继续“一件美逝”。

遇到这种情况我们该如何处理呢?这里岔开一句,不知道大家有没有看过斯嘉丽约翰逊也与摩根弗里曼演的《超体》。我认为里面有几个观点很符合我们处理这个异常的思想。即,对于生命的两个选择,在环境恶劣的时候选择永生,在温和的情况下则应该将生命传递下去。此处处理异常也是这么个想法。既然我们没法“永生”(throws)那我们就传递下去。我们可以在catch的代码块中重新设置中断标记位

public class PassInterruptState {  
public static void main(String\[\] args) throws InterruptedException {  
        Runnable runnable = () -> {  
            int num = 0;  
            while(!Thread.currentThread().isInterrupted()) {  
                try {  
                    // 使用sleep被中断时Interrupt标记位被清除  
                    /**  
                     * 在每次迭代被中断之后 isInterrupted方法返回值应该为  
                     * true,但是sleep会将该标记位清除,也就是说该方法返回值  
                     * 在中断之后仍然为false,这样,每次迭代的条件始终为真  
                     * 循环无法退出  
                     */  
                    Thread.sleep(10);  
                    System.out.println("不被打断真是意见美逝");  
                } catch (InterruptedException e) {  
                    Thread.currentThread().interrupt();  
                }  
            }  
            System.out.println("终于结束了子线程");  
        };  
        Thread thread = new Thread(runnable);  
        thread.start();  
        Thread.sleep(5000);  
        thread.interrupt();  
    }  
}  
// 下面是部分运行结果  
不被打断真是意见美逝  
不被打断真是意见美逝  
不被打断真是意见美逝  
不被打断真是意见美逝  
不被打断真是意见美逝  
不被打断真是意见美逝  
不被打断真是意见美逝  
终于结束了子线程  

Process finished with exit code 0  

在子线程被中断之后,我们在catch代码块中使用interrupt方法重新设置了线程中断标记位,让下次第迭代循环判断时能够得知线程已经被告知中断而不会因为sleep方法的原因无法看到线程标记位的变化,由此跳出循环线程被中断。

自定义异常处理方法

在处理异常时我们也可以自定义异常处理方法,不然每个人的异常格式都千奇百怪,那查看程序运行日志可真的会变成一件“美逝”了。

如果同学们对接口这个概念熟悉的话,下面的一些操作应该会显得很自然。即使不是很了解也没关系,暂且在会使用的层面就行,不需要啥高大上的理解。

在自定义接口方面,我们可以实现Thread类内部的一个UncaughtExceptionHandler接口,以此来自定义我们自己的异常处理逻辑。下面是该接口一源代码

@FunctionalInterface  
 public interface UncaughtExceptionHandler {  
     /**  
     * Method invoked when the given thread terminates due to the  
     * given uncaught exception.  
     * <p>Any exception thrown by this method will be ignored by the  
     * Java Virtual Machine.  
     * @param t the thread  
     * @param e the exception  
     */  
     void uncaughtException(Thread t, Throwable e);  
}  
 // 说明一下,t表示发生异常的线程实例,e表示该线程发生的异常类型  

从接口的声明中我们知道,只要实现uncaughtException这个方法就好了。那我们就来试一试,下面是我自己写的异常处理类

import java.util.logging.Level;  
import java.util.logging.Logger;  

public class MyUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler {  
    @Override  
public void uncaughtException(Thread t, Throwable e) {  
    Logger logger = Logger.getAnonymousLogger();  
    logger.log(Level.WARNING, "线程异常,终止了 " + t.getName(), e);  
    System.out.println(" 捕获了异常 " + t.getName() + " 异常 " + e);  
	}  
}
// 使用Log记录  

使用我们自己的异常捕获

public class UseOwnExceptionHandler implements Runnable{  
    @Override  
    public void run() {  
        throw new RuntimeException();  
    }  
    public static void main(String\[\] args) {  
    MyUncaughtExceptionHandler handler = new MyUncaughtExceptionHandler();  
  
    // 此处设置异常处理实例  
    Thread.setDefaultUncaughtExceptionHandler(handler);  
  
    new Thread(new UseOwnUncaughtExceptionHandler()).start();  
    new Thread(new UseOwnUncaughtExceptionHandler()).start();  
    new Thread(new UseOwnUncaughtExceptionHandler()).start();  
      
	}  
}  
// 部分运行结果  
警告: 线程异常,终止了 Thread-1  
java.lang.RuntimeException  
	at cn.edu.ncu.erlys.liu.uncaughtexception.UseOwnUncaughtExceptionHandler.run(UseOwnUncaughtExceptionHandler.java:20)  
	at java.base/java.lang.Thread.run(Thread.java:833)  

12月 19, 2021 11:47:49 上午 cn.edu.ncu.erlys.liu.uncaughtexception.MyUncaughtExceptionHandler uncaughtException  
警告: 线程异常,终止了 Thread-2  
java.lang.RuntimeException  
	at cn.edu.ncu.erlys.liu.uncaughtexception.UseOwnUncaughtExceptionHandler.run(UseOwnUncaughtExceptionHandler.java:20)  
	at java.base/java.lang.Thread.run(Thread.java:833)  

 捕获了异常 Thread-0 异常 java.lang.RuntimeException  
 捕获了异常 Thread-1 异常 java.lang.RuntimeException  
 捕获了异常 Thread-2 异常 java.lang.RuntimeException  

有兴趣的同学还可以康康setDefaultUncaughtExceptionHandler方法的源码,这里就不带大家看了。记住一点,多看源码

异常中断线程小结

在中断线程这件事上,始终还是一个观点,使用interrupt方法来告知线程需要被中断,而此时如果线程在运行中遇到中断需要抛出异常的情况则需要使用throws,在较高的层次进行try…catch处理而不是将异常在方法内部自行处理,以免带来后期处理上的问题。

如果异常无法被抛出,那就处理,如果异常,某些状态被清除(例如线程中断标记位),那就将状态重新设置,传递下去。

线程可能带来的问题

并发问题总起

在文章的开篇和大家说过,要编写好并发程序并不是一件简单的事情。并发的程序结果可能并不会按照我们的想法运行得那么顺利,通过前面的学习我们也能从中了解到并发是存在相比较于单线程更多的问题的。因此在Java中的并发语法基础这个大标签下,我们最后来谈谈并发带来的问题,我将从大体两方面来介绍这个内容,但是在实际中遇见的问题将远不止这些,这就需要同学么不断地实践了。大致了解某个技术或许能使我们在当下实际项目或者应用中使用起来,但是要对知识点有更深层次的理解,以至于当我们再次使用或者谈及时是一种自然而然的感觉而非简单记忆是需要相当多的时间去磨练的。所以保持好你们的热情,让我们将这个标题结束完。

安全问题

什么是线程安全

因为本身并不是线程方面的专家或者学者,我无法给出相当令人信服的总结,我便从比较权威的书籍中找到了下面这段话,希望对同学总体上认识线程安全有帮助

原文

A class is thread‐safe if it behaves correctly when accessed from multiple threads, regardless of the scheduling or interleaving of the execution of those threads by the runtime environment, and with no additional synchronization or other coordination on the part of the calling code.

翻译

当多个线程访问一个类时,如果不用考虑这些线程在运行时环境下的调度和交替执行,并且不需要额外的同步及在调用方代码不必做其他的协调,这个类的行为仍然是正确的,那么称这个类是线程安全的。

数据的不一致性

其实这个问题早在我们聊synchronized关键字的时候就已经出现了。只是当时并没有将这个问题进行归类,这里我们来重新温习一下。下面是讲解synchronized关键字时的示例代码

public class WithoutSynchronized implements Runnable{  
    private static int count = 0;  
  
public void add() {  
    for (int i = 0; i < 10000; i++) {  
        count++;  
    }  
}  
  
@Override  
public void run() {  
    add();  
}  
  
public static void main(String\[\] args) throws InterruptedException {  
    WithoutSynchronized mySelf = new WithoutSynchronized();  
    Thread thread1 = new Thread(mySelf);  
    Thread thread2 = new Thread(mySelf);  
    thread1.start();  
    thread2.start();  
    thread1.join(); // 主线程等待子线程执行完毕,此处为main所在线程等待thread1执行完毕  
    thread2.join();  
    System.out.println("count经过两轮 1w++操作的值为: " + count);  
	}  
}
// 运行结果为  
count经过两轮 1w++操作的值为: 10181  
count经过两轮 1w++操作的值为: 16664  
count经过两轮 1w++操作的值为: 20000  
count经过两轮 1w++操作的值为: 16725  

 

可以看出在不考虑任何线程安全的情况下,共享变量count的结果是无法确定的。但是我们希望的结果可不是这个程序随心所欲,要控制或者说解决这个问题就得让线程之间进行同步,而在前面的篇幅里向大家讲述了synchronized关键字的使用并且通过不同的形式解决了该问题,以及对于为啥会出现这个问题也进行了一定的分析,这里就不赘述了。忘记的同学可以回到synchronized关键字使用讲解这一节具体康康。

死锁的发生

从名字上来说,死锁听着蛮吓人的,虽然至于某些无良游戏来的恐怖,但是死锁也确实需要我们额外去注意,所以这里我们来聊聊死锁问题。

什么是死锁

什么是死锁呢?死了的锁?哈哈哈,字面理解了属于是。我们在日常生活中也遇见过这种死锁的场景。逢年过节的,你亲戚的两个小孩来你家玩,看到你还没来的及藏起来的变形金刚模型,两眼冒光,当你再次见到你的模型的时候他正处在一个死锁之中,两个孩子正一人拿着模型的一边抢着,谁也不让谁,都说”你先放,我就放“结果就是,谁都没放,谁也不能玩。只留下伤心人在时候处理残局。

这个场景其实可以对应到我们的代码中,有两个线程都存在对当想要获得的东西(锁),但是又必须等到对方先释放锁才能继续下去,由此两个线程都陷入了无尽的等待之中。

死锁代码演示

从场景上理解死锁当然不如代码上演示更能让同学们理解了,所以下面就进入代码演示环节

public class DeadLock implements Runnable{  
    int flag = 1;  
    static Object lock1 = new Object();  
    static Object lock2 = new Object();  
    public static void main(String[] args) {  
    DeadLock r1 = new DeadLock();  
    DeadLock r2 = new DeadLock();  
    r1.flag = 1;  
    r2.flag = 0;  
    Thread t1 = new Thread(r1);  
    Thread t2 = new Thread(r2);  
    t1.start();  
    t2.start();  
}  
  
@Override  
public void run() {  
    System.out.println("flag = " + flag);  
    if (flag == 1) {  
        synchronized (lock1) {  
            try {  
                Thread.sleep(500); // sleep不像wait,sleep期间不会释放锁  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
            synchronized (lock2) {  
                System.out.println("线程1成功拿到两把锁");  
            }  
        }  
    }  
    if (flag == 0) {  
        synchronized (lock2) {  
            try {  
                Thread.sleep(500); // sleep不像wait,sleep期间不会释放锁  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
            synchronized (lock1) {  
                System.out.println("线程2成功拿到两把锁");  
            }  
        }  
    }  
	}  
}
// 运行结果  
flag = 1  
flag = 0  
// 程序还在运行,但是没有下文了  

下面我们来分析一下这个程序,首先我们创建了两个Object对象作为两把锁,并且设置标记位来设置线程运行时运行不同的if语句中的代码块。

int flag = 1;  
static Object lock1 = new Object();  
static Object lock2 = new Object();  

在run方法中我们通过flag来运行不同的代码块,但是每个代码块的基本运行逻辑是差不多的就比如当flag恒等于1时执行下面的代码

synchronized (lock1) {  
    try {  
           Thread.sleep(500);  
         } catch (InterruptedException e) {  
           e.printStackTrace();  
         }  
       synchronized (lock2) {  
           System.out.println("线程1成功拿到两把锁");  
    }  
}  

当线程1进入之后获取lock1,沉睡500ms,进而继续去获取lock2,如果成功获取到了lock2,则打印出成功拿到两把锁的语句。线程2也是执行类似的操作,但是获取锁的顺序刚好相反,先获取lock2再获取lock1。最终的结果是大家都没有打印出成功获取到两把锁。此处我们假设线程1先运行并且成功拿到了lock1,此时线程1进入休眠,也是这个时候线程2启动,拿到了lock2,也进入了休眠,当线程1醒来之后准备去拿lock2,发现lock2正在被线程2持有,那就等一等吧,而当线程2醒来时想要去获取lock1,但是发现线程1正持有lock1,那也等一等吧,欸?这两人就进入了无限等待了,都在等待对方释放资源,死锁也就这么产生了。下面的图更好的向同学们展示了具体情况

分析死锁发生的必要条件(4点)

既然知道了死锁问题是怎么样的,那我们就来分析分析为什么会出现这种问题?首先就是我们多个线程都存在对方想要的资源(此处为两个线程)——锁。但是这个锁不能同时被两个线程获取,即这个资源是互斥的。其次,两个线程都不会主动释放手中已经获取的锁再者没有第三者来打断这种状态,即没有那个拯救模型的人出现(哭死)。最后等待的环路,两个线程在相互等待对方,都希望对方能够释放手中的锁,结果就是陷入大家熟悉的死锁之中。

尝试解决死锁问题

有问题就会有存在解决方案,那死锁的解决方案是啥呢?既然发生死锁存在条件,那我们就从条件入手。在此因为篇幅的原因捏,就不说那么多了。稍微带大家康下好康的解决办法。

怎么解决死锁问题呢?直接开摆,我不解决了!哈哈哈,别看这么离谱。如果我们的程序不存在并发的代码,或者并发出现死锁的可能性很小,那也是可以这么做的,先不考虑死锁的问题,直接开摆,当我们遇见死锁问题再说。虽然这看起来都不能算一个解决办法。再者,既然在获取锁的时候因为顺序问题导致线程获取锁之后存在循环等待问题,那我们就把获取锁的顺序改一改,比如把线程2先获取lock2改成先获取lock1再获取lock2,这样也就解决了循环等待。

这只是其中几个简单的解决问题的方式,还存在许多方式,希望同学们能够自己去实践,去发现。提示:从死锁发生的条件上入手解决死锁问题。

对象的发布与溢出问题

在刚刚开始学Java的时候,最开始看的课程就试图让我对描述可见性的关键字有深刻的了解,在聊到private关键字的时候告诉我,private就是不想让外部类看见我有啥,不能直接访问的东西要用private修饰。那我们假如让private修饰的内容发布出去了呢?让别的类都看到了呢?会存在问题吗?可能在我们日常的单线程编程之中貌似没有在意过这个问题,但是在多线程之中,对象的发布是我们必须要注意的问题。

考虑下面的代码

public class ThreadError implements Runnable{  
    private Map<String, String> map;  
    public ThreadError() {  
    map = new HashMap<>();  
    map.put("1", "一");  
    map.put("2", "二");  
    map.put("3", "三");  
    map.put("4", "四");  
}  
  
public Map<String, String> getMap() {  
    return map;  
}  
  
public static void main(String\[\] args) {  
    ThreadError te = new ThreadError();  
    te.getMap().remove("1");  
    Thread t = new Thread(te);  
    t.start();  
}  
  
@Override  
public void run() {  
    System.out.println(map.get("1"));  
	}  
}
// 运行结果  
null  

在主线程中我们将map内的1对应的值删去了,但是在其他线程中还是希望获取最原始的map中1的值,此时却啥也没剩下。那怎么解决类似与这种问题呢?其实也很简单,就是在获取私有变量时每次都复制一份,也就是像下面这样

public Map<String, String> getMap() {  
    return new HashMap<>(map);  
}  

不止在私有变量上存在溢出问题,在构造函数内如果操作不当也会存在类似的问题,比如在构造函数内新启动线程去初始化数据,而外界在构造函数还未完成任务的时候就已经开始执行需要依赖构造函数初始化结果的步骤,此时问题便出现了。考虑下面的代码

public class MultiThreadErrorInConstructor {  
    private Map<String, String> states;  
    public MultiThreadErrorInConstructor() {  
    new Thread(new Runnable() {  
        @Override  
        public void run() {  
            states = new HashMap<>();  
            states.put("1", "一");  
            states.put("2", "二");  
            states.put("3", "三");  
            states.put("4", "四");  
            try {  
                Thread.sleep(5000);  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
        }  
    }).start();  
}  
  
public Map<String, String> getStates() {  
    return new HashMap<>(states);  
}  
  
public static void main(String[] args) {  
    MultiThreadErrorInConstructor multiThreadsErrorPublishE = new MultiThreadErrorInConstructor();  
    System.out.println(multiThreadsErrorPublishE.getStates().get("1"));  
	}  
}

大家觉得程序运行的结果是啥呢?是字符串 一 吗?还是别的什么。:-) 就不卖关子了,这个程序会抛出一个空指针异常。下面是运行结果

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.util.Map.size()" because "m" is null  
	at java.base/java.util.HashMap.putMapEntries(HashMap.java:495)  
	at java.base/java.util.HashMap.<init>(HashMap.java:484)  
	at cn.edu.ncu.erlys.liu.threadsafe.MultiThreadErrorInConstructor.getStates(MultiThreadErrorInConstructor.java:28)  
	at cn.edu.ncu.erlys.liu.threadsafe.MultiThreadErrorInConstructor.main(MultiThreadErrorInConstructor.java:33)  

诶?我们找到对应行,定位到getStates方法内,说这里的返回值为空。emmm,确实奇怪。其实从多线程的角度来看倒也合理。我们在构造函数内启动了一个子线程去初始化map,但是这个用时我们使用sleep方法设定为5s,但是在main函数中可没等构造函数“构造”完成,直接调用getStates方法,但是此时的states还是个孩子(还是null,并未被赋值),自然也获取不到对应的值了,程序便抛出了空指针异常。至于这个问题的解决方案,同学们可以尝试自己想想怎么解决(设计模式上的使用或者类似判断线程执行完毕的操作等等)。这里就不再赘述了捏。当然线程安全问题不止这些,其他的还需同学们自己去发觉理解。

性能问题

多线程确实在很多场景下提高了我们程序的效率,但是如果处理不当,性能方面可能适得其反。而在多线程下,谈到性能,我们就不得不谈到context也就是翻译过来的“上下文”。这个翻译可能会有些晦涩,在文章内,我们能理解啥是上下文,从小学开始我们就开始在语文阅读题中看到过“上下文”。类似于“请根据上下文作答”之类的话。此处上下文值得就是对应段落的上文内容和下文内容。但是在程序里的context是啥意思呢?

什么是上下文(context)

其实也向我们翻译的那样,上下文其实就是与当前相关的上文和下文(你搁这搁这呢!)。简单来说,在多个线程切换的过程中,需要保存切换时的某些变量,状态等等信息。以便于当线程再次切换回来的时候继续运行。当然说到上下文,我们在编写web引用的时候也离不开这个概念,在这些业务逻辑代码中我们常常使用上下文来传递一些验证数据,或者用于请求的链路追踪(不继续往下说了,还是回到线程上来)当然这是很浅显的理解了。哪位大佬有更深刻的见解或者要指出我的理解那里不对请随时联系我O :-)。

上下文的切换

既然存在线程间的调度,那上下文的切换是不可避免的。但是在切换上下文,保存对应线程相关数据的同时,CPU性能上的消耗又会增加,因此并不是线程越多越好,越多效率越高。所以在使用多线程时不能一味增加线程数量,而更应该考虑如何让多个线程间有效配合来达到提高效率的目的。

Java中的并发基础概念

终于和大家一起走过了Java并发的语法或者说代码上的用法部分。接下来我们再继续往深处走一丢丢。看看内存方面的事情,Java的多线程又会在这之中遇到的概念和问题。希望大家保持学习的那股冲劲和热情,毕竟学习有时候就是需要那么一种不服输的以至于倔强的勇气与毅力。

JVM内存结构,Java内存模型,Java对象模型

前面的章节中我们在Java语法层面讨论了并发与多线程。而接下来的内容将要和大家一起研究的是三个比较重要的概念,当然光有概念没有实践也终究是纸上谈兵,所以在涉及代码的时候,我会尽力给出示例,和大家一起探讨这其中的奥妙。之所以这篇文章的作者(我才不告诉你作者是某位家园20级研发同学)要这么来区分文章层次是因为在我的学习历程中,我更希望学习到的知识能很快用上,那种正反馈的激励是继续学下去的动力之一,所以如果直接上来和大家聊概念,那估计没几个人会看到最后了。在我们学会创建线程,启动线程,终止线程,等等一系列操作之后,再来了解背后隐藏的概念,或者我们曾经一直在使用但是却未认真分析的事情这更让人倍受鼓舞,会有恍然大悟的感觉而不是满头问号,纠结于我为什么要学习这个东西。

坚持我们自己的倔强,学习从来不是一条坦途,但是,正是我们的不放弃才让这条路的风景美不胜收。

JVM内存结构

还记得我在创建线程章节除了知识以外对大家说的话吗?**学习一门技术最好的途径就是查看官方文档。**没错,这部分内容我也继续来带大家康康官方文档都说了啥。当然啦,还是得提前说一下,官方文档都是英文的,刚开始可能大家觉得很不适应也是正常的,就我个人的经验来说,在查看官方文档,即使翻译也不懂意思的情况下,可以在国内搜索引擎上找找答案,看看别人的理解,将别人的想法作为参考但不是直接照搬下,再去查看文档,这会对同学们的学习有很大帮助。

什么是JVM内存结构

JVM,即Java虚拟机英文为Java Virtual Machine,相信大家在学习Java之初就应该或多或少接触过这方面的概念了。从名字来说我们就大致知道这是啥东西,就是个虚拟机嘛,该虚拟机保证了计算机能够运行Java代码,准确的说是Java字节码,因为如今不只有Java这门语言编写的代码能运行在JVM上。那啥又是Java虚拟机的内存结构呢?

如果我们仅从内存来看的话,内存结构整体上被分为5个大区域,我们直接来看官方文档是怎么说的。

从文档中我们看出,JVM内存结构整体上被成为Run-Time Data Areas 即运行时数据区,但是就目录来看这有留个区域为啥说是5个呢?别急我们一个一个来看

首先是pc Register全称为Program Counter Register(程序计数器),来看官方文档的说法

pc Register

The Java Virtual Machine can support many threads of execution at once (JLS §17). Each Java Virtual Machine thread has its own pc (program counter) register. At any point, each Java Virtual Machine thread is executing the code of a single method, namely the current method for that thread. If that method is not native, the pc register contains the address of the Java Virtual Machine instruction currently being executed. If the method currently being executed by the thread is native, the value of the Java Virtual Machine’s pc register is undefined. The Java Virtual Machine’s pc register is wide enough to hold a returnAddress or a native pointer on the specific platform.

每个线程独有程序计数器,并且当线程当下运行的不是本地方法时,则程序计数器包含当前正在执行的 Java 虚拟机指令的地址。至于后面的相关就不带大家看了,大家只要了解我说的中文部分就好。

Java Virtual Machine Stacks

在较早的文档中也被成为_Java Stack_

Each Java Virtual Machine thread has a private Java Virtual Machine stack, created at the same time as the thread.

每个线程会有独自的Java虚拟机桟,而该桟用于存储帧(Frames),在帧内存储着许多信息,其中变量的引用也存储在这里。

Heap

中文翻译为 堆

The Java Virtual Machine has a heap that is shared among all Java Virtual Machine threads. The heap is the run-time data area from which memory for all class instances and arrays is allocated.

Heap(堆)是所有线程共享的,并且变量的实例是存储在堆中的。

Method Area

中文翻译为 方法区

The Java Virtual Machine has a method area that is shared among all Java Virtual Machine threads.It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods used in class and interface initialization and in instance initialization .

方法区是所有线程共享的,其中存储了运行时常量,字段,方法等等。

Run-Time Constant Pool

中文翻译为 运行时常量池

Each run-time constant pool is allocated from the Java Virtual Machine’s method area.

It contains several kinds of constants, ranging from numeric literals known at compile-time to method and field references that must be resolved at run-time.

运行时常量池从方法区中分配存储空间,顾名思义,包含多种常量。

Native Method Stacks

中文翻译为 本地方法桟

An implementation of the Java Virtual Machine may use conventional stacks, colloquially called “C stacks,” to support native methods

用于实现native方法

到这里我觉得我们可以用图来归纳一下JVM内存结构了。当然在内存区域意外还存在很多组件,大家有兴趣的话可以直接查阅文档,这里就不带大家查看了。

Java内存模型

什么是内存模型

内存模型即Memory Model(JMM),听起来好像很高大上,其实本质上并不难理解,即一种规范,用来约束或者预测程序的执行结果。我们直接来看看官方文档给我们的回答

The memory model describes possible behaviors of a program. An implementation is free to produce any code it likes, as long as all resulting executions of a program produce a result that can be predicted by the memory model.

即内存模型描述了程序的可能行为。并且程序产生的结果是可以有内存模型预测的。这倒是给我们的Java多线程编程带来了福音。通过之前的知识我们知道,在多线程编程时,在没有任何同步措施的情况下,很难预测程序或者说控制程序的运行结果。而在我们使用了诸如synchronized等机制之后,程序的运行结果变得可预期了。而这种可预期的结果也都是基于Java内存模型。可以说Java内存模型是各种多线程相关工具类以及关键字的原理。

Java内存模型中的三大要素

在大致了解Java内存模型的基本概念之后,我们来康康在实际编码中我们最需要注意的三点因素。

重排序
  • 什么是重排序

    在编写程序时,单个线程内我们会简单地认为程序会由上而下执行我们编写的代码(实际未必),而实际上代码执行的顺序可能会被重新排序,例如有一下代码

    a = 1;  
    b = a;  

    这两行代码如果在同一线程中,我们或许会默认为顺序是不变的,顶多在多线程执行时,中途可能会切换到其他线程执行其他代码,但总体上这两行的先后执行顺序在我们看来是不会变的。但是实际并不是如此,在实际的运行中这两行运行的顺序也可能会发生变化。不相信的同学可以试着写写代码,验证一下这种情况。(记得尝试多次)

  • 为什么会出现重排序

    一句话,为了提高程序运行速度。在Java代码层面或许程序员无法看见可以优化的点,但是从编译器乃至CPU指令的层面来看,程序还是存在进一步优化的可能性的。也就是说,编译器等为了提高程序的运行速度,可能会对代码的执行进行重排序,进而导致执行结果的不确定。

可见性
  • 什么是可见性

    日常开发之中,假如我们写下了一句a=3那么之后的代码部分应该是能”见到“这部分修改的。但是在多线程的情况下,事情可能就没这么简单了。假如a=3的操作是在一个线程中执行,但是另一个线程也需要访问a这个变量,此时访问a变量的线程得到的a的值未必是3,这便是可见性问题。

  • 代码演示

    我们先来看看下面一段代码

    public class FieldVisibility {  
        int a = 1;  
        int b = 2;  
        public static void main(String[] args) {  
        while (true) {  
            FieldVisibility test = new FieldVisibility();  
            new Thread(new Runnable() { // 创建线程1,休眠一段时间之后,修改a,b的值  
                @Override  
                public void run() {  
                    try {  
                        Thread.sleep(1);   
                    } catch (InterruptedException e) {  
                        e.printStackTrace();  
                    }  
                    test.change();  
                }  
            }).start();  
                    new Thread(new Runnable() {  
                @Override  
                public void run() { // 创建线程2,休眠一段时间之后,打印a,b的值  
                    try {  
                        Thread.sleep(1);  
                    } catch (InterruptedException e) {  
                        e.printStackTrace();  
                    }  
                    test.print();  
                }  
            }).start();  
        }  
      
    }  
      
    private void print() { // 打印a,b的值  
        System.out.println("b = " + b + " a = " + a);  
    }  
      
    private void change() { //修改a,b的值  
        a = 3;  
        b = a;  
    	}  
    }

    从我们前面学习中分析,可能会存在下面三种情况

    结果结果分析
    b = 3 a = 3线程一先全部执行完,线程二后执行完
    b = 2 a = 1线程二先全部执行完,线程一后执行完
    b = 2 a = 3线程一执行完a的修改,后线程二执行

    一张表格省去了许多文字,也让人更加清楚发生了肾磨。但是这就是全部的结果了吗?就这?还有没有我们没考虑到的情况?答案是有!这也是可见性问题存在的最好证明,在输出结果中其实还包含下面一种结果

    结果结果分析
    b = 3 a = 1可见性问题发生,线程二只能看见线程一的部分修改

    同学们可能会感到奇怪,诶?奇怪了b都已经等于3了,那说明a也一定为3了呀,为什么输出的时候a还是1呢?你不会在骗我们吧。其实大家大可在自己的计算机上运行这段代码,看看运行结果中有没有最后着一中情况,到时候再来继续往下看也不迟。记住,不要那么简单地相信某一篇文章,要自己实践得到才是值得自己去相信的。

  • 为什么会出现可见性问题

    基于我自己的实践以及学习,我来解释解释为啥会存在这种情况,这种情况的本质原因是啥。首先为啥会出现这种情况?既然b已经为3了,那么在修改操作的线程中a则必然已经被赋值为3,但是在打印变量的线程中,见到的b是3,但是a仍然为1。在代码层面上的原因便是如此。emmm,可能很多人要喷我了,你这不是废话吗?既然输出了,当然就是这个结果啦。确实,那发生这种情况的本质是啥呢?在解答这个问题之前,还得向同学们科普一些CPU的知识,假如我们使用过C语言来编写程序就应该了解到C语言中存在register这个关键字,被这个关键字修饰的变量,程序会看你运气将该变量放入寄存器中提高运行速度。这里说看你运气是因为,并不是增加该关键字就能提升程序运行速度,只是有一定几率变量会被放入寄存器中。但是,什么是寄存器呢?其实在内存与CPU的交换数据过程中存在多级缓存,当缓存越靠近CPU运行速度则越快,而寄存器存在于CPU内部,这使得寄存器内的数据访问是最快的。下面是对于以上文字的图形化表述

    也正是因为缓存的存在,当我们CPU处理完某些数据之后,将数据放回缓存,但是更外层的缓存没有更新最新的值,因此导致了其他线程再去取值时读取到的仍然是更新之前的旧值。这也就是可见性问题发生的根本原因。

    那这就很棘手了呀,因为缓存的不同,导致程序运行结果要依赖于具体的CPU,程序的运行结果不久不能预知了吗?没错是这么个理,但是想想之前我们在Java官方找到的对于Java内存模型的定义。

    The memory model describes possible behaviors of a program. An implementation is free to produce any code it likes, as long as all resulting executions of a program produce a result that can be predicted by the memory model.

    即内存模型这种规范保证了程序的可预测性。也正是内存模型将CPU这些底层细节进行了屏蔽,让开发者能在更高的层次,更简单地操作程序,更多地注重实际问题的解决。那Java内存模型又是怎么解决这个问题的呢?

    在Java内存模型中,内存被简单抽象成了两层,一层是共享的主内存,一层是每个线程独立访问的本地内存。每个线程通过自身的本地内存与主存进行数据的交互。而在每个线程独自的本地内存中,变量都是主存中的副本。而在多个线程之间,本地内存不能被相互读取,不同线程只有通过主存来交换数据。

  • 介绍happens-before原则

    啥是happens-before原则?我们先来看下官方文档是怎么说的

    If one action happens-before another, then the first is visible to and ordered before the second.

    即一个动作发生在另一个动作之前,第一个动作对于第二个动作可见。其实这个概念在我们谈及synchronized关键字的时候就已经和大家了解过了。也就是在synchronized关键字代码块包裹的代码以及各种锁操作是满足happens-before原则的,即当前线程在synchronized代码块内的变量修改等等动作,对于之后的线程而言是可见的,这些修改是能被感知到的。而在线程启动也就是调用start方法,以及线程调用join方法时,也是满足happens-before原则的。即start方法一定发生在该线程run方法所有代码执行之前调用。而run方法所有代码执行完成一定发生在join方法结束之后也就是join方法的下一行代码开始之前。使用volatile关键字(马上会讲)修饰的变量也满足happens-before原则。使用volatile修饰的变量写入操作一定在读取操作之前发生。(此处指的是该变量更新值的情况)。

volatile关键字使用

早在停止线程的章节我们就使用过了volatile这个关键字,但是当时还没有具体向大家介绍这个关键字的用法。现在就来给大加聊聊这个关键字的作用和特性。volatile关键字一般在并发场景下使用,使用了volatile修饰的变量在写入时会立即将修改后的值写入主存,在读取时,本**地内存的值会失效,因而必须到贮存中读取。**也正是因为这两个特性,可以使用volatile来解决一些可见性问题。并且volatile会禁止指令的重排序,也由此来解决某些重排序带来的问题。就比如我们之前的a,b写与读的重排序案例中,可以将a,b都使用volatile修饰则可以解决重排序问题。即下面的代码

public class FieldVisibility {  
    volatile int a = 1;  
    volatile int b = 2;  
    // ...  
}  
// 在volatile保证读取变时,变量已经被写入主存。  
// 由于volatile可见性的保证,变量b之前的操作对于其他线程来说都可见,因此在下面的代码中  
// 保证了a的值也为3而不是1  
int a = 1;  
volatile int b = 2;  
private void change() {  
    a = 3;  
    b = a;  
}  
原子性

老问题,什么是原子性,其实我们在之前的线程安全问题章节已经涉及了原子性的概念,即执行情况只存在失败和成功两种情况的一组操作,这组操作是不能被打断的,那这组操作就被成为具有原子性,这组操作也被成为原子操作。在安全性的章节的示例中,就是因为count++这个操作并不是原子性的,所以在多线程进行操作的时候会出现线程间的同步问题,导致最终结果的错误。而在增加了synchronized等限制之后,保证了部分代码块的原子性。因为解决了数据错乱的问题。其实这部分内容在安全性那节已经嵌入了很多相关概念了,这里就不再继续赘述了。

  • Java中有没有本身就是原子操作的操作?

    当然有捏,但是不多。基本类型的赋值操作属于原子操作,这里的基本类型不包括包装类型比如int类型的赋值为原子操作,但是它的包装类型Integer的赋值则不是。所有引用的赋值操作也是原子操作。最后就是Java提供的java.concurrent.Atomic.*下的类的原子操作。在所有基础类型中,long与double类型的赋值操作并不是原子性的。对于这个问题,其实不用太考虑,许多JVM实现已经解决了这个问题。

单例模式在并发中的使用

多线程编程中,单例模式使用也是十分常见和重要的,比如我们在编写web应用时的日志实例,在不同位置记录日志信息,但是全局只需要一个实例,这个时候单例模式便起到了规范与作用。

  • 单例模式写法(列出其中4种)

    1. 饿汉式

      这是单例模式下我们最容易想到的写法。

      public class Singleton {  
          // static 表示类加载时初始化  
          // 类加载时JVM保证线程安全  
          private final static Singleton INSTANCE = new Singleton();  
          private Singleton1() {  
        
      }  
        
      public static Singleton1 getInstance() {  
          return INSTANCE;  
      	}  
      }

      这种写法在类被JVM加载初始化时就创建的实例,之后也一直使用这个实例。但是有些情况下我们未必想在一开始就创建实例,更想在用到时再创建。

    2. 双重检查模式

      public class Singleton {  
          private volatile static Singleton INSTANCE;  
          private Singleton() {}  
        
      public static Singleton getInstance() {  
          if (INSTANCE == null) {  
              // 不同线程拿到锁之后仍然会多次创建实例  
              synchronized (Singleton.class) {  
                  if (INSTANCE == null)  
                      INSTANCE = new Singleton();  
              }  
          }  
          return INSTANCE;  
      	}  
      }

      在这种写法中,一开始并没有直接创建实例,而是在调用getInstance时才创建,但是为了保证线程安全,创建的实例只存在一个,这里使用了synchronized代码块的形式,在最开始检查实例是否已经被创建,如果没被创建则进入同步代码块(synchronized修饰的代码块),这里再次检查实例是否已经被创建,为什么还要再次检查呢?试想一下,假如这里不再继续检查,那多个线程在进入同步代码块之后仍然会创建多个实例,只是最后INSTACE指向得到只有一个。也没有实现真正的单例。所以此处需要双重检查。

    3. 静态内部类形式

      public class Singleton { 
          private Singleton() {}  
        
      private static class SingletonInstance {  
          // JVM确保类加载时线程安全  
          private static final Singleton INSTANCE = new Singleton();  
      }  
        
      public static Singleton getInstance() {  
          return SingletonInstance.INSTANCE;  
      	}  
      }

      由于在JVM加载时保证了线程安全,因此我们可以使用静态内部类,调用外部类的getInstance方法时使用类加载机制来初始化实例,之后再次调用getInstance时获取到的仍然是最开始创建的实例。

    4. 枚举类实现单例模式

      public enum Singleton {  
         INSTANCE;  
         // ...  
      }  

      推荐使用枚举类的形式来实现单例模式。

Java对象模型

最后一个需要向大家说明的就是对象模型,对象模型描述了对象在JVM中的存储方式。

在JVM加载java类的时候, JVM会给这个类创建一个instanceKlass并保存在方法区, 用来在JVM层表示该java类。当我们使用new关键字创建一个对象时, JVM会创建一个instanceOopDesc对象, 这个对象包含了对象头和元数据两部分信息。对象头中有一些运行时数据, 其中就包括和多线程有关的锁的信息。而元数据维护的则是指向对象所属的类的InstanceKlass的指针。

总结

本文旨在带领读者一窥Java并发与线程方面的基础知识,谈不上多专业,想要了解更多的细节还是需要读者更多地去接触经典书籍以及相关专业的文章。卡尔古斯塔夫所说过“启蒙不是对光明的想象,而是对黑暗的觉知”。虽然谈不上对“黑暗知觉”但是希望这篇文章能在读者对Java并发方面提供一些参考。学习一门技术最好的途径就是官方文档。在这篇文章中也带大家看了很多文档内容和源码内容(源码和文档都是基于JavaSE 17LTS版本),希望大家在学习上也能保持这个习惯。阅读上的障碍克服之后,剩下的就是坚持和倔强了。

评论 (...)

加载中...