【Java】线程的理解和使用

by img Microanswer 创建时间:Oct 28, 2019 2:03:41 PM 

标签: 线程 Thread Runnable



重要声明:本文章仅仅代表了作者个人对此观点的理解和表述。读者请查阅时持自己的意见进行讨论。

一、认识线程

任何一个程序至少有一个线程。这个线程是主线程,维持程序执行的线程。有时候我们在主线程中执行某个任务(方法)时,使主线程卡住或者执行缓慢。这时候,就非常有必要另外在创建一个新线程,将任务(方法)放在这个新的线程里面去执行。这样就可以减少主线程的负担并将最终结果完成得更有效率。

二、创建并使用线程

1、创建线程

线程的创建离不开Thread类,因此首先必须要先学习Thread类,才能明白如何创建线程。Thread类提供了一个无参数的构造函数,意味着可以直接通过new Thread()来创建一个新的线程。

Thread nthd = new Thread();

上述代码创建了一个空线程,这个线程什么事情都不会做。现在,通过start()方法就可以让这个线程运行起来。

nthd.start();

但是因为这是空线程,没有任何任务,因此,线程开启后马上就会退出。现在的任务就是如何让这个新创建的线程运行一个任务。

要给新线程添加一个任务,其实非常简单。新线程的创建,除了可以使用Thread的无参构造函数,还可以使用其提供的另一个构造函数Thread(Runnable target)。意味着可以传入一个Runnable来创建线程。在加入任务创建新线程之前,先了解一下Runnable

2、Runnable

它是一个接口,里面只有一个run方法,并且是无参数的。要实现这个接口就必须实现这个方法。你可以把你的任务放在这个run方法中去执行。通常实现这个接口的类都是有任务要放在新的线程里去执行的。

比如说,打印1...100这些所有数字。可以这样实现:

Runnable print100 = new Runnable() {
    @Override
    public void run() {
        for (int i = 1; i <= 100; i++) {
            System.out.println(i);
        }
    }
};

// 调用run方法,打印。
print100.run();

此程序输出1...100没有任何问题。但是注意,上述代码并没有新开任何线程。要让这个任务在新线程里面执行,必须要结合Thread才可以。下面将进行介绍。

3、新线程执行任务

现在我们对Runnable有了认识,对Thread也有了认识,只需要将RunnableThread结合,就可以让任务在新线程里面执行。

Runnable print100 = new Runnable() {
    @Override
    public void run() {
        for (int i = 1; i <= 100; i++) {
            System.out.println(i);
        }
    }
};
Thread print100Thread = new Thread(print100);
// 启动新开启的线程。即可执行 print100 的任务
print100Thread.start();

上述代码,就向你展示了如何使用RunnableThread结合来完成新线程执行指定的任务。

4、继承Thread

上面我们通过了RunnableThread的结合来完成了新线程的创建并指定了具体任务。其实有另一种更加方便的方法来实现。

继承Thread类,然后重写run方法,直接将你的任务代码写在run方法里面,就可以完成和上面一样的效果。

public class Print100Thread extends Thread {
    @Override
    public void run() {
        for (int i = 1; i <= 100; i++) {
            System.out.println(i);
        }
    }
}

然后就可以直接通过new Print100Thread()来创建一个线程执行对应的任务:

Print100Thread print100Thread = new Print100Thread();
// 启动新开启的线程。即可执行任务
print100Thread.start();

5、注意事项

现在我们了解了如何创建线程,并且熟悉了如何将任务放在线程里去执行。但是要精通这项技能,光了解这点知识点是远远不够的。还有许多要注意的地方。

  1. 只有通过调用start()方法启动的线程,run方法才会在子线程里面执行。调用其他任何方法使run方法得到了执行,都不是新开的线程。
  2. Thread类里面,仅有run方法里面的代码,或在run方法里面被调用的方法会在子线程里面执行。也就是,Thread里面子线程执行入口有且只有一个run方法。
  3. run方法执行完成,线程随之退出。run方法一直执行,此线程会一直等待直到run方法执行完成。

三、线程的进阶知识

