java 多线程
Posted 一帘幽梦&nn
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了java 多线程相关的知识,希望对你有一定的参考价值。
一、概述
1. 基本概念
进程:进程是并发执行的程序在执行过程中分配和管理资源的基本单位,是一个动态概念,是竞争计算机系统资源(CPU、内存、IO等)的基本单位。
进程是系统中运行的一个程序,程序一旦运行就是进程,因此进程可以看成是程序执行的一个实例,比如:启动一个QQ.ext程序,在windows的任务管理器中就有一个进程。
进程中所包含的一个或多个执行单元称为线程。
线程:一个线程是进程的一个顺序执行流,是程序执行的最小单位,是比进程更小的独立运行的基本单位,线程也称为轻量级进程,是CPU调度和分配的基本单位,一个进程中可以包含多个线程。线程是一个进程里面不同的执行路径。比如:QQ能同时发送和接收消息,同时上传和下载文件等。
进程和线程的区别:每个进程拥有自己的一整套变量,而线程之间则共享数据。共享变量使线程之间的通信比进程之间的通信更有效、更容易。
并行:多个cpu实例或者多台机器同时执行一段处理逻辑,是真正的同时。
并发:通过cpu调度算法,让用户看上去同时执行,实际上从cpu操作层面不是真正的同时。并发往往在场景中有公用的资源,那么针对这个公用的资源往往产生瓶颈,
我们会用TPS或者QPS来反应这个系统的处理能力。
对于单核CPU来说,多个线程来回的切换执行,实际上在同一时间点只有一个线程真正的获得了CPU运行时间,那么此时的多线程还有高性能可言么?
线程安全:经常用来描绘一段代码。指在并发的情况之下,该代码经过多线程使用,线程的调度顺序不影响任何结果。这个时候使用多线程, 我们只需要关注系统的内存,
cpu是不是够用即可。反过来,线程不安全就意味着线程的调度顺序会影响最终结果。
同步:这里的同步,可以理解为“协同步调”,Java中的同步指的是通过人为的控制和调度,保证共享资源的多线程访问成为线程安全,来保证结果的准确。
我们的程序应该在保证结果准确的同时,提高性能,才是优秀的程序。线程安全的优先级高于性能。
线程的使用场合:
a. 线程通常用于在一个程序中需要同时完成多个任务的情况。我们可以将每个任务定义为一个线程,使他们得以一同工作;
b. 用于在单一线程中可以完成,但是使用多线程可以更快的情况。eg:下载文件。
线程的状态: 线程有以下六种状态
(1) New(新创建)
(2) Runnable(可运行):一旦调用start()方法,线程就处于runnable状态。一个正在运行中的线程仍然处于可运行状态。
(3) Blocked(被阻塞):当一个线程试图获取一个内部的对象锁,而该锁被其他线程持有,则该线程进入阻塞状态。
当所有其他线程释放该所,并且线程调度器允许本线程持有它的时候,该线程将变成非阻塞状态。
(4) Waiting(等待)
(5) Timed waiting(计时等待)
(6) Terminated(被终止)
二、线程的使用
1. 创建线程
方式一:继承Thread并重写run方法
Thread类是线程类,它的每一个实例都表示一个可以并发运行的线程。我们可以通过继承该类并重写run方法来定义一个具体的线程。
重写run方法的目的只要是为了让线程执行我们所需要的逻辑。
class MyThread1 extends Thread{ public void run(){ for(int i=0;i<1000;i++){ System.out.println("你是谁啊?"); } } } Thread t1 = new MyThread1(); t1.start();
注意:
不要调用Thread或Runnable对象的run启动线程,如果直接调用run方法,只会执行同一个线程中的任务,而不会启动新线程,即:主线程不会去创建一个新的线程去运行run方法里面的代码,而是在主线程 中调用Thread实现类的run方法(这相当多态),等这个run方法执行完以后,再接着执行后面的代码,这没有到达写线程的目的。
应该调用Thread.start()方法,这个方法将创建一个执行run方法的新线程。start()方法是由Thread类实现的,在这个方法里面有很多的实现。在调用start方法后,线程就处于可运行状态,这时主程序无序等待run方法执行完毕,而是继续执行下面的代码,只有等到新创建的这个线程获得了cup时间,才会开始执行run方法的线程体,这才是真正的实现了多线程。
线程启动后,何时运行,运行多久都听线程调度管理。线程对于线程调度的工作是不可控的,即:
a. 线程何时被分配CPU时间不可控,分配多久时间不可控。线程不能主动向线程调度要时间只能被动被分配;
b. 线程调度会尽可能将CPU时间均匀的分配给所有线程,但不保证一个线程一次这样规律的切换;
此种创建线程的方式有两个不足:
a. 由于需要继承Thread,而java又是单继承原则,这就导致当前类不能在继承其他类,很多时候会在实践开发中出现继承冲突问题。
b. 由于在线程内部重写run方法定义了当前线程要执行的任务,这就导致了线程与任务有一个强耦合关系,不利于线程重用。
方式二:单独定义线程任务,实现Runnable接口
实现Runnable接口并重写run方法来定义线程体,然后在创建线程的时候将Runnable的实例传入并启动线程,或者通过线程池来执行。
public class ThreadDemo2 { public static void main(String[] args) { Runnable r1=new MyRunnable1(); Runnable r2=new MyRunnable2(); Thread t1=new Thread(r1); Thread t2=new Thread(r2); t1.start(); t2.start(); } } class MyRunnable1 implements Runnable{ public void run() { for(int i=0;i<1000;i++){ System.out.println("你最喜欢的人是谁?"); } } } class MyRunnable2 implements Runnable{ public void run(){ for(int i=0;i<1000;i++){ System.out.println("我最喜欢的人是xxx!"); } } }
优点:
a. 可以将线程与线程中我们所要执行的业务逻辑分离开来,减少耦合;
b. 解决了java中单继承的问题,因为接口是多继承的;
使用匿名内部类完成以上两种线程的创建方式:
使用该方式可以简化编写代码的复杂度,当一个线程仅需要一个实例时,我们可以通过此方式创建。
//方式一 new Thread(){ public void run(){ for(int i=0;i<1000;i++){ System.out.println("你爱我么?"); } } }.start(); //方式二 new Thread(new Runnable(){ public void run(){ for(int i=0;i<1000;i++){ System.out.println("我爱你"); } } }).start();
方式三:实现Callable接口
Callable的 call() 方法提供返回值用来表示任务的运行结果,同时还可以抛出异常。
Callable创建的任务只能通过线程池执行,即通过线程池的 submit() 方法提交,submit()方法返回Future对象,通过Future对象的get()
方法可以获得具体的计算结果。而且get是个阻塞的方法,如果当前的这个任务未执行完,则一直等待当前的任务执行完毕,再返回这个任务的结果。同时它还提供了可以检查计算是否完成的方法。
对于Calleble来说,Future和FutureTask均可以用来获取任务执行结果,不过Future是个接口,FutureTask是Future的具体实现,
而且FutureTask还间接实现了Runnable接口,也就是说FutureTask可以作为Runnable任务提交给线程池。
package thread; import java.util.ArrayList; import java.util.List; import java.util.concurrent.*; /** * Callable接口的使用 * @author zls * @date 2020/3/31 */ public class CallableTest { public static void main(String[] args) throws ExecutionException, InterruptedException { // 1.创建一个线程池,同时创建3个线程在这个线程池内 ExecutorService pool = Executors.newFixedThreadPool(3); // 创建5个有返回值的任务(同时可以处理多个任务,多余的任务会排队,当处理完一个马上就会去接着处理排队中的任务。) List<Future> list = new ArrayList<Future>(); for (int i = 0; i < 5; i++) { // 2.创建任务对象 Callable c = new MyCallable(i + " "); // 3.线程执行任务并获取Future对象(从线程池中取出线程运行任务) Future f = pool.submit(c); list.add(f); } // 4.关闭线程池 pool.shutdown(); // 5.获取所有并发任务的运行结果 for (Future f : list) { // 从Future对象上获取任务的返回值,并输出到控制台 System.out.println("获取线程执行结果>>>:" + f.get().toString()); } } } /** * 创建线程的第三种方式,实现Callable接口 */ class MyCallable implements Callable<Object> { private String taskNum; MyCallable(String taskNum) { this.taskNum = taskNum; } /** * 带有返回结果的任务 */ @Override public Object call() throws Exception { Thread.sleep(500); long id = Thread.currentThread().getId(); return "当前线程id为:"+ id+", 任务为: "+taskNum; } }
此外,对于Callable创建线程,还可以通过不通过线程池来创建,参考:java创建线程的多种方式
补充:
Lamdba表达式的强大之处就是可以传递代码,而Runnable和Callable接口都是符合Lambda要求的函数式接口。因此,可以不用实现这两个接口,而是直接将接口的实现代码传递给Thread的target即可。
2. 线程常用api
2.1 线程优先级
线程优先级有10个等级,分别用数字1-10表示其中1最低,10最高,5为默认值。 理论上,优先级越高的线程,获取CPU时间片的次数多。
Thread max=new Thread(){ public void run(){ for(int i=0;i<10000;i++){ System.out.println("max"); } } }; Thread min=new Thread(){ public void run(){ for(int i=0;i<10000;i++){ System.out.println("min"); } } }; Thread norm=new Thread(){ public void run(){ for(int i=0;i<10000;i++){ System.out.println("norm"); } } }; max.setPriority(Thread.MAX_PRIORITY); min.setPriority(Thread.MIN_PRIORITY); min.start(); norm.start(); max.start();
2.2 守护线程
守护线程(daemon thread),是个服务线程,准确地来说就是服务其他的线程。
守护线程拥有结束自己声明周期的特征,而非守护线程不具备这个特点。
如果JVM中没有一个正在运行的非守护线程时,JVM会退出。JVM中的垃圾回收线程就是典型的守护线程,正因为如此,当垃圾回收线程还在运行着的时候,如果JVM退出了,垃圾回收线程(守护线程)可以自动的结束自己的生命周期。
使用场景:
通常来说,守护线程经常被用来执行一些后台任务,但是呢,你又希望在程序退出时,或者说 JVM 退出时,线程能够自动关闭,此时,守护线程是你的首选。
package thread; import lombok.SneakyThrows; /** * 当一个进程中的所有前台线程(非守护线程)都结束后,进程结束, 这时进程中的所有正在运行的后台线程(守护线程)都会被强制中断。 * * 解释: rose为非守护线程,jack为守护线程,当rose执行结束以后,jack自动结束。如果jack先执行完(结束),则rose会等到自己执行结束才结束。 * * @author zls * @date 2020/3/30 */ public class DaemonTest { public static void main(String[] args) { // rose:前台线程 Thread rose = new Thread() { @SneakyThrows public void run() { for (int i = 0; i < 3; i++) { System.out.println("rose: 我喜欢你!"); Thread.sleep(500); } System.out.println("rose: 你不喜欢我,我就....."); } }; // jack:后台线程 - 守护线程 Thread jack = new Thread() { @SneakyThrows public void run() { while (true) { System.out.println("jack: 我不喜欢你!"); Thread.sleep(500); } } }; //设置为守护线程,必须在线程启动前设置 jack.setDaemon(true); rose.start(); jack.start(); } }
2.3 yield()
该方法用于使当前线程主动让出当次CPU时间片回到Runnable状态,等待分配时间片。
2.4 join()
该方法是用来协调线程间的同步的,当一个线程调用另一个线程的join方法时,该线程会进入阻塞状态,直到另一个线程工作完毕才会解除阻塞。(相当于方法的调用执行过程)
package thread; import lombok.SneakyThrows; /** * 线程中join方法的使用 * 背景:以下案例模拟图片的下载加载过程 * 首先,图片要下载完 * 其次,再加载图片 * * @author zls * @date 2020/3/30 */ public class JoinTest { public static void main(String[] args) { // 1.下载图片的线程 final Thread download = new Thread() { @SneakyThrows public void run() { System.out.println("down:开始下载图片..."); for (int i = 1; i <= 100; i++) { i += 20; System.out.println("down:" + i + "%"); Thread.sleep(50); } System.out.println("down:图片下载完毕!"); } }; // 2.加载图片的线程 Thread show = new Thread() { @SneakyThrows public void run() { System.out.println("show:开始加载图片.."); // 在加载图片前应当等待下载线程先将图片下载完毕 // 一个线程A调用另个线程的B的join方法时,该线程A会进入阻塞状态,只有等到线程B执行完了以后才会接触阻塞。 download.join(); System.out.println("show:显示图片完毕!"); } }; download.start(); show.start(); } }
2.5 线程阻塞sleep()
static void sleep(long ms): 该方法会将调用该方法的线程阻塞指定毫秒,当阻塞超时后, 线程会自动回到runnable状态等待分配CPU 时间片继续并发运行。
该方法声明抛出一个InterruptException(如果有线程中断了当前线程),所以在使用该方法时需要捕获这个异常。该方法的可读性较差,因此
java.util.concurren包提供了一个可读性更好的线程暂停操作类TimeUnit,通常用来替换Thread.sleep()。
2.6 wait()
该方法使当前线程进入休眠状态,并将其置入等待队列中,直到在其他线程调用此对象的notify()方法或notifyAll()方法将其唤醒。
在调用wait()之前,线程必须要获得该对象的对象级别锁,因此只能在同步方法或同步块中调用wait()方法,进入wait()方法后,当前线程释放锁。
wait()和sleep()的区别:
a. sleep()是线程中的方法,但是wait()是Object中的方法;
为啥wait()方法不放在Thread类中?因为Java提供的锁是对象级的而不是线程级的,每个对象都有锁,通过线程获得。如果线程需要等待某些锁那么调用对象中的wait()方法就有意义了。如果wait()方法定义在Thread类中,线程正在等待的是哪个锁就不明显了。
b. sleep()方法不会释放锁,但是wait()会释放锁,而且会加入到等待队列中;
验证代码:
package thread; import lombok.SneakyThrows; import java.util.stream.Stream; /** * @author zls * @date 2020/4/5 */ public class WaitTest { private final static Object lock = new Object(); public static void main(String[] args) { /** * 1.测试sleep: * 解释:线程1和线程2谁先抢到CPU资源,谁就先 执行 - 睡眠2s - 执行,在睡眠的过程中另个线程是无法执行的, * 直到执行完以后,另个线程才开始执行,这说明sleep方法不会释放锁。 * */ Stream.of("线程1", "线程2").forEach((n) -> new Thread(n){ @Override public void run() { // WaitTest.testSleep(); } }.start()); /** * 2.测试wait:作为对比试验,注释上面代码后在测试 * 解释:线程3先抢到CPU资源,先执行,然后休眠2s,在休眠的过程中,线程4是同样可以进入同步代码块执行, * 这说明wait释放了锁 * */ Stream.of("线程3", "线程4").forEach((n) -> new Thread(n){ @Override public void run() { WaitTest.testWait(); } }.start()); } @SneakyThrows private static void testSleep() { synchronized (lock) { System.out.println(Thread.currentThread().getName()+":正在执行"); Thread.sleep(2000); System.out.println(Thread.currentThread().getName()+":休眠结束"); } } @SneakyThrows private static void testWait() { synchronized (lock) { System.out.println(Thread.currentThread().getName()+":正在执行"); lock.wait(2000); System.out.println(Thread.currentThread().getName()+":休眠结束"); } } }
c. sleep()方法不依赖于同步器synchronized,但是wait()需要依赖synchronizd关键字;
wait()/notify()方法定义在Object类中。如果线程要调用对象的wait()方法,必须首先获得该对象的监视器锁,调用wait()之后,当前线程又立即释放掉锁,线程随后进入WAIT_SET(等待池)中。如果线程要调用对象的notify()/notifyAll()方法,也必须先获得对象的监视器锁,调用方法之后,立即释放掉锁,然后处于Wait_set的线程被转移到Entry_set(等锁池)中,去竞争锁资源。The Winner Thread,也就是成功获得了对象的锁的线程,就是对象锁的拥有者,会进入runnable状态。由于需要获得锁之后才能够调用wait()/notify()方法,因此必须将它们放到同步代码块中。参考:[wait为什么要放到同步代码块中]
d. sleep()不需要被唤醒,但是wait()需要(不指定时间需要被别人唤醒)。
验证代码:
package thread; import lombok.SneakyThrows; /** * wait/notify方法使用测试 * 解释:如果没有唤醒方法,那第一个线程就会处于一直等待的状态,第二个线程唤醒了之后就不再等待了。 * @author zls * @date 2020/4/5 */ public class WaitTest1 { private final static Object lock = new Object(); public static void main(String[] args) { // 这个线程一直在等待 new Thread(WaitTest1::testWait).start(); // 这个线程去唤醒 new Thread(WaitTest1::testNotifyWait).start(); } @SneakyThrows private static void testWait() { synchronized (lock) { System.out.println("wait一直在等待"); lock.wait(); System.out.println("wait被唤醒了"); } } @SneakyThrows private static void testNotifyWait() { synchronized (lock) { Thread.sleep(5000); System.out.println("睡眠5s后唤醒wait..."); lock.notify(); } } }
2.7 notify()
唤醒等待的线程,如果监视器种只有一个等待线程,使用notify()可以唤醒。但是,如果有多条线程notify()是随机唤醒其中一条线程,
与之对应的就是notifyAll()就是唤醒所有等待的线程。
2.5 suspend()/resume()
两个方法配套使用,suspend()使得线程进入阻塞状态,并且不会自动恢复,必须其对应的resume() 被调用,才能使得线程重新进入可执行状态。
典型地,suspend() 和 resume() 被用在等待另一个线程产生的结果的情形:测试发现结果还没有产生后,让线程阻塞,另一个线程产生了结果后,调用 resume() 使其恢复。
wait()/notify()和suspend()/resume()之间的区别?
区别的核心在于: 前面叙述的所有方法方法,阻塞时都不会释放占用的锁(如果占用了的话),而wait/notify方法则相反。 上述的核心区别导致了一系列的细节上的区别: 首先,前面叙述的所有方法都隶属于Thread 类,但是wait/notify却直接隶属于 Object类,也就是说,所有对象都拥有wait/notify方法。初看起来这十分不可思议,但是实际上却是很自然的,因为这一对方法阻塞时要释放占用的锁,而锁是任何对象都具有的,调用任意对象的wait()方法导致线程阻塞,并且该对象上的锁被释放。而调用任意对象的 notify() 方法则导致从调用该对象的 wait() 方法而阻塞的线程中随机选择的一个解除阻塞(但要等到获得锁后才真正可执行)。 其次,前面叙述的所有方法都可在任何位置调用,但是wait/notify方法却必须在 synchronized方法或块中调用,理由也很简单,只有在synchronized方法或块中当前线程才占有锁,才有锁可以释放。同样的道理,调用 wait/notify 方法的对象上的锁必须为当前线程所拥有,这样才有锁可以释放。因此,这一对方法调用必须放置在这样的synchronized 方法或块中,该方法或块的上锁对象就是调用这一对方法的对象。若不满足这一条件,则程序虽然仍能编译,但在运行时会出现IllegalMonitorStateException 异常。 关于 wait() 和 notify() 方法最后再说明两点: 第一:调用 notify()方法导致解除阻塞的线程是从因调用该对象的 wait()方法而阻塞的线程中随机选取的,我们无法预料哪一个线程将会被选择,所以编程时要特别小心,避免因这种不确定性而产生问题。 第二:除了 notify(),还有一个方法 notifyAll() 也可起到类似作用,唯一的区别在于,调用 notifyAll()方法将把因调用该对象的 wait()方法而阻塞的所有线程一次性全部解除阻塞。当然,只有获得锁的那一个线程才能进入可执行状态。 谈到阻塞,就不能不谈一谈死锁,略一分析就能发现,suspend()方法和不指定超时期限的 wait()方法的调用都可能产生死锁。遗憾的是,Java 并不在语言级别上支持死锁的避免,我们在编程中必须小心地避免死锁。 以上我们对 Java 中实现线程阻塞的各种方法作了一番分析,我们重点分析了 wait() 和 notify() 方法,因为它们的功能最强大,使用也最灵活,但是这也导致了它们的效率较低,较容易出错。实际使用中我们应该灵活使用各种方法,以便更好地达到我们的目的。
这确实很绕,如果你看了以后依然不理解,可参照: wait、notify为什么要放在同步代码块中
Java多线程运行环境中,在哪些情况下会使对象锁释放?
答:由于等待一个锁的线程只有在获得这把锁之后,才能恢复运行,所以让持有锁的线程在不再需要锁的时候及时释放锁是很重要的。在以下情况下,持有锁的线程会释放锁: (1)执行完同步代码块,就会释放锁。(synchronized) (2)在执行同步代码块的过程中,遇到异常而导致线程终止,锁也会被释放。(exception) (3)在执行同步代码块的过程中,执行了锁所属对象的wait()方法,这个线程会释放锁,进入对象的等待池。(wait) 除了以上情况以外,只要持有锁的线程还没有执行完同步代码块,就不会释放锁。 在下面情况下,线程是不会释放锁的: (1)执行同步代码块的过程中,执行了Thread.sleep()方法,当前线程放弃CPU,开始睡眠,进入堵塞状态,在睡眠中不会释放锁。 (2)在执行同步代码块的过程中,执行了Thread.yield()方法,当前线程放弃CPU,回到就绪状态,但不会释放锁。
2.8 获取线程相关信息的一系列方法
//获取运行当前代码片段的线程 Thread t=Thread.currentThread(); //返回该线程的标识符 long id=t.getId(); //返回该线程的名称 String name=t.getName(); //返回该线程的优先级 int priority=t.getPriority(); //测试线程是否处于活动状态 boolean isAlive=t.isAlive(); //测试线程是否为守护线程 boolean isDaemon=t.isDaemon(); //测试线程是否已经中断 boolean isInterrupted=t.isInterrupted();
三、线程同步
多线程并发安全问题:
当多个线程访问同一资源时,由于线程切换时机不确定,可能导致多个线程执行代码出现混乱,导致程序执行出现问题,严重时可能导致系统瘫痪。
解决并发安全问题,就是要将多个线程"抢"改为"排队"执行(将异步的操作变为同步操作)。
异步操作:多线程并发的操作,相当于各干各的;
同步操作:有先后顺序的操作,相当于你干完我在干(协同步调)。
1. synchronized
synchronized关键字是java的同步锁。
同步代码块包含两部分:
a. 锁对象的引用;
b. 由这个锁保护的代码块。
语法:
synchronized(同步监视器---锁对象的引用){
// 代码块
}
执行机制:
每个java对象都可以用作一个实现同步的锁,线程进入同步代码块之前会自动获取锁,在退出同步代码块时候会自动释放锁(正常退出和抛出异常都是一样的);要想获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或者方法。
1.1 同步方法
当一个方法被Synchronized修饰后,那么该方法称为"同步方法",即多个线程不能同时进入方法内部执行。
方法上使用synchronized,那么锁对象就是当前方法所属对象,即: this.
package thread; /** * 当一个方法被Synchronized修饰后,那么该方法称为"同步方法",即多个线程不能同时进入方法内部执行。 * 方法上使用synchronized,那么锁对象就是当前方法所属对象,即:this * 场景: * 假设车站里面有20张票,两个线程相当于两个售票窗口,不断的售票。 * 说明: * 为了对比,可以把同步方法的关键字去掉,发现运行时,居然可以越过临界条件 * * @author zls * @date 2020/4/2 */ public class SynchronizedMethodTest { public static void main(String[] args) { final Station station = new Station(); // 窗口一: Thread t1 = new Thread(() -> { while (true){ int ticket = station.getTicket(); //模拟CPU执行到这里没有时间 Thread.yield(); System.out.println("窗口1售出一张票,还剩:" + ticket); } }); // 窗口二: Thread t2 = new Thread(() -> { while (true){ int ticket = station.getTicket(); //模拟CPU执行到这里没有时间 Thread.yield(); System.out.println("窗口2售出一张票,还剩:" + ticket); } }); t1.start(); t2.start(); } } /** * 车站里面有20张票 */ class Station { private int tickets = 5; /* * 同步方法 */ public synchronized int getTicket() { if (tickets == 0) { throw new RuntimeException("票已售完..."); } //模拟CPU执行到这里没有时间 Thread.yield(); return --tickets; } }
1.2 同步代码块
在使用同步块时,应当在允许的情况下尽量减少同步范围,以提高并发的执行效率。
使用同步块要注意同步监视器对象(上锁对象)的选取:
a. 通常使用this即可;
b. 若多个线程调用同一个对象的某些方法时,也可以将这个对象上锁;
多个需要同步的线程在访问该同步块时,看到的应该是同一个对象引用,否则达不到同步的效果;
比如,在一个方法中多个线程调用同一个集合的方法: list.add(xxx), 这时可以将这个集合list作为上锁的对象。
原则: 多个线程看到的上锁对象是同一个时,才有同步效果。