03 Java多线程

JVM是个多任务的操作系统,可以同时运行多个任务。要理解多线程技术,就应该从理解线程开始。

1. 多线程的概念

我们都知道Windows是个多任务的操作系统,可以同时运行多个应用程序,比如可以在上网的同时,还可以听音乐,甚至边玩一些游戏,而这样每一个独立运行的程序又被称为进程,同时运行多个程序又叫多进程,它们每个都拥有独立的内存和代码,因此进程越多,对内存的消耗就越大。而线程则是包含在进程内的,一个进程里面可以包含多个线程,一个线程就是一个独立的程序流,这种称作为单线程,初学者接触的程序大多都是单线程的,比如我们在01篇编程基础里面的Hello World例子。然而较大的进程需要同时满足许多功能,靠单进程是力不从心的,需要在一个进程内开辟多个同时运行的线程,这些线程共享该进程在内存中占有的资源。

2. 多线程的实现

2.1 两种方式

Java中实现多线程,有两种方式,一是继承Thread类,二是实现Runnable接口。
继承Thread类的自定义类需要重写Thread类的public void run()方法,然后调用其start()方法开启新的线程:
class User extends Thread
{
    @Override
        public void run()
        {
            ...
        }
}
比如:
public class Test
{
    public static void main(String[] args)
    {
        new User().start();     
        new User().start();     //两个线程随机交替运行
    }
}

class User extends Thread
{   
    private int count = 0;
    @Override
        public void run()
        {
            for(int i = 0; i < 3; i++)
                //输出当前线程的编号和count值
                System.out.println(Thread.currentThread().getName() + ", count = " + ++count);
        }
}
实现Runnable接口的自定义类需要重写Runnable接口的public void run()方法,将该自定义类的对象引用传给Thread类作为参数,然后利用这个参数生成Thread类的对象并调用其start()方法开启新的线程:
class User implements Runnable
{
    public void run()
    {
        .....
    }
}
比如:
public class Test
{
    public static void main(String[] args)
    {
        User usr = new User();
        new Thread(usr).start();     //User类的对象引用作为参数来生成Thread类的对象
        new Thread(usr).start();     //两个线程随机交替运行
    }
}

class User implements Runnable
{
    private int count = 0;
    @Override
        public void run()
        {
            for(int i = 0; i < 3; i++)
                //输出当前线程的编号和count值
                System.out.println(Thread.currentThread().getName() + ", count = " + ++count);
        }
}
上面两种方式的Thread - 01和Thread - 02运行顺序是不确定的,取决于CPU对线程执行顺序的分配。

2.2 资源共享

继承Thread类和实现Runnable接口的区别,一是能否实现多继承,二是能否实现资源共享。还是以前面的两个示例程序来看:
继承Thread类的线程的输出:
Thread - 0, count = 1;
Thread - 1, count = 1;
Thread - 0, count = 2;
Thread - 1, count = 2;
Thread - 0, count = 3;
Thread - 1, count = 3;
这说明两个线程对象的count变量是各自拥有的,各自操作的,不能共享。
实现Runnable接口的线程的输出:
Thread - 0, count = 1;
Thread - 1, count = 2;
Thread - 0, count = 3;
Thread - 1, count = 4;
Thread - 0, count = 5;
Thread - 1, count = 6;
这说明两个线程对象的count变量指向同一块内存,实现了资源共享。
能否共享取决于线程的生成方式:对于继承Thread类的方法,新Thread类对象的生成调用的是无参构造方法,所以新的线程将独立生成它自己的内存空间,不知道其它线程的内存信息,也就不能共享数据。而对于实现Runnable接口的方法,新Thread类对象的生成调用的是有参构造方法,如果多个Thread类在构造对象时使用同一个Runnable类型的引用作为构造参数,则每个新的Thread类对象都有同一个实现了Runnable接口的类的对象的引用,也就获得了同一个数据地址,实现了数据共享。

2.3 如何中断一个线程

