Java多线程(一)线程基础

1.线程与进程

进程:
是并发执行的程序在执行过程中分配和管理资源的基本单位,是一个动态概念,进程是系统中独立存在的实体,拥有自己独立的资源,拥有自己私有的地址空间。进程的实质,就是程序在多道程序系统中的一次执行过程,它是动态产生,动态消亡的,具有自己的生命周期和各种不同的状态。进程具有并发性,它可以同其他进程一起并发执行,按各自独立的、不可预知的速度向前推进
进程由程序、数据和进程控制块三部分组成。

线程:
线程,有时候被称之为轻量级线程,是程序执行流的最小单元。线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。由于线程之间的相互制约,致使线程在运行中呈现出间断性。每一个程序都至少有一个线程,若程序只有一个线程,那就是程序本身。
一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。

多线程
线程是程序中一个单一的顺序控制流程。在单个程序中同时运行多个线程完成不同的工作,称为多线程。

2.线程的实现

java中现成的实现一般有四种方式

2.1 继承Thread类

2.2 实现Runnable接口

2.3 使用Callable和Future接口创建线程

因为这个我们平时用的不多,这个做个简单的demo展示。

这种创建方式的流程是创建Callable接口的实现类,并实现call()方法。并使用FutureTask类来包装Callable实现类的对象,且以此FutureTask对象作为Thread对象的target来创建线程。

  • 使用实例
package cn.ji2h.othertest;

import cn.ji2h.util.LogUtil;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class CallableDemo {
    public static void main(String[] args){
        Callable<Integer> myCallable = new MyCallable();
        FutureTask<Integer> ft = new FutureTask<Integer>(myCallable);

        Thread thread = new Thread(ft);
        thread.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        LogUtil.logger.info("线程 " + Thread.currentThread().getName() + " is running!");

        try {
            int sum = ft.get();
            LogUtil.logger.info("sum的结果是:" + sum);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }

        LogUtil.logger.info("end!");

    }

}

class MyCallable implements Callable<Integer>{

    //与run方法不同的是,call方法具有返回值
    @Override
    public Integer call() throws Exception {
        LogUtil.logger.info("线程 " + Thread.currentThread().getName() + " is running!");
        Thread.sleep(10000);
        int sum = 0;
        for (int i = 0; i < 100; i++) {
            sum += i;
        }
        return sum;
    }
}
  • 运行结果:
2018-10-02 00:35:41,424 [Thread-0] INFO  cn.ji2h.util.LogUtil - 线程 Thread-0 is running!
2018-10-02 00:35:42,284 [main] INFO  cn.ji2h.util.LogUtil - 线程 main is running!
2018-10-02 00:35:51,431 [main] INFO  cn.ji2h.util.LogUtil - sum的结果是:4950
2018-10-02 00:35:51,431 [main] INFO  cn.ji2h.util.LogUtil - end!

代码分析:
在实现Callable接口中,此时不再是run()方法了,而是call()方法,此call()方法作为线程执行体,同时还具有返回值!在创建新的线程时,是通过FutureTask来包装MyCallable对象,同时作为了Thread对象的target。FutureTask类的定义:

public class FutureTask<V> implements RunnableFuture<V>

FutureTask类实现了RunnableFuture接口,我们看一下RunnableFuture接口的实现:

public interface RunnableFuture<V> extends Runnable, Future<V> {
    void run();
}

于是,我们发现FutureTask类实际上是同时实现了Runnable和Future接口,由此才使得其具有Future和Runnable双重特性。通过Runnable特性,可以作为Thread对象的target,而Future特性,使得其可以取得新创建线程中的call()方法的返回值。

执行下此程序,我们发现sum = 4950永远都是最后输出的。那么为什么sum =4950会永远最后输出呢?原因在于通过ft.get()方法获取子线程call()方法的返回值时,当子线程此方法还未执行完毕,ft.get()方法会一直阻塞,直到call()方法执行完毕才能取到返回值。

