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 线程的生命周期
请结合图片和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异常:
- 当线程处于"运行"状态的时候,其线程对象中的isinterrupt属性被置为true。
- 当线程处于"阻塞"状态的时候,抛出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():线程通信
- wait()
导致当前线程等待并使其进入到等待阻塞状态。直到其他线程调用该同步锁对象的notify()或notifyAll()方法来唤醒此线程。 - notify()
唤醒在此同步锁对象上等待的单个线程,如果有多个线程都在此同步锁对象上等待,则会任意选择其中某个线程进行唤醒操作,只有当前线程放弃对同步锁对象的锁定,才可能执行被唤醒的线程。 - 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