Thread类内部有一个boolean型的变量叫做中断状态,我们无法直接访问,只能通过interrupt,isInterrupted和interrupted方法来访问。其中interrupt方法对于非阻塞和阻塞的线程的中断状态有不同的作用。
如果一个线程中调用了sleep,join或者wait方法,就会进入阻塞状态,暂停运行。除此之外都属于非阻塞状态,正常运行。
要正确地中断一个线程,就必须理解interrupt方法的作用。这个方法的字面意思给笔者这样的初学者以很大误导,让笔者误以为这个方法可以终止线程,实际上是不能的。
单凭调用interrupt方法不能中断线程。对于非阻塞的线程,interrupt方法的调用会将其中断状态置为true。对于阻塞的线程,会抛出异常。
真正中断线程的,是我们编写的用来响应这两个事件的代码。
对非阻塞线程使用interrupt方法,其它程序可以循环检测其中断标志,如果为true则可以写对应的处理代码,比如终止while循环这样的方式来终止线程。
对于阻塞线程使用interrupt方法,可以在线程的catch块中(可以进入阻塞状态的线程必然有异常抛出和捕获结构)写对应的处理代码,比如退出程序的语句。
下面我们来试验两种情况下的interrupt()方法:
对非阻塞线程调用interrupt:
public class Test
{
    public static void main(String[] args)
    {
        User usr = new User();
        usr.start();
        Thread.sleep(5);                                        //main线程空转5ms用于线程初始化
        System.out.println("isInterrupted ? " + usr.isInterrupted());
        usr.interrupt();                                        //中断usr线程
        System.out.println("isInterrupted ? " + usr.isInterrupted());
    }
}

class User extends Thread
{
    @Override
        public void run()
        {
            try
            {
                long time = System.currentTimeMillis();
                Thread.currentThread().setName("User thread");
                while(System.currentTimeMillis() - time < 20)     //保证User线程运行20ms以上
                {
                    System.out.println(Thread.currentThread().getName() + " is running.");
                }
            }
            catch(Exception e)
            {
                System.out.println(Thread.currentThread().getName() + " is interrupted.");
            }
        }
}
输出:
User thread is running.
User thread is running.
.....
User thread is running.
isInterrupted ? false
User thread is running.
isInterrupted ? true
User thread is running.
......
很明显,调用interrupted()方法只是将中断状态设置为true,usr线程并没中断,而是继续运行。
那应当怎样终止一个非阻塞状态的线程呢?使用共享的volatile变量作为终止信号,让线程周期性地检查这一变量并据此终止自己。
在2.2中笔者分析Runnable接口实现类的数据共享时,提到过Thread类共享接口实现类的对象引用,从而共享接口实现类对象内的数据,然而这个说法不够深入。像2.2中一个示例程序要运行,不止需要堆内存来存储接口实现类的对象,每个Thread类生成的线程都拥有各自的线程栈。线程栈保存了线程运行时的局部变量。当共享数据的两个线程A,B中的一个,比如A要修改数据时,A会先通过它拥有的接口实现类的对象的引用找到对象本身(在堆内存中),然后把对内存中的数据复制到A自己的线程栈中,然后修改这个复制的数据,在A退出之前将修改后的数据写回到堆内存的原位置。问题是如果A,B两个线程没有同步,B线程可能在A线程刚复制完数据时就改变了堆中数据的值,使得A的副本过时。volatile关键字修饰的数据可以保证共享该数据的线程总是得到该数据最新的值,也就是不适用副本而是直接访问堆。看下面的例子:
public class Test
{
    public static void main(String[] args) throws Exception
    {
        User usr = new User();
        usr.start();
        Thread.sleep(5);           //main线程空转5ms用于线程初始化
        System.out.println("isAlive ? " + usr.isAlive());
        usr.stop = true;           //中断usr线程
        Thread.sleep(5);           //留出5ms给usr线程用于终止动作
        System.out.println("isAlive ? " + usr.isAlive());
    }
}

