面试题整理(面向对象和多线程)

简述面向对象的的编程语言和以往编程语言的根本不同点。
面向对象的编程语言和以往的编程语言根本是在于抽象机制的不同;1、机器语言和汇编语言几乎没有任何抽象,对于机器而言是最合适的描述,他可以直接操作机器的硬件,并且任何操作都是面向机器的,这就要求人们在使用及其或者汇编语言编写程序的时候必须按照机器的方式去思考问题,因为没有抽象机制,所以程序员不得不陷入复杂的事物之中。
2、面向过程的语言使程序员可以离开机器层次,在更抽象的层次上表达意图,但他所关注的知识处理过程,即执行预期计算所需要的算法
3、面向对象的编程语言将客观事务看作具有状态和行为的对象,通过抽象找出同一类对象的共同状态和行为,构成模型--类。而程序需要解决的问题便反应各种不同属性的对象及对象之间的关系和消息传递。面向对象的方法在程序设计领域是一种相对较新的方法,它更接近于人类处理现实世界问题的自然思维方法。

封装的好处:隐藏类的数据、控制用户对类的修改和控制数据访问权限
目的在于将对象的使用者和设计者分开,使用者不需要知道行为实现的细节,只需要使用设计者提供的信息来访问对象。

多态是指一个程序中同名的不同方法共存,主要通过子类对父类方法的覆盖来实现。不同类的对象可以响应同名的方法,具体的实现方法却不同。多态性是语言具有灵活、抽象、行为共享、代码共享的优势很好的解决了应用程序方法同名问题。

对象的创建过程:是在内存中为此对象分配内存空间,返回对象的引用(相当于对象的存储地址)

数据成员的声明当中,transient指明变量时临时状态;volatile指明变量是一个共享变量。在方法修饰当中,比较不熟悉的关键字:native用来集成java代码和其他语言的代码,synchronized:用来控制多个并发线程对共享数据的访问。

包的作用:将相关的源代码文件组织在一起;不同包中的类名可以相同,从而避免名字冲突;提供包以及的封装和存取权限。

访问权限大小的控制:private<无修饰<protected<public;
访问权限     本类     本包的类     子类     非子类的外包类
public     是     是     是     是
protected 是     是     是     否
default     是     是     否     否
private     是     否     否     否

protected这种权限是专门为继承而设计的,protected所修饰的成员,对所有子类是可访问的,但只对同包的类是可访问的,对外包的非子类是不可以访问的。
default只对同包的类具有访问的权限,外包的所有类都不能访问。
注意事项:当某个成员能被所有的子类继承,但不能被外包的非子类访问,就是用protected;
当某个成员的访问权限只对同包的类开放,包括不能让外包的类继承这个成员,就用包访问权限
设置权限的原因:使用户不要触碰那些他们不该触碰的部程序分;类库设计者可以更改类的内部工作的方式,而不会担心这样对用户产生重大影响

异常是特殊的的运行错误对象,是面向对象规范的一部分,是异常类的对象,java中生命了很多异常类,每个异常类都代表一种运行错误,类中包含了了该运行错误的信息和处理错误的方法。

异常的抛出和捕获
当程序中发生异常的时候,称程序产生了一个异常事件,响应的生成异常对象。生成的异常对象传递给java运行时系统,异常的产生和提交的这一过程称为抛出。
异常发生时,java运行时系统从生成对象的代码开始,沿方法的调用栈逐层回溯,寻找相应的代码处理,并把异常对象交给该方法处理,这一过程称为捕获。
异常的处理
1、声明抛出异常:不在当前方法内处理异常,而是把异常抛出到调用方法中
2、使用try...catch代码块,捕获所发生的的异常,并进行相应的处理。

方法的重载是在一个类中可以有名字相同的多个方法,但这些方法的参数必须不同或者参数个数不同或者是参数类型不同。与返回值无关。
方法的覆盖如果子类不需要使用从父类继承来的方法的功能,则可以声明自己的方法。在声明的时候,使用相同的方法名及参数列表,但执行不同的功能。这种情况成为方法覆盖。使用覆盖的情况:子类实现与父类相同的功能但是采用不同的算法或者公式;在名字相同的方法中要比父类做更多的事情