上面讲述了基本的线程知识,你明白了如何创建线程,和在线程内执行你希望的任务。但是在实际开发中,遇到的需求往往比案列中要复杂得多。为了应对更为复杂的需求场景,不得不祭出和线程操作有关的更多方法。

1、Thread.sleep()

这是一个静态公共方法。由Thread类提供的。它可以使当前线程直接睡眠指定的时间(毫秒),相当于你给他传递多长时间,这行代码就要卡住多长时间。比如:

for (int i = 0; i < 60; i++) {
    System.out.println(i);
    try {
        Thread.sleep(1000); // 每次循环输出了数字后,睡眠1000毫秒(1秒)的时间。
    }catch (Exception ignore) {/* 这里暂时忽略异常 */}
}

看起来非常简单,就是让当前线程卡住(睡眠)指定长度的时间。不过还是有些点是需要注意到。

  1. 这是一个静态方法。建议你经通过Thread.sleep()的方式去调用。
  2. 起作用永远都是作用于这行代码所处的那个线程。例如下面这种极端场景也不例外:
// 创建一个新线程A
Thread threadA = new Thread();

// 创建一个新线程B
Thread threadB = new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("线程B开始了。");

        try {
            // 尝试通过A线程对象来调起 sleep 方法。
            threadA.sleep(1000);
        }catch (Exception ignore) {/*暂时忽略异常*/}
        System.out.println("线程B结束了。");
    }
});
threadB.start();

即便是这样的代码。最终的效果是:先输出线程B开始了。。然后等待1秒钟之后输出线程B结束了。。可以证明sleep方法是一个静态方法,它不在乎是谁引用调起了它,它的作用也永远是:这行代码在哪儿执行的,就把对应的线程给睡眠指定时间。

2、synchronized

synchronized 不是一个方法。em~~~。可以理解为它是一种语法。用法示列如下:

public class Test {
    static Object obj = new Object(); // 标记1
    public static void main(String[] args) {
        int number = 0;

        // 使用 synchronized 代码块 包围 里面那段代码。
        synchronized (obj) {       // 标记2
            number = 10 + number;  // 标记3
        }
    }
}

在我还没介绍前不必担心看不明白。首先需要对里面进行标记的三处代码进行介绍。

  • 标记1:创建了一个同步锁对象。
  • 标记2:使用同步锁对象和synchronized结合的语法形成同步代码块。
  • 标记3:要同步执行的代码。

十分有必要再进一步解释为什么需要这样的代码,你可能才会完全领会到它的用途。为了解释清楚,先不用代码直接说这件事。不妨比喻一个场景:

有一个公共卫生间,里面只有一个坑,有时候客流量大的时候会有很多人同时冲进去蹲坑。相关部门就看不下去了,说这也不是一个办法。必须解决这个问题。有人就提出一个解决方案,说每次只能进去一个人蹲坑,通过卫生间外面的一个“坑票循环机”,要蹲坑,先从这台机器里取票,而且总共只有一张票。蹲坑完成后将票塞回这台机器的回收口,下一个人才能又从这台机器的取票口拿到这张票。必须有票才能进去蹲坑。没票说明上一个人还在蹲,就只能在外面等着,乖乖排队。

相信这样说你应该就明白了。所谓同步代码块,就相当于这个“坑票循环机”。obj就相当于“票”。整个代码块由 synchronized、“票”、和一个{}代码块构成。当有一个线程正在执行标记3的代码时,相当于有个人进去蹲坑了,票(obj)也被拿了,这时候就不能有其它的线程再进来蹲坑了。

这就解决了一个问题啊,什么问题?比如说,如果那个number的含义是共有多少个促销商品,商品一旦开始促销,就会瞬间有很多人抢购,反应到程序里,就变成瞬间有多个线程同时要来操作这个number,厕所可以做到一个坑位即便来了2个人,另一个人也没办法蹲,而程序可不会,程序是来了就干了。这样可能出现同一个商品被卖出去多次啊,这问题可就大了去了。所以,为了解决这个问题,同步代码块产生了。

同步锁