class User extends Thread
{
    volatile boolean stop = false;
    @Override
        public void run()
        {
            try
            {
                long time = System.currentTimeMillis();
                Thread.currentThread().setName("User thread");
                while(System.currentTimeMillis() - time < 20 && stop == false)     //保证User线程运行20ms以上
                {
                    System.out.println(Thread.currentThread().getName() + " is running.");
                }
            }
            catch(Exception e)
            {
                System.out.println(Thread.currentThread().getName() + " is interrupted.");
            }
        }
}
输出:
User thread is running.
User thread is running.
.....
isAlive ? true
User thread is running.
isAlive ? false
很明显,usr.stop标志改为true时usr线程立刻终止并退出。
下面来考察阻塞状态的线程如何中断。阻塞状态的线程停止在阻塞它的语句处,无法循环检查用户设定的停止标志。这时应当使用interrupt()方法中断线程,抛出异常,并在异常处理catch块中编码决定该线程中断后的命运。先看休眠状态的线程:
public class Test
{
    public static void main(String[] args) throws Exception
    {
        User usr = new User();
        usr.start();
        Thread.sleep(1000);             //等待usr线程进入休眠状态
        System.out.println("isInterrupted ? " + usr.isInterrupted());
        usr.interrupt();                //中断usr线程
        Thread.sleep(10);               //留出10ms给usr线程用于终止动作
        System.out.println("isInterrupted ? " + usr.isInterrupted());
        System.out.println("isAlive ? " + usr.isAlive());
    }
}

class User extends Thread
{
    @Override
        public void run()
        {
            try
            {
                Thread.currentThread().setName("User thread");
                Thread.sleep(3000);     //usr线程休眠3000ms
            }
            catch(Exception e)
            {
                System.out.println(Thread.currentThread().getName() + " is interrupted.");
            }
        }
}
输出:
isInterrupted ? false
User thread is interrupted.
isInterrupted ? false
isAlive ? false
可以看到对阻塞状态的usr线程使用interrupt方法后,usr线程结束休眠,抛出异常,显示中断状态为false,随后退出。
上面的例子说明对阻塞状态的线程使用interrupt方法,中断状态仍为false,只是会立刻抛出异常。那么中断状态只能由interrupt方法来控制吗?看下面的例子:
public class Test
{
    public static void main(String[] args) throws Exception
    {
        User usr = new User();
        usr.start();
        System.out.println("isInterrupted ? " + usr.isInterrupted());
        usr.interrupt();                //中断usr线程
        System.out.println("isInterrupted ? " + usr.isInterrupted());
    }
}

class User extends Thread
{
    @Override
        public void run()
        {
            try
            {
                long time = System.currentTimeMillis();
                Thread.currentThread().setName("User thread");
                while(System.currentTimeMillis() - time < 100);   //usr线程空转100ms等待中断状态置为true
                Thread.sleep(300);                                //usr线程休眠300ms
            }
            catch(Exception e)
            {
                System.out.println(Thread.currentThread().getName() + " isInterrupted ? " + this.isInterrupted());
            }
        }
}
输出:
isInterrupted ? false
isInterrupted ? true
User thread isInterrupted ? false
这说明usr线程先被interrupt方法将中断状态置为true,然后再调用sleep时就会抛出异常,同时将中断状态置为false。
综上,中断状态跟interrupt和sleep方法的调用顺序有关,可以归纳为:
false -  > interrupt -  > true;
false -  > sleep -  > false -  > interrupt -  > Exception thrown -  > false;
false -  > interrupt -  > true - > sleep -  > Exception thrown -  > false
另一种阻塞状态的线程是调用wait方法后出现的,但这只能出现在使用同步方法的线程中。

3. 线程同步

为了保证单个线程中的多个步骤按顺序进行,不被其他线程打扰,需要进行线程同步,使得线程中的代码获得其所操作的对象的锁,这些代码得以按顺序执行完毕后再释放锁。

3.1 理解synchronized方法和synchronized块

一个没有线程同步的例子:
public class Test
{
    public static void main(String[] args) throws Exception
    {
        Resource r = new Resource();
        User usr = new User(r);
        new Thread(usr).start();
        new Thread(usr).start();
    }
}

class Resource
{
    private int count = 0;
    public void add()
    {
        count++;
        System.out.println(Thread.currentThread().getName() + " does step 1. count = " + count);
        count++;
        System.out.println(Thread.currentThread().getName() + " does step 2. count = " + count);
    }
}