有继承时的构造方法应该遵循的原则
子类不能从父类继承构造方法
好的程序设计方法是在子类的构造方法中,设计为调用父类的构造方法
super关键字也可以用于构造方法中,其功能为调用父类的构造方法
如果在子类的构造方法的声明中没有明确调用父类的构造方法,则系统在执行子类的构造方法时会自动的调用父类默认的构造方法。如果声明了,则调用语句必须放在首行。

equals和==的纠结问题
1、==的问题;对于8种基本数据类型的变量,变量直接存储的是“值”本身。而引用类型的变量存储的不是“值”本身,而是与其关联的对象在内存中的地址。指向的对象在内存中的存储地址不同。
2、equals的问题:它是所有的继承于Object的类都会有该方法,显然是用来比较的对象的引用是否相等,即是否指向同一个对象。String类对equals方法进行了重写,用来比较指向的字符串对象所存储的字符串是否相等。其他一些类存储Double等都对其进行了重写用来比较指向的的对象所存储的内容是否相等。
3、总结来说:
1)对于==,如果作用于基本数据类型的变量,则直接比较其存储的 “值”是否相等;如果作用于引用类型的变量,则比较的是所指向的对象的地址
2)对于equals方法,注意:equals方法不能作用于基本数据类型的变量
如果没有对equals方法进行重写,则比较的是引用类型的变量所指向的对象的地址;诸如String、Date等类对equals方法进行了重写的话,比较的是所指向的对象的内容。

final所修饰和限定,说明这个类不可能有子类(不能有派生类),通常是有一些固定作用用来完成某种标准功能的类,不能被继承以达到某种修改的目的。引用一个类或者对象时实际真正引用的既可能是这个类或其对象本身等,具有一定的不确定性。将一个类声明为final,则可以将内容、属性和功能固定下来,与类名形成稳定的映射关系,从而保证功能的正确无误。理由:安全方面:防止黑客用来建立派生子类搅乱系统;设计方面希望声明的类为最终类。
修饰的方法不能被重写,功能和内部语句不能被更改的最终方法,这样就固定了这个方法所对应的具体操作,防止子类对父类关键方法的错误重写,增加了代码的安全性和正确性。另一个原因是提高代码的运行效率,通常java运行环境运行方法时,首先在该类查找,找不到一直层级往上找,直到找到为止。如果是final的将该方法可执行的字节码直接放到调用它的程序中,执行速度将更快。修饰的变量不能再改变。

抽象类:抽象类可以包含常规类能够包含的任何东西,这包括构造方法。抽象类也可以包括抽象方法,这种方法只有方法的声明,而没有方法的实现。抽象类是较高层次的概括,抽象的作用是让其他的类来继承它的抽象化特征;里面包含了它所有的子类共享的公共属性、公共行为;在程序中不能用抽象类作为模板来创建对象;
优点:隐藏具体的细节信息,使调用该方法的程序不必过分关注该类和它的子类的内部状况,方法头里实际包含了调用该方法的程序语句所需要了解的全部信息。抽象方法强迫子类完成指定的行为,且必须完成其标准行为。

接口:使得抽象的概念更深入了一层,是一个纯的抽象类,只提供一种形式,并不提供实现,不规定方法主体,默认为final和static。它的引进是为了实现多继承,同时免除C++多继承的复杂性接口还可以实现不同类之间的常量共享。在使用中,接口类的变量可以用来代表任何实现了该接口的类的对象,相当于把类根据其实现的功能分别代表,而不必顾虑它所在的类继承层次,这样可以最大限度的利用动态绑定,隐藏实现细节。