通过上面的介绍,你要知道。同步代码锁其实就是一个 obj 对象,它允许的类型是Object的,也就是你可以使用任何类对象来作为这把锁。不管你用什么类、什么对象,你都要保证“坑票”只能有一个,在整块同步代码块上,那个 obj 只能是唯一的。你不能每一个同步代码块都用一个新的obj对象,那就没有意义了。那就等同于“坑票循环机”要产出多张票了。这是万万不应该的。

比如下面示列 错误 的用法(语法没问题):

public class TestLock {
    public static void main(String[] args) {
        TestLock t = new TestLock();

        new Thread(new Runnable() {
            public void run() {
                t.test();
        }}).start();

        new Thread(new Runnable() {
            public void run() {
                t.test();
        }}).start();
    }

    int number = 0;

    void test() {
        // 创建同步锁
        Object obj = new Object();
        synchronized (obj) { // 代码块
            number = 10 + number;
        }
        System.out.println(number);
    }
}

上述代码中,test 方法里有一个同步代码块,并且在main函数中使用2个线程分别调用这个方法。而这个方法里面的同步锁对象都是新创建的,意味着多个线程用的同步锁不是同一个,这就导致同步代码块毫无意义。所以不能这样使用。必须优化修改,只能使用一个锁对象。如下:

public class TestLock {
    public static void main(String[] args) {
        TestLock t = new TestLock();

        new Thread(new Runnable() {
            public void run() {
                t.test();
        }}).start();

        new Thread(new Runnable() {
            public void run() {
                t.test();
        }}).start();

    }

    // 创建同步锁. 同步锁应该只用同一个,因此将其创建为final不能修改,且作为Test的全局变量。
    private final Object obj = new Object();

    int number = 0;
    void test() {
        synchronized (obj) { // 代码块
            number = 10 + number;
        }
        System.out.println(number);
    }
}

将锁对象提取为全局对象,让test方法中使用的 obj 都保证是同一个锁对象。不过你依然要注意,并不是永远都是锁对象保持唯一,此处的示列代码,对于一个TestLock对象,里面是同步有效的。假如你new了2个TestLock出来,当然这两个里面的obj分别是各种的obj对象,其对应同步代码块用的锁也是各自的锁。按某种理解上,这里产生了2个锁对象出来,不过这两个也锁对象也保证了只会用在各自的代码块里。

所以,最终,你必须要弄清楚,同步代码块,你用在什么地方,锁对象的创建根据自己的需求创建唯一锁。

3、Object#wait()

wait方法可以让线程进入等待状态。也就是一但你用了这个方法,这行代码就会一直卡在这儿不会继续向下执行了。如果你希望这行代码的位置处继续执行下去,你可以调用notifynotifyAll方法,这两个方法将在下文讲解。wait方法是Object类的成员方法,不是静态方法。因此必须创建一个对象,在对象上使用此方法。要注意wait方法的使用必须在synchronized代码块里面,在其它地方使用都会报错的。

int number = 0;
void test() {
    synchronized (obj) {
        number = 10 + number;
        try {
            obj.wait(); // 标记1
        }catch (Exception ignore) {/*暂时忽略异常*/}
    }
    System.out.println(number);
}

上述代码中,在标记1的位置处使用了wait方法,那么这个时候,这个代码就会永远的卡在这里,等待下去...。现在我们先不关心它后续会怎么样。先来剖析一下这个代码。细心的你肯定会发现,我们是直接使用同步锁对象来调用wait方法的。这也是为什么必须在synchronized里面使用wait方法的原因。就拿上面的“蹲坑”事情来说吧,相当于这个进去蹲坑的人突然被那张票施了冰冻魔法(wait),将其冻住了,蹲坑的人就一直不动,必须等这张票再次施展火球魔法(notify)解冻,才可以继续执行下去。而此期间,进去的人一直在里面,外面也拿不到票进不来。蹲坑线程冻住了没关系,别的线程没冻住嘛,别的线程拿到同步锁,调用notify方法施展火球魔法,这张票施展火球魔法将其解冻,又继续愉快的蹲坑了。所以说,通常情况下,wait是自己线程发现某情况下需要wait了,进行自行wait。而自己都卡住了就没法运行了,也就不可能谈什么恢复了。因此通常是还有另一个线程,发现你的某情况又再一次符合标准了,它给你notify一下,你就又开始继续执行了。

