多线程系列(-)---多线程基础知识篇

本片博客是读马老师的《Java编程的逻辑》第十五章多线程的读书笔记,是对Java多线程基础知识的总结。马老师GitHub地址:https://github.com/swiftma/program-logic

创建线程的方法

线程表示一条单独的执行流,它有自己的程序计数器,有自己的栈。在Java中创建线程有两种方式:一种是继承Thread;另一种是实现Runnable接口。

继承Thread

Java中java.lang.Thread这个类表示线程,一个类可以继承Thread类,并重写run()方法来实现一个线程,如下所示:

1
2
3
4
5
6
public class HelloThread extends Thread{
@Override
public void run(){
System.out.println("hello");
}
}

定义了这个类不代表代码会开始执行,线程需要被启动,启动需要先创建一个HelloThread实例对象,然后调用Thread的start方法,如下:

1
2
3
4
public static void main(String[] args){
Thread thread = new HelloThread();
thread.start();
}

为什么调用的start方法,执行的却是run方法?其实,start表示启动该线程,使其成为一条单独的执行流,操作系统会分配线程相关的资源,操作系统会把这个线程作为一个独立的个体进行调度,分配时间片让它执行,执行的起点就是run方法。

那么怎么确定它是在HelloThread线程中执行的而不是在main主线程中执行的呢?Thread有一个静态方法currentThread,返回当前执行的线程对象:

public static native Thread currentThread()

另外,每个Thread都有一个id和name:

public long getId()

public final String getName()

这样,我们就可以判断代码是在哪个线程中执行的。修改HelloThread的run方法:

1
2
3
4
5
@Override
public void run(){
System.out.println("thread name:"+Thread.currentThread().getName());
System.out.println("hello");
}

如果在main方法中通过start方法启动线程,程序输出为:

1
2
thread name: Thread-0
hello

如果在main方法中直接调用run方法,程序输出为:

1
2
thread name: main
hello

调用start后,就有两条执行流,新的一条执行run方法,旧的一条执行main方法,两条执行流并发执行,操作系统负责调度,在单个CPU的机器上,同一时刻只能有一个线程在执行,在多CPU的机器上,同一时刻可以有多个线程同时执行,但操作系统给我们屏蔽了这种差异,给我们的感觉就是多个线程并发执行,但是哪个语句先执行哪个后执行是不一定的。

实现runnable接口

通过继承Thread来实现线程虽然比较简单,但是Java只支持单继承,每个类最多有一个父类,如果一个类已经有父类了,就不能继承Thread,这时可以通过实现Runnable接口来实现线程。Runnable接口的定义如下:

1
2
3
public interface Runnable{
public abstract void run();
}

一个类可以实现该接口,并实现run方法,如下所示:

1
2
3
4
5
6
public class HelloRunnable implements Runnable{
@Override
public void run(){
System.out.println("hello");
}
}

仅仅实现Runnable是不够的,要启动线程,还是要创建一个Thread对象,但传递一个Runnable对象,如下所示:

1
2
3
4
public static void main(String[] args){
Thread helloThread = new Thread(new HelloRunnable());
helloThread.start();
}

无论是通过继承Thread还是实现Runnable接口来创建线程,启动线程都是调用start方法。

线程的几个常用的属性和方法

线程有一些基本属性和方法,包括id、name、优先级、是否daemon线程、是否存活、sleep方法、yield方法、join方法、过时方法等等。

id和name

每一个线程都有一个id和name。id是一个递增的整数,每创建一个线程就加一。name的默认值是Thread-后面跟一个编号,name可以在Thread的构造方法中进行指定,也可以通过setName方法进行设置,给Thread设置一个友好的名字,可以方便调试。

得到线程的名字