线程:
(1)、进程:指正在运行的程序,当一个程序进入内存运行就变成一个进程,具有一定的独立功能
线程:是进程中的一个执行单元,负责当前进程中程序的执行;一个进程中含有多个线程;一个程序中有多个线程在同时执行就是多线程,比如买票
程序的运行原理:
分时调度,所有线程轮流使用CPU的使用权,平均分配每个线程占用CPU的时间。
抢占式调度,优先让优先级高的线程使用CPU,如果优先级相同就会随机选择一个。

Java使用的就是抢占式调度。实际上就是CPU在多个线程之间的切换速度非常快,对我们感觉要快,看上去是在同一时刻运行;实际上并不能提高程序的运行速度,但是可以提高程序的运行效率,让CPU的使用率更高。
(2)、Thread类
创建线程的方法
a.将类声明为Thread的子类,(也就是继承)该子类重写Thread类的run方法,然后创建对象,开启线程。
注意:run和start方法的区别,run仅仅是对象调用方法,不开启线程;start是开启线程,并让jvm调用run方法在开启的线程中执行。
继承这个类是为了描述线程,具备线程应该有的功能,为什么不直接创建该类的对象呢因为里面的run方法没有干任何事情,我们自己重写run方法这个操作是让线程执行的代码。
多线程执行时,在栈内存中,每个执行线程都有自己的内存空间,进行方法的弹栈、压栈。执行到start()方法时就会开启这个线程,并调用run()方法,实现多个线程执行

b.声明一个实现Runnable接口的类。(实现该类)。然后实现run()方法,创建Runnable的子类对象,传到某个线程的构造方法中,开启线程。
实现该接口,避免了继承Thread的单继承的局限性,上面的只有创建Thread才能创建线程。创建时就能明确,要运行的线程的任务。
优点:
第二种方式避免了单继承的局限性,更加符合面向对象,而且将线程任务单独分离出来封装成对象,实现了线程任务和线程对象的解耦。而上面的既是线程对象,又是线程任务

线程的状态:类比人的一生遇到许多事情。
new新建状态:至今尚未启动的线程,已经有了。==new Thread();
runnable运行状态:通过start()方法进入。
死亡状态:run()方法结束,stop()也可以让线程死亡,已经过时了。
blocked受阻塞状态:start()过后不一定能到运行,CPU没时间,先等着
timed-waiting休眠状态:运行之后sleep()休眠,也可以回到受阻塞状态。
waiting等待状态:运行之后wait()导致当前线程等待,无线等待下去。但是可以叫醒,利用notify()方法。也可以到受阻塞状态。有的状态是可以控制有的不能控制。休眠状态是可以自己醒的。

1、线程安全问题是由于全局变量以及静态变量引起的,若每个线程对全局变量只有读操作而没有写操作,这个全局变量就是线程安全的;若有多个线程同时执行写操作,一般考虑线程同步,否则的话就可能影响线程的安全。
2、java中提供了线程同步机制,有两种,同步代码块,同步方法。
同步代码块:sychronized(对象){可能会产生安全问题的代码}
同步方法:在主的方法中调用另外一个被sychronized修饰的方法;静态同步方法时对象是类名.class
3、当线程任务中出现了多个同步(多个锁时)如果同步中嵌套了其他的同步,容易引发死锁,程序无限等待。能避免就避免。互相等待对方……一直等不到
4、Lock提供了一个更加面向对象的锁,在锁中提供了更多的操作锁的功能。在会产生错误的代码中先上锁,执行完之后在关闭锁。Lock ck=new ReentrantLock();等待唤醒机制的方法,wait(),释放其执行资格和执行权。notify(),唤醒等待的线程,一次唤醒任意一个;nitifyAll();唤醒全部线程。
5、多线程有几种实现方案,分别是哪几种?
    a, 继承Thread类
    b, 实现Runnable接口
    c, 通过线程池,实现Callable接口

6、同步有几种方式,分别是什么?
    a,同步代码块
    b,同步方法
      静态同步方法

7、启动一个线程是run()还是start()?它们的区别?
    启动一个线程是start()
    区别:
        start: 启动线程,并调用线程中的run()方法
        run  : 执行该线程对象要执行的任务

