多线程
并行与并发
- 并发:多个进程在一个CPU下采用时间片轮转的方式,在一段时间之内,让多个进程都得以推进,称之为并发(实际上并没有同时进行)。
- 并行:多个进程在多个CPU下分别,同时进行运行,这称之为并行。
并发与并行类似于工厂中的流水线,要扩大产量,1是考虑建造多个工厂,这就是并行,2是考虑每个工厂中新增流水 线,这就类似并发。
上下文切换
多核cpu下,多线程是并行工作的,如果线程数多,单个核又会并发的调度线程,运行时会有上下文切换的概念cpu执行线程的任务时,会为线程分配时间片,以下几种情况会发生上下文切换。
- 线程的cpu时间片用完
- 垃圾回收
- 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法
当发生上下文切换时,操作系统会保存当前线程的状态,并恢复另一个线程的状态,jvm中有块内存地址叫程序计数器,用于记录线程执行到哪一行代码,是线程私有的。
进程与线程
进程(系统资源分配得最小单位)
- 当一个程序被运行,就开启了一个进程, 比如启动了qq
- 程序由指令和数据组成,指令要运行,数据要加载,指令被cpu加载运行,数据被加载到内存,指令运行时可由cpu调度硬盘、网络等设备
线程(系统调度的最小单位)
- 一个进程内可分为多个线程(一个进程中最少有一个主线程)
- 一个线程就是一个指令流,cpu调度的最小单位,由cpu一条一条执行指令
进和线程的区别
多线程的好处
- 程序运行的更快!
- 充分利用cpu资源。
多线程的应用场景
- 工作量大,执行时间比较长的任务
- 让阻塞的代码不影响后续代码的执行(后续的代码在其他线程执行)
Thread类的常见方法
静态方法:作用在当前线代码所在的线程
- static Thread currentThread() 获取代码行所在的当前线程
- static void sleep(long millis) 让当前线程休眠给定的时间,会抛出InterruptedException异常
- static void **yield()**当前线程让步,从运行态变为就绪态
- static boolean interrupted() 判断当前线程的中断标志被设置,清除中断标志
实例构造方法:作用在调用的线程对象上
- void start() 启动线程,申请系统调度该线程
- void run()定义线程的任务
- void interrupt()中断一个线程
- booelan isIntrrupted()
- void join()无条件等待:当前线程阻塞并等待,一直等到调用的线程执行完毕,也可以传入一个参数,表示限时等待:当前线程阻塞并等待,直到调用线程执行完毕,或者时间到了,再往下执行
- boolean isAlive()是否存活,即简单的理解,为 run 方法是否运行结束了
- String getName()获取线程名称
- int getPriority() 获取线程优先级 0-10的数值
- boolean isDaemon()是否为后台线程,需要记住一点:JVM会在一个进程的所有非后台线程结束后,才会结束运行。
多线程的基本使用
线程的状态 state
大家不要被这个状态转移图吓到,我们重点是要理解状态的意义以及各个状态的具体意思。
我们来举个例子:
- 刚把李四、王五找来,给他们在安排任务,没让他们行动起来,就是 NEW 状态;
- 当李四、王五开始去窗口排队,等待服务,就进入到 RUNNABLE
状态。该状态并不表示已经被银行工作人员开始接待,排在队伍中也是属于该状态,即可被服务的状态,是否开始服务,则看调度器的调度; - 当李四、王五因为一些事情需要去忙,例如需要填写信息、回家取证件、发呆一会等等时,进入 BLOCKED 、WATING 、 TIMED_WAITING 状态
- 如果李四、王五已经忙完,为 TERMINATED 状态。
线程的创建 new
- 继承Thread类
// 方法一 继承Thread 单继承
static class MyThread extends Thread {
@Override
public void run() {
System.out.println("子线程" + Thread.currentThread().getName());
}
}
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
System.out.println("主线程" + Thread.currentThread().getName());
}
- 实现Runnanle接口(重写run()方法)
//方法一 实现Runnable接口
static class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("线程名:" +
Thread.currentThread().getName());
}
}
public static void main(String[] args) {
// 创建 Runnable 子对象
MyRunnable myRunnable = new MyRunnable();
// 创建线程
Thread thread = new Thread(myRunnable);
// 启动线程
thread.start();
}
//方法二 创建一个匿名 Runnable 常用
public static void main(String[] args) {
// 创建一个匿名 Runnable 类
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("当前线程:" +
Thread.currentThread().getName());
}
});
thread.start();
}
//方法三 lambda + runnable 常用
public static void main(String[] args) {
Thread thread = new Thread(() -> {
System.out.println("当前线程" + Thread.currentThread().getName());
});
thread.start();
}
- 实现Callable接口(重写call()方法)可以拿到线程的返回值(常用)
//方法以 可以拿到线程的返回值(常用)
static class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
int num = new Random().nextInt(10);
System.out.println(String.format("线程%s 产生的随机数: %d",Thread.currentThread().getName(),num));
return num;
}
}
public static void main6(String[] args) throws ExecutionException, InterruptedException {
// 1.创建 Callable 子对象
MyCallable callable = new MyCallable();
// 2.使用 FutrueTask 接收 Callable
FutureTask<Integer> futureTask = new FutureTask<>(callable);
// 3.创建线程并设置任务
Thread thread = new Thread(futureTask);
// 执行线程
thread.start();
// 得到线程的执行结果
int ret = futureTask.get();
System.out.println("拿到的随机数为ret:" + ret);
}
线程的启动 start()
之前我们已经看到了如何通过覆写 run 方法创建一个线程对象,但线程对象被创建出来并不意味着线程就开始运行了。
- 覆写 run 方法是提供给线程要做的事情
- 而调用 start() 方法,就是喊一声:”行动起来!“,线程才真正独立去执行了。
start()run()的区别:
- start()是线程的开启方法,它使用新的线程来执行任务,run()是一个对象的普通方法,它使用当前线程来执行任务
- start()方法可以执行一次,但run()可以调用多次
- 如果不调用start(),而是直接调用run(),相当于java对象直接调用普通的实例方法
线程的休眠 sleep()
休眠一个线程,会抛出InterredExcep异常
-
方式一
Thread.sleep( 1000);// 休眠 1 秒 -
方式二
TimeUnit.SECONDS.sleep(1); // 休眠 1 秒
TimeUnit.HOURS.sleep(1); // 休眠 1 小时 -
方式三
Thread.sleep(TimeUnit.SECONDS.toMillis(1));//休眠一秒
线程的等待 join()
有时,我们需要等待一个线程完成它的工作后,才能进行自己的下一步工作。
线程的中断 Interrupt
当线程进入运行态时,如果发生紧急情况,我们可以中断线程。
目前常见的有以下两种方式:
-
1.使用自定义的全局变量来终止(但当线程阻塞时不行终止线程,舍弃)
-
2.实例方法Thread.currentThread().interrupt() 设置代用线程的中断标志位为true,至于是否被中断,由当前线程决定(如果线程处于阻塞(调用wait/join/sleep),则会中断并且抛出InterruptedException异常,并重置标志位)
-
3.实例方法Thread.currentThread().isInterrupted() 判断指定线程的中断标志被设置,不清除中断标志
-
4.静态方法Thread.interrupted() 判断当前线程的中断标志被设置,清除中断标志(静态的,大家都可以用,所以使用完后要恢复,方便下一次使用)
线程通信 wait()notify()
所谓的线程通信是指在一个线程中的操作可以影响到另一个线程。
wait()线程等待
其实wait()方法就是使线程停止运行。
- wait方法在执行前必须先加锁(wait配合synchronized一起使用)
- wait和notifiy在在配合使用时一定要操作同一把锁
- wait在不传递任何参数的情况下会进入waiting状态(其实底层调用了wait(0)这个方法),当传入一个大于0的整数时,它会进入timed_waiting状态
- wait在执行时会释放锁
notify()线程唤醒(随机唤醒一个)
notifyAll()线程唤醒(全部唤醒)
wait和sleep的区别
相同点
- wait和sleep都是让线程进入休眠状态
- wait和sleep在执行的过程中都可以接收到线程终止的通知
不同点
- wait必须配合synchronized一起使用,而sleep不用
- wait会释放锁,而sleep不会释放锁
- wait是Object的方法,而sleep是Thread(线程)的方法
- 默认情况下wait(不传递任何参数或者参数为0的情况下)它会进入waiting状态,而sleep会进入timed_waiting状态
- 使用wait时可以主动的唤醒线程,而使用sleep时不能主动地唤醒线程
sleep(0)和wait(0)的区别
- sleep(0)表示过了0ms之后会继续执行,而wait(0)会一直休眠
- sleep(0)会重新出发一次CPU竞争
为什么wait会释放锁,而sleep不会释放锁?
- sleep必须要传入一个最大等待时间,也就是说sleep是可控的(对于时间层面来说),而wait是不可以传递参数的,如果wait不主动释放锁的话就,没被唤醒前就会一直阻塞
为什么wait是Object的方法,而sleep是Thread的方法?
wait需要操作锁,而锁是属于对象级别的(存放在对象头当中)它不是线程级别的,一个线程可以有多把锁,为了灵活起见,所以讲wait放在了Object当中
解决wait/notify随机唤醒的问题(指定唤醒某个线程)
- LockSupport park()/unpark(线程)
public class Test {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
// 让线程进行休眠
LockSupport.park();
System.out.println("唤醒 t1");
}
}, "t1");
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
// 让线程进行休眠
LockSupport.park();
System.out.println("唤醒 t2");
}
}, "t2");
Thread t3 = new Thread(new Runnable() {
@Override
public void run() {
// 让线程进行休眠
LockSupport.park();
System.out.println("唤醒 t3");
}
}, "t3");
t1.start();
t2.start();
t3.start();
LockSupport.unpark(t2);
}
}
- LockSupporrt()虽然不会报Interrupt的异常,但依然可以监听到线程终止的指令
ublic class Main {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("park 之前 Interrupt 状态:" +
Thread.currentThread().isInterrupted());
// 线程进入休眠
LockSupport.park();
System.out.println("park 之后 Interrupt 状态:" +
Thread.currentThread().isInterrupted());
}
}, "t1");
// 启动线程
t1.start();
Thread.sleep(100);
// 中止线程
t1.interrupt();
// 唤醒线程 t1
LockSupport.unpark(t1);
}
}
线程安全
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的,否则即为不安全的。
多线程不安全的原因
CPU是抢占式执行的(万恶之源)
非原子性
什么是原子性
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还 没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。
那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。
有时也把这个现象叫做同步互斥,表示操作是互相排斥的。
一条 java 语句不一定是原子的,也不一定只是一条指令
比如 n++,其实是由三步操作组成的:
- 从内存把数据读到 CPU
- 进行数据更新
- 把数据写回到 CPU
或者new 一个对象, ListNode tmp = new ListNode();
- 创建初始化内存空间
- new对象
- 赋值给变量
内存可见性
主内存-工作内存
为了提高效率,JVM在执行过程中,会尽可能的将数据在工作内存中执行,但这样会造成一个问题,共享变量在多线程之间不能及时看到改变,这个就是可见性问题
编译器优化/指令重排序
操作的是一个变量
解决线程不安全问题
volatile关键字
- 可以解决内存不可见和指令重排序的问题,
- 但不可以解决原子性的问题
使用场景: - 写操作不依赖共享变量,赋值的是一个常量(依赖共享变量是原子性操作)
- 作用在读,写依赖其他手段(加锁)
synchronized 关键字(监视器锁monitor lock)
synchronized的底层是使用操作系统的mutex lock实现的。(jvm层面来解决问题的)
- 当线程释放锁时,JMM会把该线程对应的工作内存中的共享变量刷新到主内存中
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
synchronized用的锁是存在Java对象头里的(Java层面)。
synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题; 同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。
synchronized实现:
- 针对操作系统层面,它是依靠互斥锁mutex
- 针对JVM层面,它是依靠monitor来实现
- 针对Java语言来说,是将锁信息存放在对象头(标识,锁的状态,所得拥有者)
synchronized的三种使用场景
- 使用synchronized修饰代码块(可以给任意对象进行加锁)
- 使用synchronized来修是静态方法(对当前的类进行加锁)
- 使用synchronized来修饰普通实例方法(对当前类实例进行加锁)
sunchronized锁升级的过程(JDK1.6以后)
lock()手动加锁
加锁的方式
lock的使用场景
- lock只能修饰代码块
注意事项:
lock()操作一定要放在try外面,如果放在try里面可能会造成两个问题:
- 如果try里面抛异常了,还没有加锁成功就执行了finally里面的释放所得操作(但此时还没有得到锁呢)
- 如果放在try里面,如果没有锁的情况下试图释放锁,这个时候产生的异常就会将业务代码(也就是try里面的异常)给覆盖掉,增加了代码调试的难度
公平锁和非公平锁
- 公平锁:一个线程释放锁,(主动)唤醒“需要得到锁”的就绪队列里的线程来得到锁
- 非公平锁:当一个线程释放锁之后,另一个线程刚好执行到获取锁的代码就直接可以获取锁(效率更高)
在Java语言中所有的锁默认都是非公平锁(synchronized和ReentrantLock()默认都是非公平锁),但lock可以显示声明公平锁
Lock lock = new ReentrantLock(true);
synchronized和lock的区别
- synchronized自行进行加锁和释放锁,二lock需要手动进行加锁和解锁
- lock是Java层面锁的实现的,二synchronized是JVM层面实现的
- synchronized可以修饰代码块、静态方法、实例方法,而lock只能修饰代码块
- synchronized只能实现非公平锁,但lock可以实现非公平锁和公平锁
- lock的灵活性更高(tryLock)
死锁
死锁:在两个或者两个以上的线程运行中,因为资源抢占而造成线程一直等待的问题。
简易的死锁代码
public class text {
public static void main(String[] args) {
//定义两个锁对象
Object lockA = new Object();
Object lockB = new Object();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
synchronized(lockA) {
String threadName = Thread.currentThread().getName();
System.out.println(threadName + "得到lockA等待lockB");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized(lockB) {
System.out.println("Wait B");
}
}
}
}, "t1");
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
System.out.println(threadName + "得到lockB等待lockA" );
synchronized(lockB) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized(lockA){
System.out.println("Wait A");
}
}
}
}, "t2");
t1.start();
t2.start();
}
}
造成死锁的四个条件:
- 互斥条件:当资源被一个线程拥有之后,就不能被其他的线程拥有了(不可更改)
- 请求拥有条件:当一个线程拥有了一个资源之后又试图请求另一个资源(可以解决)
- 不可剥夺条件:当一个资源被一个线程拥有之后,如果不是这个线程主动释放此资源的情况下,其他线程不能拥有此资源(不可更改)
- 环路等待条件:两个或两个以上的线程在拥有了资源之后,试图获取对方资源的时候形成了一个环路(可以解决)
如何解决死锁
- 控制加锁的顺序(解决环路的等待条件)
多线程案例
单例模式
- 饿汉模式
class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
- 懒汉模式(单线程版)
class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
- 懒汉模式(多线程版,效率低)
class Singleton {
private static Singleton instance = null;
private Singleton() {}
public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
懒汉模式(双重校验锁版,性能高)
class Singleton {
private static volatile Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
- 用静态内部类实现一个单例模式
class Singleton {
/** 私 有 化 构 造 器 */
private Singleton() {
}
/** 对外提供公共的访问方法 */
public static Singleton getInstance() {
return UserSingletonHolder.INSTANCE;
}
/** 写一个静态内部类,里面实例化外部类 */
private static class UserSingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
}
public class Main {
public static void main(String[] args) {
Singleton u1 = Singleton.getInstance();
Singleton u2 = Singleton.getInstance();
System.out.println("两个实例是否相同:"+ (u1==u2));
}
}
- 用枚举实现一个单例模式
public enum TestEnum {
INSTANCE;
public TestEnum getInstance(){
return INSTANCE;
}
public static void main(String[] args) {
TestEnum singleton1=TestEnum.INSTANCE;
TestEnum singleton2=TestEnum.INSTANCE;
System.out.println("两个实例是否相同:"+(singleton1==singleton2));
}
}
阻塞式队列
生产者消费者模型
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不 直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻 塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了 生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
/**
* 实现阻塞队列:
* 1. 线程安全问题:在多线程下,put、take不具有原子性,4个属性,不具有可见性
* 2. put操作,如果存满了,需要阻塞等待。take如果是空,需要阻塞等待
* @param <T>
*/
public class MyBlockingQueue<T> {
//使用数组实现循环队列
private Object[] queue;
//存放元素的索引
private int putIndex;
//取元素的索引
private int takeIndex;
//当前存放元素的数量
private int size;
public MyBlockingQueue(int len){
queue = new Object[len];
}
//存放元素:需要考虑1.putIndex超过数组的长度,2.size达到数组最大长度
public synchronized void put(T e) throws InterruptedException {
//当阻塞等待到被唤醒并再次竞争成功对象锁,恢复后往下执行时,条件可能会被其他线程修改
while(size == queue.length){
this.wait();//wait();
}
//存放到数组中放元素的位置
queue[putIndex] = e;
//存放位置超过数组的最大索引,需要取模放在0位置
putIndex = (putIndex+1)%queue.length;
size++;
notifyAll();//this.notifyAll(); ---> 和synchronized加锁的对象一样
}
//取元素
public synchronized T take() throws InterruptedException {
while (size == 0){
wait();
}
T t = (T) queue[takeIndex];
queue[takeIndex] = null;
takeIndex = (takeIndex+1)%queue.length;
size--;
notifyAll();
return t;
}
public synchronized int size(){
return size;
}
public static void main(String[] args) {
MyBlockingQueue<Integer> queue = new MyBlockingQueue<>(10);
//多线程的调试方式:1.写打印语句 2.jconsole/jstack 3.debug在有些场景不一定适用
for(int i=0; i<3; i++){
new Thread(new Runnable() {
@Override
public void run() {
try {
for(int j=0; j<1000; j++){
queue.put(j);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
for(int i=0; i<3; i++){
new Thread(new Runnable() {
@Override
public void run() {
try {
for(;;){
int i = queue.take();
System.out.println(Thread.currentThread().getName()+": "+i);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
}
线程池
为什么要有线程池?
如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。线程池就使得线程可以复用。线程池最大的好处就是减少每次启动、销毁线程的损耗。
线程池概念:
其实就是一个容纳多个线程的容器,其中的线程可以反复使用,省去了频繁创建线程对象的操作,无需反复创建线程而消耗过多资源。
合理使用线程池能够带来的三个好处:
- 降低资源消耗,减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务。
- 提高响应速度,当任务到达时,任务可以不需要等线程创建就能立即执行
- 提高线程的可管理性,可以根据系统的承受能力,调整线程池中工作线线程的数目,防止因为消耗过多的内
存,而把服务器累趴下(每个线程需要大约1MB内存,线程开的越多,消耗的内存也就越大,最后死机)。
线程池的使用:
- Java里面线程池的顶级接口是 java.util.concurrent.Executor ,但是严格意义上讲 Executor 并不是一个线程 池,而只是一个执行线程的工具。真正的线程池接口是 java.util.concurrent.ExecutorService 。
Executors类中创建线程池的方法如下:
- public static ExecutorService newFixedThreadPool(int nThreads):返回线程池对象。(创建的是有界线 程池,也就是池中的线程个数可以指定最大数量)
使用线程池中线程对象的步骤:
- 创建线程池对象。 ExecutorService service = Executors.newFixedThreadPool(2);
- 创建Runnable接口子类对象。(task) MyRunnable r = new MyRunnable();
- 提交Runnable接口子类对象。(take task) service.submit®;service.submit®;
- 关闭线程池(一般不做)。service.shutdown();
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class ThreadPoolExecutorTest {
public static void main(String[] args) {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
5,//核心线程数--->正式员工
10,//最大线程数--->正式员工+临时工
60,
TimeUnit.SECONDS,//idle线程的空闲时间:临时工最大的存活时间,超过时间就解雇
new LinkedBlockingQueue<>(),//阻塞队列:任务存放的地方(快递仓库)
new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {//线程池中定义的任务类r
return new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"开始执行了");
//r对象是线程池内部封装过的工作任务类(Worker),会一直循环等待的方式从阻塞队列中取任务来执行
r.run();
}
});
}
},//创建线程的工厂类:线程池创建线程时,调用该工厂的方法创建线程--->招聘员工的标准
new ThreadPoolExecutor.AbortPolicy()
/**
* 拒绝策略:达到最大线程数且阻塞队列已满,采取的拒绝策略
* AbortPolicy:直接抛RejectedExecutionException(不提供handler时的默认策略)
* CallerRunsPolicy:谁(某个线程)交给我(线程池)任务,我拒绝执行,由谁自己执行
* DiscardPolicy:交给我的任务,直接丢弃掉(从阻塞队列丢弃最新的任务(队尾))
* DiscardOldestPolicy:丢弃阻塞队列中最旧的任务(从阻塞队列丢弃最旧的任务(队首))
*/
);//线程池创建以后,只要有任务就自动执行
for(int i=0; i<20; i++){
final int j = i;
//线程池执行任务:execute、submit--->提交执行一个任务
pool.execute(new Runnable() {
@Override
public void run() {
System.out.println(j);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
}
}
后续还会些补充,如有问题请评论留言。。。。。。