作者:老九—技术大黍
社交:知乎
公众号:老九学堂(新人有惊喜)
特别声明:原创不易,未经授权不得转载或抄袭,如需转载可联系笔者授权
前言
Java多线程问题,我们认为它是一个玄学问题,因为Java虚拟机本身不是开源的程序,我们并不知道它到底怎么是处理Java的多线程机制的。
各位看官如果不同意这种说法,那么请大家耐心等到我解读QT开发时,我们再来讨论操作系统级别的多线程开发问题,到时候我们再来看看这个Java多线程问题到底是怎么回事儿。
线程简介
本文参考自《Java Threads 2nd Edition》一书。在这本专业书籍中两位作者是这样描述多线程:
我来参考翻译一下,请大家不要当成标准,如果有不足之处,请大家指正和补充:
线程的概念简单地讲,就是对线程控制的描述,而这种控制是最简化的、最关键的控制过程,当然这些控制过程是我们代码片断(Java的代码)在单个程序中与其它被控制的独立线程一起独立运行。
下面都是我这个计科系出身的程序员翻译的,请大家不要当成标准,如果有不足之处,请大家指正和补充,或者自行百度也行,因为像这种吹牛皮的东西,网上多得很。我们的目标是想给大家一种针对专业书籍的解读和理解,希望能够帮助大家少走弯路,比较快速地理解Java神秘的多线程机制。
线程控制
线程控制是一种复杂的技术,但是它的概念非常简单:它是一种路径,该路径是一个程序执行期间的路径!这种路径决定了代码怎样被执行:是if块中的语句被执行,还是else块中的语句被执行?While语句应该循环多少次?这些执行步骤和执行它们的顺序就是线程控制的结果。
多线程控制有点像从两个列表中执行任务,我们控制两个列表中的“to do”顺序来完成相同的任务。当一个列表中的任务在反复执行(bored)时,我们可以调整这个列表的一些时间给另外一个列表使用。
多任务简介
我们非常熟悉多任务操作系统模拟运行多个程序,比如Windows操作系统,Linux操作系统等。在这样的程序中,至少有一个线程在里面,我们把这种情况叫做单线程。单线程有如下特点:
- 进程从一个即定的点开始执行。比如C和C++中的main()方法开始执行。
- 语句的执行顺利完成按照有序执行,这种顺序是一组给定的输入定义。而单进程不是这样:它只是简单的执行程序中的下一句指令。
- 在执行期间进程可以访问确定的数据。在Java中有三种数据进程可以访问:从线程的栈访问本地变量;通过对象引用访问实例变量;通过类/对象引用访问静态变量。
现在我们假设电脑中有启动两个单一线程:一个文本编译器。这时电脑中有两个进程在运行;每个进程有一个单线程刚好不在线。这时两个进程不需要知道彼此,虽然它们之间可以有很多种试通信。
- Processes in a multitasking environment--多任务环境中的进程
- Operating System--操作系统
- Shared Memory--共享内存
- Application--应用程序(这里应用程序不只是指Java应用程序一种,它包括系统级别和非系统级别的应用程序,比如Notepad、Word字处理、Java虚拟机等)
- Local Memory--本地内存
多线程简介
从使用电脑的人的角度来讲,这两个进程是同时执行的,虽然有多种因素可以影响显示的效果。而这种影响依赖于操作系统,比如一个OS不支持多进程,那么两个程序不会有同时出现的效果。
最后,两个进程中的数据默认情况是分开的:彼此有自己的栈来保存本地变量,以及自己的数据区域。
这种状况让我们可以类推:把Java认为一种进程,然后我们认为一个程序在一个JVM中以多线程的形式运行,就像我们认为多个进程在一个操作系统中一样,如下图:
- Multitasking versus threading--多任务与线程对比
- Operating System--操作系统
- Application--应用程序(这里应用程序不只是指Java应用程序一种,它包括系统级别和非系统级别的应用程序,比如Notepad、Word字处理、Java虚拟机等)
- Java Virtual Machine--Java虚拟机,从上图可以看出,操作系统控制Java虚拟机,Java虚拟机与其它应用一样都是一种特殊的应用程序。我们这里就可以理解在“JNI编程不完全详解”一文中讲到:“当Java平台被部署到主机环境的最顶端处时,它就可以让Java应用程序与使用其他编程语言(比如C/C++)实现的本地代码协同工作。” 此时,我们就可以理解什么叫做主机环境的最顶端处,就是操作系统直接Java虚拟机程序而已的意思。当然Java虚拟机是使用C/C++来书写的,不是使用Java语言来书写的,并且不开源。相信咱们到这里可以真正理解什么是Java虚拟机的意思了吧
。
- Thread--线程
- Local Variables--本地变量
所以在Java程序中,多线程有以下特点:
- 每个线程根据预定义执行。对于一个程序中的线程启始位置是main方法;对于其它的线程,它们的启始位置是由代码来决定的。比如applet小程序,它的main方法由浏览器本向来执行。
- 每个线程从启始位置开始有顺序的执行代码。线程的目的很简单,它在语句序列中简单的执行下一个语句。
- 在程序中,每个线程执行的代码独立于其它线程。如果线程选择彼此合作,那么需要合作的策略来规范。但是合作不是必须的
- 线程可以导致高并发的效果出现。高并发效果取决于操作的支持能力,以及程序员的意识—保持潜在的并发执行!
- 线程可以访问各种数据类型。由此推理,多进程有时候会中断,同样Java程序也可以出现这种状态。
每个线程是分开的,所以方法中的本地变量对于不同的线程也是分开的,我们指被线程执行的方法。如果线程执行相同的方法,那么每个线程有该方法的本地变量的拷贝。这完全类似于多进程处理一个文件编辑器。
另外一个方面是对象和它们的实例变量可以在一个Java程序中被多线程共享。它比大多数的多进程操作系统共享容易得多。实际上,线程之间共享数据容易也是另外一个使用线程编程的重要原因!但是的线程不可以任意访问另外一个线程的数据,这必须取得其它线程的授权。**推理:**静态变量是Java中的最大异常:因为自动被所有线程对象共享!
Java的线程Thread被设计来简化多线程的编程。那么为什么需要使用线程呢? 最重要的原因是Java没有异步行为的概念。但是,实际中常常需要这种异步行为,于是Java使用多线程技术可优雅的来解决这些问题。
非阻塞I/O(Nonblocking I/O)
在Java编程中,像大多数的编程语言,当我们需要用户输入时,一般我们会指定用户终端(System.in)的read()方法来实现。当程序使用read()方法时,程序一般都会等待用户输入之后才会继续执行下一行语句—这种类型的I/O被叫作阻塞I/O—因为程序必须满足read()方法有数据输入的动作。
这种输入行为一般不我们所希望的,比如我们从一个网络socket对象读取数据,该数据经常是当我们想读它是时常常读不到:因为通过网络传输数据有延迟。如果我们让程序在从socket对象读取数据时必须等待,而不做其它事情,可以想像一下这种情况是多么糟糕。又比如在GUI编程中,如果我们画面中有两个按钮,当一个按钮的事件处事动作是执行read()方法时不让程序可以做其它事情,那么意味着鼠标事件没有办法响应用户的动作!结果会让用户非常郁闷,因为他可以认为程序“挂了”。
对于这种问题传统的解决方式有三种:
- I/O multiplexing (I/O丛集技术)
- Polling (投票技术)
- Signals (信号技术)
是在Java语言来讲,它没有直接使用这些技术的方法。像投票技术可以FilterInputStream类的available()方法有限支持。但是操作系统不支持这种技术。作为缺失这些技术的补偿那就是:Java程序员必须创建两个分开的线程来解决,两个线程在数据不可用时会自动阻塞。而其它线程可以在处理用户事件,或者处理其它任务。
当我们反复从网络socket对象读数据时,阻塞I/O的问题会非常明显。如果进行socket编程,那么你可以其中一种技术来读取socket对象,但是不会使用这种技术写数据到socket对象中去。这种情况一般在局域网中不明显,但是如果在Internet网上,会经常发生读取socket时也会有写入socket的动作,所以在Java网络编程中,你需要两个线程来处理socket对象:一个用来读取socket对象,另外一个用来向socket对象写数据。
小结
一个Java程序可以包含多个线程,所以这些不毕显示让开发者知道。但是,现在你必须知道,当我们书写一个Java应用时,有一个最初的线程(主线程)是由main()方法开始的。当我们书写一个Java applet小程序时,有一个线程执行回调方法(init(), actionPerformed()等)来执行applet小程序。当我们执行有阻塞的I/O操作时,我们可使用多线程编程;当我们在初始线程的基础上作并发动作时,我们可以使用多线程编程。
线程API
无线程程序运行状态
线程使用线程类,我们说把任务分开可以并行运算。一般这些任务是由线程执行的简单代码,并且这些简单的代码是我们程序的一个组成部分。这些简单代码一般是从服务器下载图片、声音文件、视频等任务。因为这些是代码,所以当然也可以被源线程(主线程)执行。单线程和多线程的运行原理如下:
- Graphical representation of nonthreaded method execution--非线程方向执行图示
- Applet executing run() method--Applet小程序执行run方法
- Applet executing init() method--Applet小程序执行init方法
- Applet thread doing other tasks--Applet小程序线程执行其他的事情
Applet小程序
在《Applet Reference》一文中这样介绍:
我来参考翻译一下:
Applet类一个让Java程序在web网页中运行的框架。
我们只要把写好的Java应用程序放到这个框架中,那么我们Java应用程序就可以像HTML5、CSS3和JavaScript脚本程序一样可以在Web网页中运行了。中国最早一批的MMORPG页游《倾城》就是成都汉森公司使用纯Java书写的游戏。
它的客户端就是使用Applet小程序来实现的,当然后端也是使用Java来实现的。
关于Java开发游戏,我们会在后面慢慢解读Java的游戏开发实现技术,请喜欢游戏开发的小伙伴耐心等待哦。
有线程程序运行状态
- Applet executing run() method--Applet小程序执行run方法
- Applet executing internal thread tasks--Applet小程序执行内部线程事情
- Applet executing start() method--Applet小程序执行start方法
- Applet executing init() method--Applet小程序执行init方法
- Applet thread doing other tasks--Applet小程序线程执行其他的事情
对比两张图,我们可以简单地理解为线程把原来的Applet的run方法切片使用了,它使用run的空转时间变少了,从而提高了计算机CPU的使用效率。
Thread类
- Thread()构造方法:构造一个线程对象,使用默认值来构造。
- void run()方法:该方法由被新创建的线程对象执行。开发者必须重写该方法,以便让这个方法中的代码被新的线程对象所运行;
- void start()方法:该方法具体创建一个新的线程对象,然后执行在该线程类定义的run()方法。
参见上图多线程运行结构,我们可发现创建一个子线程对象由两个步骤。首先,我们创建在重写Thread类的run方法体中的内容,然后我们使用Thread()构造方法来创建一个子线程对象,最后我们呼叫子线程的start()方法来执行run()方法。
run()和main()方法的比较:
- 从本质上讲,run()方法可以被认为是一个新线程对象的main()方法:每个新的线程对象从run()方法开始执行,就像普通类对象是从main()方法开始执行的一样。
- 但是main()方法可以从argv参数接收实参(一般是指从命令接收参数), 而新线程对象的只能从源线程(主线程)通过编程取得参数。因此,新线程的参数可以通过构造方法、静态实例变量或者其它的技术来传送。
演示无多线程代码
import java.applet.*;
import java.awt.*;
public class AnimateFirst extends Applet {
int count, lastcount;
Image pictures[];
TimerThread timer;
public void init() {
lastcount = 10; count = 0;
pictures = new Image[10];
MediaTracker tracker = new MediaTracker(this);
for (int a = 0; a < lastcount; a++) {
pictures[a] = getImage (
getCodeBase(), new Integer(a).toString()+".jpeg");
tracker.addImage(pictures[a], 0);
}
tracker.checkAll(true);
}
public void start() {
timer = new TimerThread(this, 1000);
timer.start();
}
public void stop() {
timer.shouldRun = false;
timer = null;
}
public void paint(Graphics g) {
g.drawImage(pictures[count++], 0, 0, null);
if (count == lastcount) count = 0;
}
}
演示多线程代码一
public class TimerThread extends Thread {
Component comp; // Component that need repainting
int timediff; // Time between repaints of the component
boolean shouldRun; // Set to false to stop thread
public TimerThread(Component comp, int timediff) {
this.comp = comp;
this.timediff = timediff;
shouldRun = true;
setName("TimerThread(" + timediff + " milliseconds)");
}
public void run() {
while (shouldRun) {
try {
comp.repaint();
sleep(timediff);
} catch (Exception e) {}
}
}
}
演示多线程代码二
public class OurRunnableClass implements Runnable {
public void run() {
for (int I = 0; I < 100; I++) {
System.out.println("Hello, from another thread");
}
}
}
import java.applet.Applet;
public class OurRunnableApplet extends Applet {
public void init() {
Runnable ot = new OurRunnableClass();
Thread th = new Thread(ot);
th.start();
}
}
不管哪种方式,最终都是Thread的对象才能变成多线程代码!
Applet动画
当我们在一个Web网页中显示一个动画时,一般我们都使用一组连续的图片(帧)来实现,比如JavaScript中使用setTimeout()方法来实现。但是,在Java中我们使用两个线程来实现:因为在Java中没有异步信号机制,所以你必须使用多个线程来做,让一个线程睡觉一会儿,然后告诉applet再画下一个窗体。
完整代码实现
import java.applet.*;
import java.awt.*;
public class Animate extends Applet implements Runnable {
int count, lastcount;
Image pictures[];
Thread timer;
public void init() {
lastcount = 10; count = 0;
pictures = new Image[10];
MediaTracker tracker = new MediaTracker(this);
for (int a = 0; a < lastcount; a++) {
pictures[a] = getImage (
getCodeBase(), new Integer(a).toString()+".j
tracker.addImage(pictures[a], 0);
}
tracker.checkAll(true);
}
public void start() {
if (timer == null) {
timer = new Thread(this);
timer.start();
}
}
public void paint(Graphics g) {
g.drawImage(pictures[count++], 0, 0, null);
if (count == lastcount) count = 0;
}
public void run() {
while (isActive()) {
try {
repaint();
Thread.sleep(1000);
} catch (Exception e) {}
}
timer = null;
}
}
运行效果
小结
区别Applet类的start()方法和stop()方法与Thread类的start()方法和stop()方法的数字签名是一样的。两个类的方法实现的目的完成不一样,并且没有直接关系!一个让浏览器呼叫,一个是让主线程呼叫。
当我们需要实现一个复杂业务逻辑的子线程对象时,如果继承Thread类,那么实现代码是比较“昂贵”的。为解决这个问题,我们使用接口来解决它—Runnable接口。这样,当我们在实际中可以方便把一颗树变量一个子线程来使用。
使用isActive()方法是用来替换线程的stop方法的使用,也就是说,我们可以使用另外一种技巧来停止子线程对象的运行。这样做的好处是,允许run()方法的执行被按照一般的方式被终止,而不是只能通过中介方法stop来终止运行!。
isActive()方法是Applet类的方法,它用来判断applet对象是否活着。不要与Thread类的isAlive()方法混淆了!
总结一下,使用TimerThread类比较容易理解和调试,它与Runnable本质上没有什么区别,怎样使用它们,需要根据开发人员的偏好来决定。
线程的生命周期
我们知道子线程的是通过start()方法开始的,而它怎样结束是需要在run()方法编程来决定的。如果在呼叫了start()方法之后,我们可以使用isAlive()方法来判断子线程是否真正被JVM启动了。当一个线程从run()方法返回之后,JVM清理该线程会需要一些时间,所以如果你使用stop()方法时,会占用JVM一些时间来清理。它的生命周期如下图:
- state/status--线程状态
- running--运行期间
- not running--非运行期间
- period during the start() method--执行start方法的期间
- period during the stop() method--执行stop方法的期间
因为一个线程被启动之后,并不意味着它实际上就等于进入运行状态,或者说让一个可运行的线程进入阻塞状态来等待I/O,因为它可能还在执行start()方法的过渡期。所以,我们会使用isAlive()方法来检查一个线程是否已经停止了运行。特别是当有两个线程访问一个共享的数据时,我们必须使用这个方法来确保另外一个线程已经停止了,然后才能操作数据—解决方法很简单,在执行具体处理以前,我们循环检查该线程是否已经还活着。最后一个使用isAlive()方法的原因是,如果呼叫了stop()方法,该线程会被JVM (或者OS)短暂的认为它不是活着的!这时,该方法可以判定run()方法是否已经结束,否则通常情况下会接着呼叫stop()方法的。
Joining Threads
方法isAlive()可以被认为一种粗糙的线程通信方式。我们可能使用join方法来确保一个线程的执行时间。Thread类提供了三个join()方法:
- void join()方法:表示等待指定线程完全执行结束。join()方法返回的线程会立即被认为“not alive”了。
- void join(long timeout)方法:指定的毫秒等待数
- void join(long timeout, int nanos)方法:指定等待精度为纳秒单位。
方法isAlive()和join()都不会实际影响线程对象,isAlive()方法只是返回指定线程的状态而已;而join()方法只等待指定的时间而已。
线程结束有两种方式:第一种是run()方法执行结束;第二种是呼叫stop()方法(已经过时,因为它需要与isAlive()方法配置使用,加重开发人员的负担)或者join()方法(建议使用的结束方法!)。
示义代码
import java.applet.*;
import java.awt.*;
public class Animate extends Applet implements Runnable {
int count, lastcount;
Image pictures[];
Thread timer;
public void init() {
lastcount = 10; count = 0;
pictures = new Image[10];
MediaTracker tracker = new MediaTracker(this);
for (int a = 0; a < lastcount; a++) {
pictures[a] = getImage (
getCodeBase(), new Integer(a).toString()+".j
tracker.addImage(pictures[a], 0);
}
tracker.checkAll(true);
}
public void start() {
if (timer == null) {
timer = new Thread(this);
timer.start();
}
}
public void paint(Graphics g) {
g.drawImage(pictures[count++], 0, 0, null);
if (count == lastcount) count = 0;
}
public void run() {
while (isActive()) {
try {
repaint();
Thread.sleep(1000);
} catch (Exception e) {}
}
timer = null;
}
//...这里是一个示义代码,表示这这个线程中有另外一个线程对象
public void stop() {
timerThread.shouldRun = false;
try {
timerThread.join();
} catch (InterruptedException e) {}
}
}
public class TimerThread extends Thread {
Component comp; // Component that need repainting
int timediff; // Time between repaints of the component
boolean shouldRun; // Set to false to stop thread
public TimerThread(Component comp, int timediff) {
this.comp = comp;
this.timediff = timediff;
shouldRun = true;
setName("TimerThread(" + timediff + " milliseconds)");
}
public void run() {
while (shouldRun) {
try {
comp.repaint();
sleep(timediff);
} catch (Exception e) {}
}
}
}
线程同步技术
前面介绍的线程是不关系共享数据的类型,下面我们看一下两个线程共享数据的情况。线程之间共享数据就是众所周知的”race condition”(竞争)情况,共享数据的线程都想更多的得到资源,会产生线程同时访问资源的状况。这种状况可以使用存钱的例子来描述:
- Algorithm flow chart for ATM withdrawal--ATM机取钱算法流程图
- Enough cash? --有现金吗?
- Deduct Amount--下账
- Dispense Cash--吐钞
- Print Receipt--打印收据
如果出现两个人同时访问相同的帐号(比如联合帐号),比如,可能出现丈夫和妻子取空相同帐号上的钱的情况,并且取钱的时间是相同的。这时就会出现race condition的情况,原因是因为帐号状态不是“原子性的”(atomic)造成。
原子性定义:
术语atomic(原子性)是相对于原子(atom)而言的,可以被认为是最小的事物单元,它不可以再分被切割了。当我们把取款流程看成一个原子性的东西,那么它在执行时就不能被中断!这种原子性的需求一般被硬件或者被软件中的同步完成。(可以理解为数据库的事务—本人注)
互斥锁
互斥锁(mutually exclusive lock)是解决race condition的方案。这种锁是由线程系统提供的,以实现线程的同步结果。一般来说,使用了该锁之后,只有一个线程对象可以抓到互斥的时间:如果两个线程抓互斥时间,只有一个线程可以成功!而另外一个线程只能等待第一个线程释放互斥锁,如果它能抓住锁,那么才可能继续操作。
在Java中,系统为每个被创建的对象都创建了一把锁,当一个方法被声明为“synchronized”时,那么运行的线程必须抓住该对象的互斥锁,才能继续运行。当运行完同步的方法之后,系统为自动释放互斥锁。
异步读取数据
使用线程最主要的原因是在Java程序中可以实现异步地读取数据。主要表现在从网络socket对象中读文件时需要这样做。当然从网络中读取数据还需要其它资源,这些资源可以是硬件,比如硬盘或者网络;也可以操作系统或者浏览器,也可能是其它程序等。从网络socket对象读大数据时会等待比较长的时间,而Java API中没有现存的异步读取机制,所以我们需要使用多线程来解决这个问题。
演示代码
import java.io.*;
import java.net.*;
public class AsyncReadSocket extends Thread {
private Socket s;
private StringBuffer result;
public AsyncReadSocket(Socket s) {
this.s = s;
result = new StringBuffer();
}
public void run() {
DataInputStream is = null;
try {
is = new DataInputStream(s.getInputStream());
} catch (Exception e) {}
while (true) {
try {
char c = is.readChar();
appendResult(c);
} catch (Exception e) {}
}
}
// Get the string already read from the socket so far.
// This method is used by the Applet thread to obtain the data
// in a synchronous manner.
public synchronized String getResult() {
String retval = result.toString();
result = new StringBuffer();
return retval;
}
// Put new data into the buffer to be returned
// by the getResult method
public synchronized void appendResult(char c) {
result.append(c);
}
}
运行效果
总结
我们Java应用程序代码是一种字节码,它是运行在Java虚拟机进程中。
所以,Java多线程到底运行效率多高,只能依赖于Java虚拟机进程来支撑了。
因此,Java的多线程本质与C/C++的多线程是有不同的,我们对于Java多线程的理解只能通过专业书籍的描述来参考理解。不能直接使用C/C++的多线程概念来理解它。所以,这也是为什么我们在一开篇就说了:“Java多线程问题,我们认为它是一个玄学问题”。
作为一个Java程序员,真有想过:Java的多线程到底是什么东西吗?有标准的、专业的解释吗?而这些东西的理解对我们在实践中调试JVM时候是有帮助的。
我们给出这篇文章,希望能够帮助大家真的能够快速理解Java的多线程技术,而不只是乱看网上一堆的东西,或者为了面试道听途说乱七八糟的东西。
最后
如果大家觉得有用,请记得给大黍❤️关注+点赞+收藏+评论+转发❤️
作者:老九学堂—技术大黍
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。