getName()方法可以得到线程的名字,我们可以有两种方式调用,一个是我们的类继承Thread来使用多线程的时候,可以用过this来调用。另一种是通过Thread.currentThread() 来调用这些方法。但是这两个方法在不同的使用场景下是有区别的。
第一个Thread.currentThread()的使用,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
public class ExampleCurrentThread extends Thread{

public ExampleCurrentThread(){
System.out.println("构造方法的打印:" + Thread.currentThread().getName());
}

@Override
public void run() {
super.run();
System.out.println("run方法的打印:" + Thread.currentThread().getName());
}
}

测试的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
public class ExampleCurrentThreadTest extends TestCase {

public void testInit() throws Exception{
ExampleCurrentThread thread = new ExampleCurrentThread();
}

public void testRun() throws Exception {
ExampleCurrentThread thread = new ExampleCurrentThread();
thread.start();
Thread.sleep(1000);
}
}

结果如下:

1
2
3
构造方法的打印:main
run方法的打印:Thread-0
构造方法的打印:main

为什么我们在ExampleCurrentThread内部用Thread.currentThread()会显示构造方法的打印是main,是因为Thread.currentThread()返回的是代码段正在被那个线程调用的信息。这里面很显然构造方法是被main线程执行的,而run方法是被我们自己启动的线程执行的,因为没有给他起名字,所以默认是Thread-0。
接下来,我们在看一看继承自Thread,用this调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ComplexCurrentThread extends Thread{

public ComplexCurrentThread() {
System.out.println("begin=========");
System.out.println("Thread.currentThread().getName=" + Thread.currentThread().getName());

System.out.println("this.getName()=" + this.getName());
System.out.println("end===========");
}

@Override
public void run() {
super.run();
System.out.println("run begin=======");
System.out.println("Thread.currentThread().getName=" + Thread.currentThread().getName());
System.out.println("this.getName()=" + this.getName());
System.out.println("run end==========");
}
}

测试代码如下:

1
2
3
4
5
6
7
8
9
public class ComplexCurrentThreadTest extends TestCase {
public void testRun() throws Exception {
ComplexCurrentThread thread = new ComplexCurrentThread();
thread.setName("byhieg");
thread.start();

Thread.sleep(3000);
}
}

结果如下:

1
2
3
4
5
6
7
8
begin=========
Thread.currentThread().getName=main
this.getName()=Thread-0
end===========
run begin=======
Thread.currentThread().getName=byhieg
this.getName()=byhieg
run end==========

首先在创建对象的时候,构造器还是被main线程所执行,所以Thread.currentThread()得到的就是Main线程的名字,但是this方法指的是调用方法的那个对象,也就是ComplexCurrentThread的线程信息,还没有setName,所以是默认的名字。然后run方法无论是Thread.currentThread()还是this返回的都是设置了byhieg名字的线程信息。
所以Thread.currentThread指的是具体执行这个代码块的线程信息。构造器是main执行的,而run方法则是哪个线程start,哪个线程执行run。这么看来,this能得到的信息是不准确的,因为如果我们在run中执行了this.getName(),但是run方法却是由另一个线程start的,我们是无法通过this.getName得到运行run方法的新线程的信息的。而且只有继承了Thread的类才能有getName等方法,这对于Java没有多继承的特性语言来说,是个灾难。所有后面凡是要得到线程的信息,我们都用Thread.currentThread()来调用API。

优先级

线程有个优先级的概念,在Java中,优先级是从1到10,默认是5,相关方法是:

1
2
public final void setPriority(int newPriority)
public final int getPriority()

线程的优先级是有继承的特性,如果我们在A线程中启动了B线程,则AB具有相同的优先级。一般我们在main线程中启动线程,就和main线程有一致的优先级。main线程的优先级默认是5。

下面说一下优先级的一些规则:

  1. 优先级高的线程一般会比优先级低的线程获得更多的CPU资源,但是不代表优先级高的任务一定先于优先级低的任务先执行完。因为不同优先级的线程中run方法内容可能不一样。
  2. 优先级高的线程一定会比优先级低的线程执行的快。如果两个线程是一样的run方法,但是优先级不一样,确实优先级高的线程先执行完。