2.4 各种线程池启动线程

略,后续会有专题说明

3.线程的状态和生命周期

3.1 线程的状态

java线程的五种基本状态

  • 新建状态:当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread()。

  • 就绪状态:当调用线程对象的start()方法(t.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了t.start()此线程立即就会执行。

  • 运行状态:当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中。

  • 阻塞状态:处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:

    • 等待阻塞-运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态。
    • 同步阻塞-线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态。
    • 其它阻塞-通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
  • 死亡状态:线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

3.2 线程的生命周期

Java多线程(一)线程基础
请结合图片和3.1接内容进行查看

4.线程的常用方法

4.1 sleep():线程休眠

sleep() 的作用是让当前线程休眠,即当前线程会从“运行状态”进入到“休眠(阻塞)状态”。sleep()会指定休眠时间,线程休眠的时间会大于/等于该休眠时间;在线程重新被唤醒时,它会由“阻塞状态”变成“就绪状态”,从而等待cpu的调度执行。常用来暂停程序的运行。
sleep()方法不会释放锁。

4.2 yield():线程让步

让出cpu重新竞争,线程让步。它能让当前线程暂停,但不会阻塞该线程,而是由“运行状态”进入到“就绪状态”,从而让其它具有相同优先级的等待线程获取执行。因此,使用yield()的目的是让相同优先级的线程之间能适当的轮转执行。但是,并不能保证在当前线程调用yield()之后,其它具有相同优先级的线程就一定能获得执行权,也有可能是当前线程又进入到“运行状态”继续运行!
yield()方法不会释放锁。

  • 实例代码
package cn.ji2h.othertest;

import cn.ji2h.util.LogUtil;

public class YieldDemo {
    public static void main(String[] args){
        ThreadYield yt1 = new ThreadYield("A");
        ThreadYield yt2 = new ThreadYield("B");
        yt1.start();
        yt2.start();
    }
}

class ThreadYield extends Thread{

    public ThreadYield(String name) {
        super(name);
    }

    @Override
    public void run() {
        for (int i = 0; i <=10 ; i++) {
            LogUtil.logger.info(this.getName() + "--------" + i);

            if (i == 3){
                this.yield();
            }
        }
    }
}
  • 运行情况
    第一种情况:A线程当执行到30时会将CPU时间让掉,这时B线程抢到CPU时间并执行。
    第二种情况:A线程当执行到30时会将CPU时间让掉,这时A线程抢到CPU时间并执行。
    第三种情况:B线程当执行到30时会将CPU时间让掉,这时A线程抢到CPU时间并执行。
    第四种情况:B线程当执行到30时会将CPU时间让掉,这时B线程抢到CPU时间并执行。

4.3 isAlive():线程判活

方法isAlive()功能是判断当前线程是否处于活动状态。活动状态就是线程启动且尚未终止,比如正在运行或准备开始运行。

4.4 interrupt():线程中断

4.4.1 interrupt简述

interrupt()单词本身的含义是中断、终止、阻断。当某个线程收到这个信号(命令)的时候,会将自生的状态属性置为“interrupted”,但是线程本身并不会立刻终止。程序员需要根据这个状态属性,自行决定如何进行线程的下一步活动。

我们经常通过判断线程的中断标记来控制线程,但需要注意的是interrupt,并不是线程处于任何状态,都可以接收interrupt信号。如果在收到interrupt信号时,线程处于阻塞状态(wait()、wait(time)或者sleep引起的),那么线程将会抛出InterruptedException异常:

  1. 当线程处于"运行"状态的时候,其线程对象中的isinterrupt属性被置为true。
  2. 当线程处于"阻塞"状态的时候,抛出InterruptedException异常。注意,如果抛出了InterruptedException异常,那么其isinterrupt属性不会被置为true。任然是false
  • 实例代码
package cn.ji2h.othertest;

public class InterruptDemo {

    public static void main(String[] args) throws Exception {
        // thread one线程
        Thread threadOne = new Thread(new Runnable() {
            @Override
            public void run() {
                Thread currentThread = Thread.currentThread();
                // 并不是线程收到interrupt信号,就会立刻种种;
                // 线程需要检查自生状态是否正常,然后决定下一步怎么走。
                while(!currentThread.isInterrupted()) {
                    /*
                     * 这里打印一句话,说明循环一直在运行
                     * 但是正式系统中不建议这样写代码,因为没有中断(wait、sleep)的无限循环是非常耗费CPU资源的
                     * */
                    System.out.println("Thread One 一直在运行!");
                }

                System.out.println("Thread One 正常结束!" + currentThread.isInterrupted());
            }
        });

        // thread two线程
        Thread threadTwo = new Thread(new Runnable() {
            @Override
            public void run() {
                Thread currentThread = Thread.currentThread();
                while(!currentThread.isInterrupted()) {
                    synchronized (currentThread) {
                        try {
                            // 通过wait进入阻塞
                            currentThread.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace(System.out);
                            System.out.println("Thread Two 由于中断信号,异常结束!" + currentThread.isInterrupted());
                            return;
                        }
                    }
                }

                System.out.println("Thread Two 正常结束!");
            }
        });

        threadOne.start();
        threadTwo.start();
        // 您可以通过eclipse工具在这里打上端点,以保证threadOne和threadTwo完成了启动
        // 当然您还可以使用其他方式来确保这个事情
        System.out.println("两个线程正常运行,现在开始发出中断信号");
        threadOne.interrupt();
        threadTwo.interrupt();
    }

}

上面的示例代码中,我们创建了两个线程threadOne和threadTwo。其中threadOne线程在没有任何阻塞的情况下一直循环运行(虽然这种方式在正式环境中不建议使用,但是这里我们是为了模拟这种线程运行状态),每循环一次都检测该线程的isInterrupt属性的值,如果发现值为true则终止循环;另一个threadTwo线程,在启动后马上进入阻塞状态,等待唤醒(实际上没有其他线程会唤醒它,以便模拟线程阻塞的状态)。

  • 运行结果
Thread One 一直在运行!
Thread One 一直在运行!
两个线程正常运行,现在开始发出中断信号
Thread One 一直在运行!
Thread One 正常结束!true
java.lang.InterruptedException
	at java.lang.Object.wait(Native Method)
	at java.lang.Object.wait(Object.java:502)
	at cn.ji2h.othertest.InterruptDemo$2.run(InterruptDemo.java:34)
	at java.lang.Thread.run(Thread.java:748)
Thread Two 由于中断信号,异常结束!false

threadOne线程的isInterrupt属性被成功置为true,循环正常结束线程运行正常完成;而threadTwo线程由于处于wait()引起的阻塞状态,所以在收到interrupt信号后,抛出了异常,其isInterrupt属性依然是false;

4.4.2 thread.isInterrupted()和Thread.interrupted()的区别

在Java的线程基本操作方法中,有两种方式获取当前线程的isInterrupt属性。一种是对象方法thread.isInterrupted(),另一种是Thread类的静态方法Thread.interrupted()。这两个方法看似相同,实际上是有区别的,我们来看看Java的Thread类的这部分源代码:

public class Thread implements Runnable {
    ......

    public static boolean interrupted() {
        return currentThread().isInterrupted(true);
    }

    public boolean isInterrupted() {
        return isInterrupted(false);
    }

    /**
     * Tests if some Thread has been interrupted.  The interrupted state
     * is reset or not based on the value of ClearInterrupted that is
     * passed.
     */
    private native boolean isInterrupted(boolean ClearInterrupted);

    ......
}

可以看到,对象方法的thread.isInterrupted()和静态方法的Thread.interrupted()都是调用的JNI底层的isInterrupted()方法。但是区别在于这个ClearInterrupted参数,前者传入的false,后者传入的是true。相信各位读者都已经猜出其中的含义了,ClearInterrupted参数向操作系统层指明是否在获取状态后将当前线程的isInterrupt属性重置为(或者叫恢复,或者叫清除)false。

这就意味着当某个线程的isInterrupt属性成功被置为true后,如果您使用对象方法thread.isInterrupted()获取值,无论您获取多少次得到的返回值都是true;但是如果您使用静态方法Thread.interrupted()获取值,那么只有第一次获取的结果是true,随后线程的isInterrupt属性将被恢复成false,后续无论使用Thread.interrupted()调用还是使用thread.isInterrupted()调用,获取的结果都是false。

4.5 join():线程礼让

让一个线程等待另一个线程完成才继续执行。如A线程执行体中调用B线程的join()方法,则A线程被阻塞,直到B线程执行完为止,转换为就绪状态,得到CPU之后,A才能得以继续执行。

4.6 wait()、notify()、notifyAll():线程通信

  1. wait()
    导致当前线程等待并使其进入到等待阻塞状态。直到其他线程调用该同步锁对象的notify()或notifyAll()方法来唤醒此线程。
  2. notify()
    唤醒在此同步锁对象上等待的单个线程,如果有多个线程都在此同步锁对象上等待,则会任意选择其中某个线程进行唤醒操作,只有当前线程放弃对同步锁对象的锁定,才可能执行被唤醒的线程。
  3. notifyAll()
    唤醒在此同步锁对象上等待的所有线程,只有当前线程放弃对同步锁对象的锁定,才可能执行被唤醒的线程。
  • 使用实例
package cn.ji2h.othertest;

public class WaitDemo {
    public static void main(String[] args) {
        Account account = new Account("123456", 0);

        Thread drawMoneyThread = new DrawMoneyThread("取钱线程", account, 700);
        Thread depositeMoneyThread = new DepositeMoneyThread("存钱线程", account, 700);

        drawMoneyThread.start();
        depositeMoneyThread.start();
    }
}

class Account{
    private String accountNo;
    private double balance;
    // 标识账户中是否已有存款
    private boolean flag = false;

    public Account() {

    }

    public Account(String accountNo, double balance) {
        this.accountNo = accountNo;
        this.balance = balance;
    }

    public String getAccountNo() {
        return accountNo;
    }

    public void setAccountNo(String accountNo) {
        this.accountNo = accountNo;
    }

    public double getBalance() {
        return balance;
    }

    public void setBalance(double balance) {
        this.balance = balance;
    }

    /**
     * 存钱
     *
     * @param depositeAmount
     */
    public synchronized void deposite(double depositeAmount, int i) {

        if (flag) {
            // 账户中已有人存钱进去,此时当前线程需要等待阻塞
            try {
                System.out.println(Thread.currentThread().getName() + " 开始要执行wait操作" + " -- i=" + i);
                wait();
                // 1
                System.out.println(Thread.currentThread().getName() + " 执行了wait操作" + " -- i=" + i);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } else {
            // 开始存钱
            System.out.println(Thread.currentThread().getName() + " 存款:" + depositeAmount + " -- i=" + i);
            setBalance(balance + depositeAmount);
            flag = true;

            // 唤醒其他线程
            notifyAll();

            // 2
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "-- 存钱 -- 执行完毕" + " -- i=" + i);
        }
    }

    /**
     * 取钱
     *
     * @param drawAmount
     */
    public synchronized void draw(double drawAmount, int i) {
        if (!flag) {
            // 账户中还没人存钱进去,此时当前线程需要等待阻塞
            try {
                System.out.println(Thread.currentThread().getName() + " 开始要执行wait操作" + " -- i=" + i);
                wait();
                System.out.println(Thread.currentThread().getName() + " 执行了wait操作" + " -- i=" + i);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } else {
            // 开始取钱
            System.out.println(Thread.currentThread().getName() + " 取钱:" + drawAmount + " -- i=" + i);
            setBalance(getBalance() - drawAmount);

            flag = false;

            // 唤醒其他线程
            notifyAll();

            System.out.println(Thread.currentThread().getName() + "-- 取钱 -- 执行完毕" + " -- i=" + i); // 3
        }
    }
}
class DrawMoneyThread extends Thread{

    private Account account;
    private double amount;

    public DrawMoneyThread(String threadName, Account account, double amount) {
        super(threadName);
        this.account = account;
        this.amount = amount;
    }

    public void run() {
        for (int i = 0; i < 5; i++) {
            account.draw(amount, i);
        }
    }

}

class DepositeMoneyThread extends Thread{

    private Account account;
    private double amount;

    public DepositeMoneyThread(String threadName, Account account, double amount) {
        super(threadName);
        this.account = account;
        this.amount = amount;
    }

    public void run() {
        for (int i = 0; i < 5; i++) {
            account.deposite(amount, i);
        }
    }

}
  • 运行结果
取钱线程 开始要执行wait操作 -- i=0
存钱线程 存款:700.0 -- i=0
存钱线程-- 存钱 -- 执行完毕 -- i=0
存钱线程 开始要执行wait操作 -- i=1
取钱线程 执行了wait操作 -- i=0
取钱线程 取钱:700.0 -- i=1
取钱线程-- 取钱 -- 执行完毕 -- i=1
取钱线程 开始要执行wait操作 -- i=2
存钱线程 执行了wait操作 -- i=1
存钱线程 存款:700.0 -- i=2
存钱线程-- 存钱 -- 执行完毕 -- i=2
存钱线程 开始要执行wait操作 -- i=3
取钱线程 执行了wait操作 -- i=2
取钱线程 取钱:700.0 -- i=3
取钱线程-- 取钱 -- 执行完毕 -- i=3
取钱线程 开始要执行wait操作 -- i=4
存钱线程 执行了wait操作 -- i=3
存钱线程 存款:700.0 -- i=4
存钱线程-- 存钱 -- 执行完毕 -- i=4
取钱线程 执行了wait操作 -- i=4

1.wait()方法执行后,当前线程立即进入到等待阻塞状态,其后面的代码不会执行;
2.notify()/notifyAll()方法执行后,将唤醒此同步锁对象上的(任意一个-notify()/所有-notifyAll())线程对象,但是,此时还并没有释放同步锁对象,也就是说,如果notify()/notifyAll()后面还有代码,还会继续执行,直到当前线程执行完毕才会释放同步锁对象;
3.notify()/notifyAll()执行后,如果下面有sleep()方法,则会使当前线程进入到阻塞状态,但是同步对象锁没有释放,依然自己保留,那么一定时候后还是会继续执行此线程,接下来同2;
4.wait()/notify()/nitifyAll()完成线程间的通信或协作都是基于相同对象锁的,因此,如果是不同的同步对象锁将失去意义,同时,同步对象锁最好是与共享资源对象保持一一对应关系;
5.当wait线程唤醒后并执行时,是接着上次执行到的wait()方法代码后面继续往下执行的。

上面的例子相对来说比较简单,只是为了简单示例wait()/notify()/noitifyAll()方法的用法,但其本质上说,已经是一个简单的生产者-消费者模式了。

5.线程的优先级

java中的线程优先级的范围是1~10,默认的优先级是5。每个线程默认的优先级都与创建它的父线程具有相同的优先级。默认情况下,mian线程具有普通优先级。“高优先级线程”会优先于“低优先级线程”执行。Thread提供了setPriority(int newPriority)和getPriority()方法来设置和返回线程优先级。
需要注意的是,优先级只能保证尽可能的优先执行,不能保证绝对优先执行,这个和具体的操作系统调度有关系。

Thread类有3个静态常量:

 MAX_PRIORITY = 10
 MIN_PRIORITY = 1
 NORM_PRIORITY = 5

6.线程的同步

java允许多线程并发控制,当多个线程同时操作一个可共享的资源变量时(如数据的增删改查),将会导致数据不准确,相互之间产生冲突,因此加入同步锁以避免在该线程没有完成操作之前,被其他线程的调用,从而保证了该变量的唯一性和准确性。

保证线程同步需要添加synchronized字段。

6.1 synchronized可标注的位置

在JAVA中synchronized关键字可以加载很多位置。您可以在一个方法定义上加synchronized关键字、也可以在方法体中加synchronized关键字、还可以在static块中加synchronized关键字。

// 代码片段1
static {
    synchronized(ThreadLock.class) {
    }
}
// 代码片段2
public synchronized void someMethod() {
}
// 代码片段3
public synchronized static void someMethod() {
}
// 代码片段4
public static void someMethod() {
    synchronized (ThreadLock.class) {
    }
}
// 代码片段5
public void someMethod() {
    synchronized (ThreadLock.class) {
    }
}

但是不同位置的synchronized的关键字,代表的含义是不一样的。synchronized(){}这个写法,开发人员可以指定需要检查的对象锁。但是当synchronized加载在方法上时,有的读者就感觉有点混淆了。这里详细说明一下:

synchronized关键字加载在非静态方法上时:
其代表的含义和synchronized(this){}的意义相同。即对所拥有这个方法的对象进行对象锁状态检查。
synchronized关键字加载在静态方法上时:
其代表的含义和synchronized(Class.class)的意义相类似。即对所拥有这个方法的类的对象进行对象锁状态检查(类本身也是一个对象哦 _

  • 使用实例
package cn.ji2h.othertest;

import cn.ji2h.util.LogUtil;

public class SynchronizedDemo {

    public static void main(String[] args){
        Thread ticket1 = new Thread(new Ticket());
        Thread ticket2 = new Thread(new Ticket());
        Thread ticket3 = new Thread(new Ticket());
        ticket1.start();
        ticket2.start();
        ticket3.start();
    }
}

class Ticket implements Runnable{

    private static volatile int ticket = 10;

    @Override
    public void run() {
        synchronized (Ticket.class){
            for(int i = 0; i< 20; i++){
                if(ticket > 0){
                    ticket --;
                    LogUtil.logger.info("现在还有票数: " + ticket);
                }
            }
        }

    }
}
  • 运行结果

不加synchronized的运行结果

2018-10-02 11:59:16,432 [Thread-2] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 7
2018-10-02 11:59:16,433 [Thread-1] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 7
2018-10-02 11:59:16,434 [Thread-1] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 5
2018-10-02 11:59:16,435 [Thread-1] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 4
2018-10-02 11:59:16,435 [Thread-1] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 3
2018-10-02 11:59:16,435 [Thread-1] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 2
2018-10-02 11:59:16,435 [Thread-1] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 1
2018-10-02 11:59:16,435 [Thread-1] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 0
2018-10-02 11:59:16,433 [Thread-0] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 7
2018-10-02 11:59:16,434 [Thread-2] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 6

加synchronized的运行结果

2018-10-02 11:55:51,315 [Thread-0] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 9
2018-10-02 11:55:51,323 [Thread-0] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 8
2018-10-02 11:55:51,324 [Thread-0] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 7
2018-10-02 11:55:51,324 [Thread-0] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 6
2018-10-02 11:55:51,326 [Thread-0] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 5
2018-10-02 11:55:51,326 [Thread-0] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 4
2018-10-02 11:55:51,327 [Thread-0] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 3
2018-10-02 11:55:51,327 [Thread-0] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 2
2018-10-02 11:55:51,327 [Thread-0] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 1
2018-10-02 11:55:51,328 [Thread-0] INFO  cn.ji2h.util.LogUtil - 现在还有票数: 0

7. 参考链接

https://www.cnblogs.com/xiaoxi/p/7581899.html
https://blog.****.net/column/details/yinwenjiethread.html