8、sleep()和wait()方法的区别
    sleep: 不释放锁对象, 释放CPU使用权
            在休眠的时间内,不能唤醒
    wait(): 释放锁对象, 释放CPU使用权
            在等待的时间内,能唤醒

为什么wait(),notify(),notifyAll()等方法都定义在Object类中
    锁对象可以是任意类型的对象
9、为什么使用线程池?方式有哪些?
解决线程生命周期和资源不足的问题,系统开销大,程序响应更快
线程池相应的介绍:
Executors线程池创建工厂类,返回线程池对象
ExecutorsService:线程池类,submit()方法用来获取一个线程对象,并执行;shutdown()方法是关闭线程池
Future接口:用来记录线程任务执行完毕后产生的结果。线程池创建与使用
Runnable接口方式:
//创建线程池对象
ExecutorService service = Executors.newFixedThreadPool(2);//包含2个线程对象
//创建Runnable实例对象MyRunnable r = new MyRunnable();
//自己创建线程对象的方式
//Thread t = new Thread(r);
//t.start(); ---> 调用MyRunnable中的run()        
//从线程池中获取线程对象,然后调用MyRunnable中的run()
service.submit(r);
//再获取个线程对象,调用MyRunnable中的run()
service.submit(r);
service.submit(r);
//注意:submit方法调用结束后,程序并不终止,是因为线程池控制了线程的关闭。将使用完的线程又归还到了线程池中
//关闭线程池
//service.shutdown();

Callable接口实现类,call方法可抛出异常、返回线程任务执行完毕后的结果

10、线程在一定条件下,状态会发生变化。线程一共有以下几种状态:

1、新建状态(New):新创建了一个线程对象。

2、就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start()方法。该状态的线程位于“可运行线程池”中,变得可运行,只等待获取CPU的使用权。即在就绪状态的进程除CPU之外,其它的运行所需资源都已全部获得。

3、运行状态(Running):就绪状态的线程获取了CPU,执行程序代码。

4、阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。

阻塞的情况分三种:

(1)、等待阻塞:运行的线程执行wait()方法,该线程会释放占用的所有资源,JVM会把该线程放入“等待池”中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify()或notifyAll()方法才能被唤醒,

(2)、同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入“锁池”中。

(3)、其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

5、死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

1. 多线程使用的优缺点? 
优点:
(1)多线程技术使程序的响应速度更快
(2)当前没有进行处理的任务可以将处理器时间让给其它任务
(3)占用大量处理时间的任务可以定期将处理器时间让给其它任务
(4)可以随时停止任务
(5)可以分别设置各个任务的优先级以及优化性能
缺点:
(1)等候使用共享资源时造成程序的运行速度变慢
(2)对线程进行管理要求额外的cpu开销
(3)可能出现线程死锁情况。即较长时间的等待或资源竞争以及死锁等症状。

2、start()方法和run()方法简介和区别?
start()方法:
1)用start方法来启动线程,真正实现了多线程运行,这时无需等待run方法体代码执行完毕而直接继续执行下面的代码。
2)通过调用Thread类的start()方法来启动一个线程,这时此线程处于就绪(可运行)状态,并没有运行,一旦得到CPU时间片,就开始执行run()方法。
run()方法:
1)run()方法只是类的一个普通方法而已,如果直接调用Run方法,程序中依然只有主线程这一个线程,其程序执行路径还是只有一条。
总结:
1)调用start方法方可启动线程,
2)而run方法只是thread的一个普通方法调用,还是在主线程里执行。
3)把需要并行处理的代码放在run()方法中,start()方法启动线程将自动调用run()方法,这是由jvm的内存机制规定的。
4)并且run()方法必须是public访问权限,返回值类型为void.。

3、Runnable接口和Callable接口的相同点和不同点?
相同点:都是接口、都可以运用于Executors
不同点:前者实现run方法,后者实现call方法,有返回值、可以抛异常,jdk1.5之后才有