class User implements Runnable
{
    Resource r;
    User(Resource r)
    {
        this.r = r;     //线程生成时获取其要操作的对象r
    }
    @Override
        public void run()
        {
            for(int i = 0; i < 3; i++) 
            {
                 r.add();
            }
        }
}
输出:
Thread - 1 does step 1. count = 2
Thread - 0 does step 1. count = 2
Thread - 1 does step 2. count = 3
Thread - 0 does step 2. count = 4
Thread - 1 does step 1. count = 5
Thread - 0 does step 1. count = 6
Thread - 1 does step 2. count = 7
Thread - 0 does step 2. count = 8
...
两个线程共享对象r。add方法中的两步count++应当是由同一个线程连续完成的。但由于这里没有线程同步,两个线程随机调用同一个r对象的add方法,不管另一个线程是否完成了add方法的两步,造成了执行顺序的混乱,甚至打印命令也被视作单独的一步,也不按代码中的顺序执行了,显示的count值出现错误。
如果将add方法用synchronized关键字修饰,或者将r.add()语句加上对象锁如下:
class Resource
{
    private int count = 0;
    public void synchronized add()      //synchronized关键字
    {
        count++;
        System.out.println(Thread.currentThread().getName() + " does step 1. count = " + count);
        count++;
        System.out.println(Thread.currentThread().getName() + " does step 2. count = " + count);
    }
}

class User implements Runnable
{
    Resource r;
    User(Resource r)
    {
        this.r = r;     //线程生成时获取其要操作的对象r
    }
    @Override
        public void run()
        {
            for(int i = 0; i < 3; i++) 
            {
                //synchronized(r)    //对象锁让线程锁住对象r, 将r换成this也可以
                {
                     r.add();
                }
            }
        }
}
输出:
Thread - 1 does step 1. count = 1
Thread - 1 does step 2. count = 2
Thread - 0 does step 1. count = 3
Thread - 0 does step 2. count = 4
Thread - 0 does step 1. count = 5
Thread - 0 does step 2. count = 6
Thread - 1 does step 1. count = 7
Thread - 1 does step 2. count = 8
...
这时执行顺序和显示都正确了。这里两种synchronized关键字的两种使用方法都能奏效,一种是synchronized方法,一种是synchronized块。当add()方法被synchronized关键字修饰,成为synchronized方法后,add方法就被锁住了,其中所有代码必须被一个线程执行完毕后才能被另一个线程执行。或者当r.add()语句被包括进synchronized块中,如果参数是r,表示块中的代码必须由获得了r对象锁的线程执行,而一个线程如果不用wait方法,在运行完之前是不会释放其所拥有的锁的。这就保证了块中的代码是由一个线程运行完毕后才交给另一个线程运行的。如果参数是this,表示块中代码必须由获得了本对象锁的线程执行。这里的本对象就是usr这个线程对象本身,而因为多个线程共享usr,而且获得usr锁的Thread线程执行完块中代码才会释放锁,就保证了块中代码也是由一个线程运行完毕后才交给另一个线程运行的。
从上面的分析可以看出,synchronized块必须至少锁住一个完整的对象,该对象的所有方法,无论是同步还是非同步方法,都被锁住了。而synchronized方法只会锁住一个完整对象中的某个方法,不妨碍其他线程访问未上锁的方法。所以synchronized块要求线程获得对象锁,而synchronized方法要求线程获得方法锁。

3.2 通过单个锁和标志位控制多个线程的运行顺序

毕老师的生产者消费者代码很好地解释了线程通过wait方法释放锁进入等待,通过notify方法唤醒等待的线程使其从新得到锁从而运行来控制生产线程和消费线程轮流运行的思路。
下面我们来考虑一种更复杂的情况,如何要求三个线程A, B, C按照ABCABCA的顺序轮流运行?按照视频的思路,比较简单的情况是设置一个标志位,不过不是boolean类型,而是一个字符串,用来标记下一个应当运行的线程:
public class Test
{
    public static void main(String[] args)
    {
        Tag tag = new Tag();
        new A(tag).start();
        new B(tag).start();
        new C(tag).start();
    }
}

class Tag
{
    String next = new String("A");
}

class A extends Thread 
{
    Tag tag = null;

    A(Tag tag)   
    {
        this.tag = tag;
    }

    @Override
        public void run()
        {
            for(int i = 0; i < 5; i++) 
            {
                synchronized(tag)
                {
                    //wait()一定要写在while循环里,否则会出现假唤醒的状况,即满足wait条件时线程继续运行,因为已经判断过。
                    //while循环使得线程在继续运行前一定再检测一遍wait条件是否满足
                    while(! tag.next.equals("A"))
                    {
                        try
                        {
                            tag.wait();
                        }
                        catch(Exception e){}
                    }
                    System.out.println("A");
                    tag.next = "B";
                    tag.notifyAll();
                }
            }
        }
}