状态

线程有一个状态概念,Thread有一个方法用于获取线程的状态:
public State getState()
返回值类型是Thread.State,它是一个枚举类型,有如下值:

1
2
3
4
5
6
7
8
public enum State{
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}

解释一下:

  1. NEW:没有调用start的线程状态为NEW。
  2. TERMINATED:线程运行结束后的状态
  3. RUNNABLE:调用start后线程在执行run方法且没有阻塞时状态为RUNNABLE,不过,RUNNABLE不代表CPU一定在执行该线程代码,可能正在执行也可能在等待操作系统分配时间片,只是它没有在等待其他条件。
  4. BLOCKED、WAITING、TIMED_WAITING:都表示线程被阻塞了,在等待一些条件。
    Thread还有一个方法,返回线程是否活着:
    public final native boolean isAlive()
    线程被启动后,run方法运行结束前,返回值都是true,其他时间段都是false。

是否daemon线程

Thread有一个是否daemon线程的属性,相关方法是:

1
2
public final void setDaemon(boolean on)
public final boolean isDaemon()

前面我们提到,启动线程会启动一条单独的执行流,整个程序只有在所有线程都结束时才退出,但是daemon线程是个例外、当整个程序中只有daemon线程时,程序就会退出。
守护线程的特点就是其它非守护线程执行完,守护线程就自动销毁,典型的例子就是GC回收器。

sleep方法

Thread有一个静态的sleep方法,调用该方法会让当前线程睡眠指定的时间,单位是毫秒:
public static native void sleep(long millis) throws InterruptedException;
睡眠期间,该线程会出让CPU,但睡眠的时间不一定是确切的给定的毫秒数,有一定的偏差,偏差与系统定时器和操作系统调度器的精准度有关。睡眠期间线程可以被中断,如果被中断,sleep会抛出InterruptedException。

yield方法

Thread还有一个让出CPU的方法:
public static native void yield();
这也是一个静态方法,调用该方法,是告诉操作系统的调度器:我现在不着急占用CPU,你可以先让其他线程运行。不过这只是建议,调度器如何处理是不一定的,它可能完全忽略该调用。

join方法

在前面HelloThread的例子中,HelloThread没执行完,main线程可能就执行玩了,Thread有一个join方法,可以让调用join的线程等待该线程结束,join方法的声明为:
public final void join() throws InterruptedException
在等待线程结束的过程中,这个等待可能被中断,如果被中断,会抛出InterruptedException。
join方法还有一个变体,可以限定等待的最长时间,单位是毫秒,如果是0,表示无限期等待:
public final synchronized void join(long millis) throws InterruptedException
在前面HelloThread示例中,如果希望main线程在子线程结束后再退出,main方法可以修改为:

1
2
3
4
5
public static void main(String[] args){
Thread thread = new HelloThread();
thread.start();
thread.join();
}

这里有一张图帮助理解线程从创建到消亡的状态变化:

过时方法

Thread类中还有一些过时的方法,我们不能再程序中使用他们:

1
2
3
public final void stop()
public final void suspend()
public final void resume()

共享内存及可能存在的问题

前面我们提到,每个线程表示一条单独的执行流,有自己的程序计数器,有自己的栈,但线程之间可以共享内存,它们可以访问和操作相同的对象。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class ShareMemoryDemo {
private static int shared = 0;
private static void incrShared(){
shared++;
}
static class ChildThread extends Thread{
List<String> list;
public ChildThread(List<String> list){
this.list =list;
}
@Override
public void run(){
incrShared();
list.add(Thread.currentThread().getName());
}
}

public static void main(String[] args) throws InterruptedException {
List<String> list = new ArrayList<>();
Thread t1 =new ChildThread(list);
Thread t2 = new ChildThread(list);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(shared);
System.out.println(list);
}
}