4. volatile关键字的作用是什么?
(1)多线程使用volatile关键字修饰的变量,保证了其在多线程之间的可见性,即每次读取到volatile变量,一定是最新的数据
(2)Java代码执行中,为了获取更好的性能JVM可能会对指令进行重排序,多线程下可能会出现一些意想不到的问题。使用volatile则会对禁止语义重排序,当然这也一定程度上降低了代码执行效率

5、sleep方法和wait方法的相同点和不同点?
相同点:
二者都可以让线程处于冻结状态。
不同点:
1)首先应该明确sleep方法是Thread类中定义的方法,而wait方法是Object类中定义的方法。
2)sleep方法必须人为地为其指定时间。
wait方法既可以指定时间,也可以不指定时间。
3)sleep方法时间到,线程处于临时阻塞状态或者运行状态。
wait方法如果没有被设置时间,就必须要通过notify或者notifyAll来唤醒。
4)sleep方法不一定非要定义在同步中。
wait方法必须定义在同步中。
5)当二者都定义在同步中时,
线程执行到sleep,不会释放锁。
线程执行到wait,会释放锁。

6、生产者和消费者模型的作用是什么?
1)通过平衡生产者的生产能力和消费者的消费能力来提升整个系统的运行效率,这是生产者消费者模型最重要的作用
2)解耦,这是生产者消费者模型附带的作用,解耦意味着生产者和消费者之间的联系少,联系越少越可以独自发展而不需要收到相互的制约

7、 ThreadLocal的作用是什么?
1)ThreadLocal用来解决多线程程序的并发问题
2)ThreadLocal并不是一个Thread,而是Thread的局部变量,当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本.
3)从线程的角度看,目标变量就象是线程的本地变量,这也是类名中“Local”所要表达的意思。
4)线程局部变量并不是Java的新发明,Java没有提供在语言级支持(语法上),而是变相地通过ThreadLocal的类提供支持.

8、Lock和synchronized对比?
1)Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;
 2)synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
 3)Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
 4)通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
 5)Lock可以提高多个线程进行读操作的效率。
 6)在JDK1.5中,synchronized是性能低效的。因为这是一个重量级操作,它对性能最大的影响是阻塞式的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性带来了很大的压力。相比之下使用Java提供的Lock对象,性能更高一些。
但是,JDK1.6,发生了变化,对synchronize加入了很多优化措施,有自适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在JDK1.6上synchronize的性能并不比Lock差。因此。提倡优先考虑使用synchronized来进行同步。

9、ConcurrentHashMap的并发度是什么?
ConcurrentHashMap的并发度就是segment的大小,默认为16,这意味着最多同时可以有16条线程操作ConcurrentHashMap,这也是ConcurrentHashMap对Hashtable的最大优势,任何情况下,Hashtable能同时有两条线程获取Hashtable中的数据∂

10、ReadWriteLock是什么?
ReadWriteLock是一个读写锁接口,ReentrantReadWriteLock是ReadWriteLock接口的一个具体实现,读写锁是用来提升并发程序性能的锁分离技术的成果。实现了读写的分离,读锁是共享的,写锁是独占的,读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,提升了读写的性能。它可以实现读写锁,当读取的时候线程会获得read锁,其他线程也可以获得read锁同时并发的去读取,但是写程序运行获取到write锁的时候,其他线程是不能进行操作的,因为write是排它锁,而上面介绍的两种不管你是read还是write没有抢到锁的线程都会被阻塞或者中断,它也是个接口,里面定义了两种方法readLock()和writeLock(),他的一个实现类是ReentrantReadWriteLock。


11、什么是乐观锁和悲观锁?
(1)乐观锁:对于并发间操作产生的线程安全问题持乐观状态,乐观锁认为竞争不总是会发生,因此它不需要持有锁,将比较-设置这两个动作作为一个原子操作尝试去修改内存中的变量,如果失败则表示发生冲突,那么就应该有相应的重试逻辑。
(2)悲观锁:对于并发间操作产生的线程安全问题持悲观状态,悲观锁认为竞争总是会发生,因此每次对某资源进行操作时,都会持有一个独占的锁,就像synchronized,直接对操作资源上了锁。