class B extends Thread 
{
    Tag tag = null;

    B(Tag tag)   
    {
        this.tag = tag;
    }

    @Override
        public void run()
        {
            for(int i = 0; i < 5; i++) 
            {
                synchronized(tag)
                {
                    while(! tag.next.equals("B"))
                    {
                        try
                        {
                            tag.wait();
                        }
                        catch(Exception e){}
                    }
                    System.out.println("B");
                    tag.next = "C";
                    tag.notifyAll();
                }
            }
        }
}

class C extends Thread 
{
    Tag tag = null;

    C(Tag tag)   
    {
        this.tag = tag;
    }

    @Override
        public void run()
        {
            for(int i = 0; i < 5; i++) 
            {
                synchronized(tag)
                {
                    while(! tag.next.equals("C"))
                    {
                        try
                        {
                            tag.wait();
                        }
                        catch(Exception e){}
                    }
                    System.out.println("C");
                    tag.next = "A";
                    tag.notifyAll();
                }
            }
        }
}
输出:
A
B
C
A
B
C
A
...
如果被选中运行的线程不是符合顺序的,则调用wait方法放弃锁并进入等待队列,等其它未在等待且顺序正确的线程执行完后将其唤醒,再试试运气是否符合顺序可以完整运行。

3.3 通过多个锁控制多个线程的运行顺序

上面的例子里线程依靠一个锁和标志位来决定运行顺序。然而在只有一个锁却有3个线程的情况下,如果A线程进入wait放弃了锁,则获得锁的线程可能是C(如果B也处于wait状态),也可能是B、C中的一个(如果B不在wait状态)。也就是说单靠一把锁不能确定下一个线程是B还是C,故添加了标志位。这种方法的本质是试错,如果正确则运行,错误则阻塞。获得锁的线程不一定是正确的。想象一下,对于3个线程,仅仅通过获得锁能不能确定一个线程是正确的呢?答案是可以的,不过它要同时获得两把锁:
public class Test
{
    public static Object a = new Object();
    public static Object b = new Object();
    public static Object c = new Object();

    public static void main(String[] args) throws Exception
    {
		A tA = new A();
		B tB = new B();
		C tC = new C();

		tA.start();
       
        Thread.sleep(1);

		tB.start();

        Thread.sleep(1);

		tC.start();
    }
}

class A extends Thread    //A线程要同时获得a,b两把锁才能运行
{
    public void run() 
    {
        for (int i = 0; i < 5; i++) 
        {
            try 
            {
                synchronized (Test.a) 
                {
                    synchronized (Test.b) 
                    {
                        System.out.println("A");
                        Test.b.notify();        
                    }
                    if (i < 4) 
                    {
                        Test.a.wait();
                    }
                }
            } 
            catch (Exception e){} 
        }
    }
}

class B extends Thread    //B线程要同时获得b,c两把锁才能运行
{
    public void run() 
    {
        for (int i = 0; i < 5; i++) 
        {
            try 
            {
                synchronized (Test.b) 
                {
                    synchronized (Test.c) 
                    {
                        System.out.println("B");
                        Test.c.notify();
                    }
                    if (i < 4) 
                    {
                        Test.b.wait();
                    }
                }
            } 
            catch (Exception e){} 
        }
    }
}

class C extends Thread    //C线程要同时获得c,a两把锁才能运行 
{
    public void run() 
    {
        for (int i = 0; i < 5; i++) 
        {
            try 
            {
                synchronized (Test.c) 
                {
                    synchronized (Test.a) 
                    {
                        System.out.println("C");
                        Test.a.notify();
                    }
                    if (i < 4) 
                    {
                        Test.c.wait();
                    }
                }
            } 
            catch (Exception e) {}
        }
    }
}
输出:
A
B
C
A
B
C
A
...
当一个线程获得三把锁中指定的两把,就可以运行。A线程需获得a, b锁。B线程需获得b, c锁。C线程需获得c, a锁。三个线程中A最先启动,B其次,C最后启动,这样确保了从A线程开始运行。

4. 总结

多线程算是java基础中最难的一部分了。考虑多线程程序时要能想象多个线程同时运行,还要容忍它们彼此间的不确定性。唯一的办法就是多写代码做试验,并且熟悉一些定时启动,停止线程的方法以便在一定程度上定量分析。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章