目录
进程与线程
当你运行一个程序,系统就在内存中创建了一个进程(process)。显然程序只是一组指令与数据的静态实体,不运行它的话是没有任何意义的;而进程则是一个动态的实体,它负责将程序所具备的功能展现出来。
一般来说一个程序会具备多个功能,每一个具体的功能就可以理解为线程(Thread),因此一个进程至少需要一个线程。线程是进程的子集,不能够独立执行,必须依存在于进程中。
打个比方,当你运行QQ软件的时候,系统就创建了一个名为QQ的进程。此时你想要一边群语音,一边和好友打字聊天,那么QQ进程就会创建语音聊天和打字聊天两个线程(当然这只是一个比喻,真正QQ运行起来要复杂的多)。
简而言之,运行一个程序至少有一个进程,一个进程至少有一个线程。
并行与并发
并发指的是能够做多件事情,并行指的是可以同时做多件事情。换而言之,并行是并发的子集。
从上面的描述中,我们可以知道一个并发程序中应该至少存在两个线程,否则不应该称之为并发。如果程序在单核CPU上运行,那么这些线程将会不停的交替执行;如果程序能够并行执行,那么就一定是运行在多核CPU上(此时程序中的每个线程会分配到一个独立的CPU核上,因此可以同时运行)。
也就是说一个多线程的并发程序,如果没有多核CPU来执行,那么就不能达到并行的效果。简单来说就是
- 并发(单核CPU):交替做多件事情
- 并行(多核CPU):同时做多件事情
JVM的多线程
在弄清楚前面的概念之后,我们来看这样一个问题:JVM是进程还是线程?答案很明显——JVM是进程,因为Java中存在垃圾回收(Garbage Collection)机制。
当一个对象没有引用指向它时,这个对象会成为垃圾。随着垃圾的不断增加,我们的程序还没有结束运行,此时就会触发GC。即在JVM的主线程(运行main()方法产生的线程,也叫main线程)之外,再创建一个GC线程。
代码演示如下:
public class TestGCAndMain {
public static void main(String[] args) {
for (int i = 0; i < 1000000; i++) { // 由于垃圾不会马上回收,所以制造足够多的垃圾。
new Garbage();
}
for (int i = 0; i < 1000; i++) {
System.out.println("now in main");
}
}
}
class Garbage {
@Override
protected void finalize() {
System.out.println("collecting garbage");
}
}
运行程序就可以在控制台中看到“now in main”和“collecting garbage”两种语句在控制台中交替打印。
部分打印结果截图:
实现多线程的两种方式
Java提供两种方式实现多线程,一种是继承Thread类,另一种是实现Runnable接口。
继承Tread类
一个类继承Thread类之后,需要重写该类的run()方法。run()方法中具体的代码逻辑,就是新创建线程的具体功能——当然你也可以不重写run()方法,这就意味着新线程什么也没做。我们只需要创建Thread子类的实例对象并调用它的start()方法,就可以创建一个新的线程。
注:调用run()方法不会开启一个新的线程,只是单纯的调用该方法,调用start()方法才会创建线程。
见下面一个例子:
public class MultiThreadingByThread extends Thread {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
System.out.println("now in MultiThreadByThread");
}
}
public static void main(String[] args) {
MultiThreadingByThread thread = new MultiThreadingByThread();
// thread.run(); // // 调用run()方法不会开启一个新线程,只是单纯的调用该方法
thread.start();
for (int i = 0; i < 1000; i++) {
System.out.println("now in main");
}
}
}
部分打印结果截图:
实现Runnable接口
Runnable接口中只有一个run()方法,因此一个类实现Runnable接口之后必须重写run()方法。
public interface Runnable {
public abstract void run();
}
Thread类有一个重载的构造方法,该方法将Runnable接口作为参数。我们将Runnable实现类的实例对象作为Thread类构造方法的参数传入,再调用Thread类实例的start()方法,就能创建一个新线程。
见下面一个例子:
public class MultiThreadingByRunnable implements Runnable {
@Override
public void run() {
for(int i = 0; i < 1000; i++) {
System.out.println("now in MultiThreadingByRunnable");
}
}
public static void main(String[] args) {
MultiThreadingByRunnable runnable = new MultiThreadingByRunnable();
Thread thread = new Thread(runnable);
thread.start();
for (int i = 0; i < 1000; i++) {
System.out.println("now in main");
}
}
}
部分打印结果截图:
不难发现,即使使用实现Runnable接口的方式实现多线程,依然需要借助于Thread才能创建另一个线程。而且这个线程做的事情,也是重写的run()方法中的代码逻辑。下面我们查看Thread的源码,看看具体实现是怎样的。
public class Thread implements Runnable {
private Runnable target;public Thread(Runnable target) {
init(null, target, "Thread-" + nextThreadNum(), 0);
}@Override
public void run() {
if (target != null) {
target.run();
}
}private void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc,
boolean inheritThreadLocals) {
...
this.target = target;
...
}
...
}
上面是Thread类中的部分代码。可以看到Runnable实现类的实例对象作为参数传递给Thread类的构造方法之后,该对象会被赋值给Thread类的成员变量target。
当我们调用start()方法时,该线程要做事情就是run()方法的代码逻辑,而此时target不为null,那么真正调用是target的run()方法,也就是Runnable实现类重写的run()方法。
匿名内部类实现多线程
通过前面的两个例子可以发现,不论是继承Thread类还是实现Runnable接口,我们仅仅是重写了run()方法,这种情况下使用匿名内部类会更简洁一些:
public class TestMultiThreadingByAnonymous {
public static void main(String[] args) {
// 匿名Thread子类
new Thread() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
System.out.println("now in thread-1");
}
};
}.start();
// 匿名类实现Runnable接口
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
System.out.println("now in thread-2");
}
}
}).start();
}
}
部分打印结果截图:
线程操作
创建一个线程很简单,难的是线程创建完毕后如何去控制它。
自定义线程名
每个人都有名字,每一个线程也有自己的名字。
public class TestThreadName {
public static void main(String[] args) {
new Thread(){
@Override
public void run() {
System.out.println(this.getName());
}
}.start();
}
}
打印结果如下:
Thread-0
可以看到我们创建的这个线程的名字是“Thread-0”。我们创建的线程默认名字是“Thread-”加上一个数字(从0开始,依次递增),当然线程的名字是可以自定义的。
自定义线程名有两种做法:一种是创建Thread对象时,直接传入线程名字符串作为该线程的名字;另一种是调用setName()方法修改线程的名字。
public class ChangeThreadName {
public static void main(String[] args) {
// 创建Thread实例对象时传入线程名
new Thread("线程A") {
@Override
public void run() {
System.out.println(this.getName());
}
}.start();
// 通过setName()方法修改线程名
Thread thread = new Thread() {
@Override
public void run() {
System.out.println(this.getName());
}
};
thread.setName("线程B");
thread.start();
}
}
打印结果如下:
线程A
线程B
在上面的例子中,我们可以通过this.getName()来获取当前线程的名字,因为这里是通过Thread的子类实例对象创建线程,所以可以通过this调用Thread中的方法。那么如果是通过实现Runnable接口创建线程,还能通过this获取线程的名字吗?
答案肯定是不行的。因为this代表当前对象的引用,通过实现Runnable接口创建线程,this指向的是Runnable实现类的实例对象,所以无法通过this调用setName()方法。这里我们需要用到Thread类提供静态方法currentThread(),该方法会返回当前正在运行的线程。
public class ChangeThreadNameByCurrentThread {
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
// 通过Thread.currentThread()获取当前正在运行的线程
System.out.println(Thread.currentThread().getName());
}
}, "线程A").start();
// currentThread()方法调用时,位于哪个线程中,返回的就是哪个线程
System.out.println(Thread.currentThread().getName());
}
}
打印结果如下:
main
线程A
休眠线程
Thread类的静态方法sleep()可以让线程暂时停止运行一段时间(单位毫秒)。和currentThread()方法一样,在哪个线程中调用该方法就会休眠哪个线程。调用该方法会抛出InterruptedException异常。
public class TestSleepThread {
public static void main(String[] args) throws InterruptedException {
for (int i = 1; i <= 5; i++) {
Thread.sleep(1000); // 休眠一秒
System.out.println("已经运行" + i + "秒");
}
}
}
打印结果如下:
已经运行1秒
已经运行2秒
已经运行3秒
已经运行4秒
已经运行5秒
需要注意的是在自己创建的线程中调用sleep()方法时,不能抛出InterruptedException异常,必须try...catch。因为父类 / 接口中的run()方法没有抛出异常,则重写的run()方法中也不能抛出异常(具体可以参考 Java基础(六) 异常)。
public class TestThreadException {
public static void main(String[] args) {
new Thread() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
// 这里必须使用try...catch捕获异常
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}.start();
}
}
守护线程
Java中线程可以分为两类:用户线程(普通线程)、守护线程(后台线程)。
正如其名,守护线程的作用就是服务用户线程。如果已经没有任何一个用户线程还在运行,那么守护线程也就没有可以服务的对象,就会和JVM一起结束运行。守护线程最典型的应用就是GC。
创建守护线程很简单,只要将需要设置为守护线程的线程调用setDaemon(true)即可。需要注意的是,setDaemon()方法必须在start()方法之前调用,因为无法将一个正在运行的线程设置为守护线程。如果你尝试将一个正在运行的线程设置为守护线程,将会抛出IllegalThreadStateException异常。
下面通过一个例子演示:
public class TestDaemonThread {
public static void main(String[] args) {
// 守护线程
Thread daemonThread = new Thread() {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println(this.getName() + ":守护线程");
}
}
};
// 用户线程
new Thread() {
@Override
public void run() {
for (int i = 0; i < 3; i++) {
System.out.println(this.getName() + ":用户线程");
}
}
}.start();
// setDaemon()方法必须设置在start()之前
daemonThread.setDaemon(true);
daemonThread.start();
}
}
部分打印结果截图(每次的执行结果会有略微不同):
当用户线程的三条语句打印完成之后,守护线程也会马上结束语句打印。但是守护线程并不是马上结束语句打印,而是接着打印不止一条语句。这是因为当所有的用户线程结束运行,守护线程会先收到结束运行的通知再结束运行,在这个期间内足够守护线程干好些事情了,比如打印几条语句。
比较典型的是使用QQ和好友聊天,如果突然退出QQ,聊天窗口会间隔几秒钟才关闭,这是因为聊天窗口就是一个守护线程。
加入线程
事有轻重缓急,线程也是如此。有的线程要做的事情比较重要,那么该线程就需要“插队”优先执行。
这里需要用到的是join()方法。当一个线程调用join()方法加入到另一个线程时,另一个线程会等待这个加入的线程执行完毕,然后再继续它的工作。
join()还有一个重载的方法,该方法接受一个毫秒值作为参数,这表示被加入的线程最多等待加入线程执行一段时间,如果在这段时间内加入的线程没有执行完毕,那么被加入的线程不会再继续等待加入线程,而是和加入线程争夺CPU的执行权。
public class TestJoinThread {
public static void main(String[] args) {
Thread thread = new Thread() {
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(this.getName() + ": 插队");
}
}
};
new Thread() {
@Override
public void run() {
for (int i = 0; i < 20; i++) {
if (3 == i) {
try {
// 暂停当前线程, 等待该线程至执行结束
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(this.getName() + ": 正在执行中");
}
}
}.start();
thread.start();
}
}
部分打印结果截图(每次的执行结果会有略微不同):
通过打印结果可以看到,线程Thread-0加入线程Thread-1之后,就获得了“插队”的特权。
需要注意的是,如果程序中还存在第三条线程,那么第三条线程并不会停下来等待线程加入线程执行完毕,而是和加入线程争夺CPU的执行权。也就是说,加入线程的“插队”特权是相对于被加入线程而言的。
线程同步
对于前面给出的例子,多个线程执行的任务之间没有什么关联,因此看不出有什么问题。但是假如两个线程同时操作同一个资源或者数据,而这些操作中既有读操作又有写操作,那么就会导致资源或数据的状态出现混乱。
为了规避这种情况,我们需要用到线程同步。根据同步内容颗粒度大小不同,线程同步分为同步代码块和同步方法。
同步代码块
看这样一个例子。创建两个线程,一个负责读取文件中的数据,另一个负责向文件写入数据,运行程序会怎么样呢?
public class FileAction {
public void read() {
System.out.print("读");
System.out.print("取");
System.out.print("文");
System.out.print("件");
System.out.print("\n");
}
public void write() {
System.out.print("写");
System.out.print("入");
System.out.print("数");
System.out.print("据");
System.out.print("\n");
}
public static void main(String[] args) {
FileAction p = new FileAction();
// 线程一:读取文件
new Thread() {
@Override
public void run() {
while(true) {
p.read();
}
}
}.start();
// 线程二:写入数据
new Thread() {
@Override
public void run() {
while(true) {
p.write();
}
}
}.start();
}
}
在打印结果中,我们会看到类似下面的奇怪的打印信息:
出现这样的结果正是由于线程不同步引起的。当线程一正在读取文件,CPU就轮换到线程二向文件写入数据,线程二写入数据还没结束,CPU又切换回线程一,这样就导致读取到的数据不正确。
互斥
那么什么情况下需要同步线程呢?相信看到这儿你已经很清楚了,答案是互斥。
如果多个线程共享数据或资源,那么它们对共享数据或资源的操作应该是互斥的,也就是说一个线程在对数据或资源进行操作时,其他的线程需要等待该线程执行完毕,然后等待CPU轮换执行。反之,如果两个线程不处理任何共享数据或资源,那么它们不需要以互斥的方式执行。
在前面的例子中,两个线程只有一方独自完成任务,才能保证正确的打印结果。换而言之,两个线程的特定代码块(读写文件)是互斥的。但是如果还存在第三个线程,它的任务是数据计算,那么该线程和前两个线程要做的事情就不是互斥的。即使线程一执行任务的时候被线程三打断,也不会影响到线程一读取文件的结果。
锁与synchronized
Java以锁的形式实现线程同步。如果多个线程中的代码片段使用同一把锁,那么在某个线程加锁的代码片段执行期间,其他线程在这期间会进入挂起状态。也就是说不同线程中使用同一把锁的代码片段是互斥的。
给代码片段加锁需要用到关键字synchronized(同步),使用该关键字需要将任意一个对象作为锁。所以锁也叫对象锁,而加锁的代码片段称之为同步代码块。
下面通过一个例子演示:
public class FileActionWithSyncCode {
private Object lock = new Object();
public void read() {
synchronized(lock) { // 加锁
System.out.print("读");
System.out.print("取");
System.out.print("文");
System.out.print("件");
System.out.print("\n");
}
}
public void write() {
synchronized (lock) { // 加锁
System.out.print("写");
System.out.print("入");
System.out.print("数");
System.out.print("据");
System.out.print("\n");
}
}
public static void main(String[] args) {
FileActionWithSyncCode p = new FileActionWithSyncCode();
// 线程一:调用read()方法
new Thread() {
@Override
public void run() {
while(true) {
p.read();
}
}
}.start();
// 线程二:调用write()方法
new Thread() {
@Override
public void run() {
while(true) {
p.write();
}
}
}.start();
}
}
将互斥的代码片段设置为同步代码块之后,运行程序就不会出现奇怪的打印结果了。
部分打印结果截图:
同步方法
上面的例子中,方法read()和write()中的所有代码都需要设置成同步代码块,那么我们就可以直接将方法read()和write()设置成同步方法。
相较于同步代码块,同步方法的设置简单很多,只需在方法签名上加入synchronized关键字,该方法就是同步方法了。下面将write()方法设置为同步方法:
public class FileActionWithSyncMethod {
private Object lock = new Object();
public void read() {
synchronized(lock) { // 同步代码块
System.out.print("读");
System.out.print("取");
System.out.print("文");
System.out.print("件");
System.out.print("\n");
}
}
public synchronized void write() { // 同步方法
System.out.print("写");
System.out.print("入");
System.out.print("数");
System.out.print("据");
System.out.print("\n");
}
public static void main(String[] args) {
FileActionWithSyncMethod p = new FileActionWithSyncMethod();
// 线程一:调用read()方法
new Thread() {
@Override
public void run() {
while(true) {
p.read();
}
}
}.start();
// 线程二:调用write()方法
new Thread() {
@Override
public void run() {
while(true) {
p.write();
}
}
}.start();
}
}
部分打印结果截图(每次的执行结果会有略微不同):
可以看到打印结果中还是存在奇怪的打印信息,这是怎么回事呢?原因很简单——同步方法和同步代码块使用的锁不是同一个。read()方法中同步代码块使用成员变量lock作为锁,但是我们并没有为同步方法设置锁。同步方法使用的锁又是什么呢?
显然我们无法显式的为同步方法加锁,那么程序应该隐式的将某个对象作为同步方法的锁。我们知道要实现方法的互斥,那么两个方法就必须使用同一把锁,也就是说这把锁需要具备唯一性。
在一个类中,有什么东西可以具备唯一性呢?答案是该类的实例对象本身,即this。当该类的实例对象被创建出来,此实例对象就会被默认作为同步方法的锁。
我们将FileActionWithSyncMethod类中read()方法的锁修改为this:
public void read() {
synchronized(this) {
System.out.print("读");
System.out.print("取");
System.out.print("文");
System.out.print("件");
System.out.print("\n");
}
}
运行程序,打印结果正常。
这样一来又会出现新的问题——静态方法会随着类的加载而加载,它无法将实例对象作为锁。那么一个类加载的时候什么东西是唯一的呢?没错,就是就是这个类的Class对象。
将FileActionWithSyncMethod类中的read()方法的锁修改为FileActionWithSyncMethod.class,write()方法的修改为静态方法。
public void read() {
synchronized(FileActionWithSyncMethod.class) {
System.out.print("读");
System.out.print("取");
System.out.print("文");
System.out.print("件");
System.out.print("\n");
}
}
public synchronized static void write() {
System.out.print("写");
System.out.print("入");
System.out.print("数");
System.out.print("据");
System.out.print("\n");
}
运行程序,打印结果正确。
总结:
- 非静态同步方法使用的锁是方法所在类的实例对象
- 静态同步方法使用的锁是方法所在类的Class对象
实战——模拟卖票
相信大家应该都见过卖票窗口,在网络还不发达的年代,窗口前的队伍长龙一度是春运的标志。扯远了,回归正题。
说到卖票,一般有三个特点:一、一天的车票总量是一定的;二、一张票只能卖出一次;三、一个窗口卖票的同时,其余窗口必须等待该窗口卖票完成,才能继续卖票。
假设一共有4个售票窗口,100张票。你可以先试着自己编写程序实现该功能。
下面分别用两种方法实现。继承Thread方式实现:
public class TicketSellerByThread extends Thread {
private static int num = 100;
public TicketSellerByThread(String name) {
super(name);
}
@Override
public void run() {
while (true) {
synchronized (TicketSellerByThread.class) {
if (num != 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()
+ ": 卖出1张,第" + --num + "张票");
}
}
}
}
public static void main(String[] args) {
new TicketSellerByThread("窗口1").start();
new TicketSellerByThread("窗口2").start();
new TicketSellerByThread("窗口3").start();
new TicketSellerByThread("窗口4").start();
}
}
实现Runnable接口实现:
public class TicketSellerByRunnable implements Runnable {
private int num = 100;
@Override
public void run() {
while (true) {
synchronized (this) {
if (num != 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()
+ ": 卖出1张,剩余" + --num + "张票");
}
}
}
}
public static void main(String[] args) {
TicketSellerByRunnable ticketSeller = new TicketSellerByRunnable();
new Thread(ticketSeller, "窗口1").start();
new Thread(ticketSeller, "窗口2").start();
new Thread(ticketSeller, "窗口3").start();
new Thread(ticketSeller, "窗口4").start();
}
}
部分打印结果截图(每次的执行结果会有略微不同):
死锁
有这样一个故事:有一群哲学家聚在一起吃饭,菜上齐后准备开动时,他们发现每个人面前只有一只筷子。一只筷子没法夹菜吃饭,但是介于身份的特殊性,他们又做不出手抓饭菜的举动。于是每个人都想说服别人把筷子给自己,结果谁也说服不了别人,于是这群哲学家全部都饿死了。
这个问题描述的情形就是死锁,它是由于同步代码块嵌套而引发的一种状况。看下面一个例子:
public class TestDeadLock {
private static final String chopsticks1 = "筷子1";
private static final String chopsticks2 = "筷子2";
public static void main(String[] args) {
// 线程一
new Thread() {
@Override
public void run() {
while(true) {
synchronized (chopsticks1) {
System.out.println(this.getName()
+ ": 得到筷子1, 等待筷子2");
synchronized (chopsticks2) {
System.out.println(this.getName()
+ ": 得到筷子2,吃饭");
}
}
}
}
}.start();
// 线程二
new Thread() {
@Override
public void run() {
while(true) {
synchronized (chopsticks2) {
System.out.println(this.getName()
+ ": 得到筷子2, 等待筷子1");
synchronized (chopsticks1) {
System.out.println(this.getName()
+ ": 得到筷子1,吃饭");
}
}
}
}
}.start();
}
}
部分打印结果截图(每次的执行结果会有略微不同):
上面的打印结果,程序打印完最后一行语句就不会再继续打印语句了,因为此时已经进入了死锁状态。程序中的死锁具体是怎么产生的呢?下面我们分析死锁产生的过程。
当线程一获得锁chopsticks1的锁定时,线程二也获得了锁chopsticks2的锁定。线程一执行完打印语句后,内层同步代码块想要获得锁chopsticks2的锁定,但是此时锁chopsticks2在线程二手中;另一边线程二执行完打印语句,内层同步代码块想要获得锁chopsticks1的锁定,但是锁chopsticks1在线程一手中。两条线程都因为无法获取需要的锁而进入了挂起状态,死锁由此诞生。
所以在多线程程序中要尽量避免同步代码块的嵌套。
线程安全类
在之前的文章中谈到过线程安全(具体可以参考 Java基础(5) 集合):
ArrayList和Vector的底层实现是数组,而LinkedList的底层实现是链表。
所以相对而言,ArrayList和Vector的查找和修改比较快。但Vector出现时间比较早,它的线程是安全的安全,效率相对低一点,目前几乎不用;而ArrayList的线程不安全,所以效率相对快一点。
这里出现的线程安全是什么意思呢?我们分别查看ArrayList和Vector类中的add()方法:
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
...
}public class Vector<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
...
}
通过比较就很容易看出来,二者在功能实现上几乎相同,Vector类所谓的线程安全指的就是使用了同步方法。与之同样的还有Hashtable和HashMap:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
...
}public class Hashtable<K,V> extends Dictionary<K,V> implements Map<K,V>, Cloneable, java.io.Serializable {
public synchronized V put(K key, V value) {
...
}
...
}
然而大多数时候我们是不需要线程安全的,如果有需要,我们可以调用Collections工具类提供方法synchronizedCollection(),通过该方法可以将线程不安全的集合对象变成线程安全的。所以Vector和Hashtable就逐渐没落了。
参考:
一文秒懂 Java 守护线程 ( Daemon Thread )