这里我们有静态变量shared和静态内部类ChildThread,在main方法中创建了两个线程,访问共享变量shared和list,main方法最后输出共享的list和shared值,大部分情况会输出期望的值:

1
2
2
[Thread-0,Thread-1]

执行流、内存和程序代码之间的关系:

  • 该例子中有三个执行流,一个main,两个ChildThread执行流执行run方法。
  • 不同执行流可以访问和操作相同的变量,如本例中的shared和list变量
  • 不同执行流可以执行相同的程序代码,如本例中incrShared方法,ChildThread的run方法,被两条ChildThread执行流执行,incrShared方法是在外部定义的,但被ChildThread的执行流执行,在分析代码执行过程时,理解代码在被哪个线程执行是很重要的。
  • 当多条执行流执行相同的程序代码时,每条执行流都有单独的栈,方法中的参数和局部变量都有自己的一份。
    当多条执行流可以操作相同的变量时,可能会出现一些意料之外的结果,包括竟态条件和内存可见性问题:

竟态条件

所谓竟态条件是指,当多个线程访问和操作同一个对象时,最终执行结果与执行时序有关,可能正确也可能不正确。看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class CounterThread extends Thread {
private static int counter = 0;

@Override
public void run() {
for(int i=0;i<1000;i++){
counter++;
}
}

public static void main(String[] args) throws InterruptedException {
int num = 1000;
Thread[] threads = new Thread[num];
for(int i=0; i<num; i++){
threads[i] = new CounterThread();
threads[i].start();
}

for(int i=0; i<num; i++){
threads[i].join();
}

System.out.println(counter);
}
}

有一个共享静态变量counter,初始值为0,在main方法中创建了1000个线程,每个线程对counter循环加1000次,main线程等待所有线程结束后输出counter的值。
期望结果是100万,但是实际每次都不一样,也不够100万。因为counter++这个操作不是原子操作,两个线程可能同时取到相同的counter值,比如都取到了100,第一个线程执行完后counter变为101,而第二个线程执行完后还是101,最终的结果就与期望不符。

解决办法:

  • 使用synchronized关键字
  • 使用显式锁
  • 使用原子变量

内存可见性

多个线程可以共享访问和操作相同的变量,但一个线程对一个共享变量的修改,另一个线程不一定马上就能看到,甚至永远也看不到,这可能有悖直觉,我们来看一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class VisibilityDemo {
private static boolean shutdown = false;

static class HelloThread extends Thread {
@Override
public void run() {
while(!shutdown){
// do nothing
}
System.out.println("exit hello");
}
}

public static void main(String[] args) throws InterruptedException {
new HelloThread().start();
Thread.sleep(1000);
shutdown = true;
System.out.println("exit main");
}
}

在这个程序中,有一个共享的boolean变量shutdown,初始为false,HelloThread在shutdown不为true的情况下一直死循环,当shutdown为true时退出并输出”exit hello”,main线程启动HelloThread后睡了一会,然后设置shutdown为true,最后输出”exit main”。

期望的结果是两个线程都退出,但实际执行,很可能会发现HelloThread永远都不会退出,也就是说,在HelloThread执行流看来,shutdown永远为false,即使main线程已经更改为了true。

这是怎么回事呢?这就是内存可见性问题。在计算机系统中,除了内存,数据还会被缓存在CPU的寄存器以及各级缓存中,当访问一个变量时,可能直接从寄存器或CPU缓存中获取,而不一定到内存中去取,当修改一个变量时,也可能是先写到缓存中,而稍后才会同步更新到内存中。在单线程的程序中,这一般不是个问题,但在多线程的程序中,尤其是在有多CPU的情况下,这就是个严重的问题。一个线程对内存的修改,另一个线程看不到,一是修改没有及时同步到内存,二是另一个线程根本就没从内存读。

解决办法:

  • 使用volatile关键字
  • 使用synchronized关键字或显式锁同步

本篇是对多线程基础知识的总结帮助理解多线程。