这样就就形成了一种很协调的机制,与现实世界的事件发生顺序可以完全吻合了。比方说:你找包工头要工资,包工头发现上头还没拨款下来,包工头也没钱。你就赖着(wait)不走了,就一直等啊等,突然包工头收到了钱了,包工头给你一notify,你就美滋滋拿钱走人了。又比如说:你是工厂,你在生产手机。有个超市要从你这儿进购一批手机,你发现手机不够啊。这时候,人家就wait在哪儿了,你就不停的生产啊生产。唉!突然手机生产够了,你就又给人家来个notify。人家美滋滋拿着货走人。

4、Object#notify()\notifyAll()

notifynotifyAll方法作用相似,只不过前者只会唤醒一个在wait的线程。而后者会唤醒所有在wait的线程。我们知道wait方法的使用必须要在同步锁对象上使用。因此这里所说的唤醒一个或者唤醒所有是指的唤醒使用对应同步锁进行wait的一个或者所有线程。示列如下:

// 假设现已有同步锁 obj
final Object obj = new Object();
// 开启线程
new Thread(new Runnable() {
    public void run() {
        synchronized(obj) {
            System.out.println("开始...");

            try {
                // 进入等待状态。
                obj.wait();
            }catch(Exception ignore) {
                /*暂时忽略异常*/
            }

            System.out.println("完成...");
        }
    }
}).start();
// 同时也开启另一个线程
new Thread(new Runnable() {
    public void run() {
        // 先 sleep 2秒钟,
        // 这样2秒过后左边都等很久了。
        // 再调notify就能把左边唤醒。
        try {
            Thread.sleep(2 * 1000);

            synchronized (lock) {
                // 用obj同步锁调用唤醒,
                // 让左边的线程继续执行。
                obj.notify();
            }
        }catch(Exception ignore) {
            /*暂时忽略异常*/
        }
    }
}).start();

为了直观感受,我将排版改成横向的了。运行上面的代码,左右两边的线程同时开启,左边一开始就进入wait状态,后面一开始就先睡眠2秒钟,等2秒钟过后,右边的线程唤醒了左边的wait状态,左边进行输出。结合wait那一小节的知识,相信很快就能理解。

四、生产者与消费者

学会了上面的知识点之后,不如进行一次简单的实战。生产者与消费者的关系基本上是学习线程的首个demo案列。就如同你要学习某个语言之后第一个程序是“hello world.”一样。

生产者作为一个线程。 消费者作为一个线程。这就形成了最简单的供求关系了。而且可能有多个消费者线程、也有多个生产者线程。

1、程序设计

这个案例涉及到的东西相对较多,我们必须有一个合适的程序结构,才能更容易理解,更符合程序规范,更加容易维护我们写出来的程序。首先我们要考虑到的就是“生产者”可以有多个人的,“消费者”也可以有多个人。那么,生产者生产的“产品”,是要设计在生产者里面,还是要设计在一个单独的地方?

这个问题很好回答,如果我们只支持一个生产者,当然可以把“产品”就放在生产者里面。但我们要设计支持多个生产者同时生产的,如果还把产品放在各自生产者里自己维护,那么每个生产者要去关心自己有没有足够的产品提供。相当于生产者除了要做生产这件事、它还要管理产品够不够给的事情。这无疑有点让生产者的设计变得臃肿且没有必要。

所以,将产品放在一个单独的管理区,相当于有一个仓库。生产者生产的产品都放在这个仓库里面。这样生产者只需要生产,而不需在乎产品具体有多少。即便有多个生产者,产出的产品也直接往仓库里面放就好了。而对于消费者,直接从仓库取。这样一来,无论是生产者还是消费者,都可以支持多个生产者或消费者。

2、开发产品仓库

不论是消费者还是生产者,都是要和仓库打交道的。首先开发出仓库,可以同时为生产者和消费者提供更多便利之处。以ProductRoom类作为仓库:

public class ProductRoom {
    // 同步锁对象。
    private final Object lock = new Object();
    // 所有产品都放置在这个集合里。
    private List<Object> products;

    public ProductRoom () {
        products = new ArrayList<>();
    }

    // 从仓库中取一个产品出来。
    public Object takeAProduct() throws Exception{
        // 使用同步代码块进行同步,保证每个产品不会被多次获取。
        synchronized (lock) {
            // 只要仓库里没有产品,就在这儿等着。
            while (products.isEmpty()) {
                lock.wait();
            }
            // 能运行到这里,说明必然是有产品的。
            return products.remove(0);
        }
    }

    // 放一个产品到仓库里面。
    public void putAProduct(Object obj) {
        synchronized (lock) {
            products.add(obj);
            // 放了一个产品进去后,有可能有些消费者还在等着获取产品呢。
            // 所以,这里就调一次唤醒,让等待的消费者能继续获取产品。
            lock.notifyAll();
        }
    }
}

3、开发生产者

一个生产者就是一个新线程,所以,我们的生产者肯定是要继承Thread来实现的。并且我们定义生产者每5秒钟生产一个产品。我们以ProductMaker作为生产者类:

/**
 * 生产者。
 */
public class ProductMaker extends Thread{

    // 为了识别生产者,我们给生产者一个名字。
    private String mName;

    // 仓库。
    private ProductRoom productRoom;

    // 构造时,指定将生产的产品放到对应的仓库里。
    public ProductMaker (String name, ProductRoom productRoom) {
        this.mName = name;
        this.productRoom = productRoom;
    }

    @Override
    public void run() {
        super.run();

        // 生产者只要已启动,就不停的每5秒生产一个产品。
        while (true) {
            try {
                // 每五秒生产一个,只需要sleep5秒钟,来模拟耗时5秒钟才生产完成。
                Thread.sleep(5 * 1000);
            }catch (Exception ignore) {/* 暂时忽略异常 */}

            Object o = new Object();
            // 生产了一个。就放到仓库里
            productRoom.putAProduct(o);
            // 顺便打印一个提示。
            System.out.println("[" + new Date().toLocaleString() + "] " + this.mName + "生产了一个产品:@" + o.hashCode());
        }

    }

    public String getmName() {
        return mName;
    }
}

4、开发消费者

一个消费者也是一个线程,所以也还继承Thread来实现,我们定义消费者每3秒就消费一个产品。定义消费者ProductUser类如下:

/**
 * 消费者类
 */
public class ProductUser extends Thread {

    // 为了记录是哪一个消费者,我们定一个姓名。
    private String mName;

    // 定义消费者要消费哪一个仓库里的产品。
    private ProductRoom productRoom;

    public ProductUser(String mName, ProductRoom productRoom) {
        this.mName = mName;
        this.productRoom = productRoom;
    }

    @Override
    public void run() {
        super.run();

        while (true) {

            try {
                // 从仓库取出消耗一个产品。
                System.out.println("[" + new Date().toLocaleString() + "] " + mName + "获取产品..");
                Object o = productRoom.takeAProduct();
                System.out.println("[" + new Date().toLocaleString() + "] " + "成功并消费了:@" + o.hashCode());

                // 每3秒就消耗一个产品。
                Thread.sleep(3 * 1000);
            } catch (Exception ignore) {/*暂时忽略异常*/}
        }
    }
}

5、测试

现在,一切就绪,可以进行测试了:

public static void main(String[] args) {
    // 创建一个仓库
    ProductRoom room = new ProductRoom();

    // 创建2个生产者
    ProductMaker maker = new ProductMaker("生产者A", room);
    ProductMaker maker2 = new ProductMaker("生产者B", room);

    // 创建一个消费者
    ProductUser user = new ProductUser("消费者Z", room);

    // 开启生产和消费
    maker.start();
    maker2.start();
    user.start();
}

来看一下运行结果:

全文完, 转载请注明出处。 对你有帮助?不如赞一个吧:
发表评论(发表评论需要登录,你现在还没有登录。)
你需要先登录才可以评论。

评论列表 (0条)