12、synchronized、Lock、ReentrantLock、ReadWriteLock。的区别
1.synchronized 用于同步方法和代码块,执行完后自动释放锁

2.Lock是一个锁的接口,提供获取锁和解锁的方法(lock,trylock,unlock)

3.ReentrantLock 重入锁

Lock有一个实现类:ReentrantLock,它实现了Lock里面的方法,但是使用Lock的时候必须注意它不会像synchronized执行完成之后或者抛出异常之后自动释放锁,而是需要你主动释放锁,所以我们必须在使用Lock的时候加上try{}catch{}finally{}块,并且在finally中释放占用的锁资源。

Lock和synchronized最大的区别就是当使用synchronized,一个线程抢占到锁资源,其他线程必须像SB一样得等待;而使用Lock,一个线程抢占到锁资源,其他的线程可以不等待或者设置等待时间,实在抢不到可以去做其他的业务逻辑。

13、什么是死锁?怎么避免死锁?
产生死锁的四个必要条件: 
- 互斥条件:一个资源每次只能被一个进程使用。 
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。 
- 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。 
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
- 从死锁的四个必要条件来看,破坏其中的任意一个条件就可以避免死锁。但互斥条件是由资源本身决定的,不剥夺条件一般无法破坏,要实现的话得自己写更多的逻辑。 
- 避免无限期的等待:用Lock.tryLock(),wait/notify等方法写出请求一定时间后,放弃已经拥有的锁的程序。 
- 注意锁的顺序:以固定的顺序获取锁,可以避免死锁。 
- 开放调用:即只对有请求的进行封锁。你应当只想你要运行的资源获取封锁,比如在上述程序中我在封锁的完全的对象资源。但是如果我们只对它所属领域中的一个感兴趣,那我们应当封锁住那个特殊的领域而并非完全的对象。 
- 最后,如果能避免使用多个锁,甚至写出无锁的线程安全程序是再好不过了。
14、volatile关键字的作用和原理。
保证变量的可见性。 
在java内存结构中,每个线程都是有自己独立的内存空间(此处指的线程栈)。当需要对一个共享变量操作时,线程会将这个数据从主存空间复制到自己的独立空间内进行操作,然后在某个时刻将修改后的值刷新到主存空间。这个中间时间就会发生许多奇奇怪怪的线程安全问题了,volatile就出来了,它保证读取数据时只从主存空间读取,修改数据直接修改到主存空间中去,这样就保证了这个变量对多个操作线程的可见性了。换句话说,被volatile修饰的变量,能保证该变量的 单次读或者单次写 操作是原子的。

但是线程安全是两方面需要的 原子性(指的是多条操作)和可见性。volatile只能保证可见性,synchronized是两个均保证的。 
volatile轻量级,只能修饰变量;synchronized重量级,还可修饰方法。 
volatile不会造成线程的阻塞,而synchronized可能会造成线程的阻塞。

15、ThreadPoolExecutor的内部工作原理。
ThreadPoolExecutor的主要逻辑是,当用户调用execute(Runnable command) ,大致逻辑如下(注意是大致逻辑,代码中的逻辑更复杂更详细):
1.查看当前运行状态,如果不是RUNNING状态,将直接拒绝新任务。否则进入步骤2。
2.查看当前运行线程的数量,如果数量少于核心线程数,将直接创建新的线程执行该任务。否则进入步骤3。
3.将该任务添加到阻塞队列,等待核心线程执行完上一个任务再来获取。如果添加到阻塞队列失败,进入步骤4。
4.尝试创建一个非核心线程执行该任务,前提是线程的数量少于等于最大线程数。如果失败,拒绝该任务。
 

猜你喜欢

转载自blog.csdn.net/formydream111/article/details/89197307