Java面试考点之Java SE (Java高级知识)

一、线程

1、多线程中的i++线程安全吗?为什么?

i++和++i都是i=i+1的意思,但是过程有些许区别:
i++:先赋值再自加。(例如:i=1;a=1+i++;结果为a=1+1=2,语句执行完后i再进行自加为2)
++i:先自加再赋值。(例如:i=1;a=1+++i;结果为a=1+(1+1)=3,i先自加为2再进行运算)
但是在单独使用时没有区别:如for(int i=0;i<10;i++){ }和for(int i=0;i<10;++i) { }没有区别。

i++和++i的线程安全分为两种情况:
1、如果i是局部变量(在方法里定义的),那么是线程安全的。因为局部变量是线程私有的,别的线程访问不到,其实也可以说没有线程安不安全之说,因为别的线程对他造不成影响。
2、如果i是全局变量(类的成员变量),那么是线程不安全的。因为如果是全局变量的话,同一进程中的不同线程都有可能访问到。如果有大量线程同时执行i++操作,i变量的副本拷贝到每个线程的线程栈,当同时有两个线程栈以上的线程读取线程变量,假如此时是1的话,那么同时执行i++操作,再写入到全局变量,最后两个线程执行完,i会等于3而不会是2,所以出现不安全性。

2、实现一个线程安全的计数器

Java 提供了一组atomic class来帮助我们简化同步处理。基本工作原理是使用了同步synchronized的方法实现了对一个long, integer, 对象的增、减、赋值(更新)操作.

import java.util.concurrent.atomic.AtomicInteger;
public class MySafeThread implements Runnable{

    //设置计数器初始值为0
        private static AtomicInteger count = new AtomicInteger(0);

        @Override
        public void run(){
            while (true){
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                MySafeThread.calc();
            }
        }
        //计数 注意加锁sychronized
        private synchronized static void calc(){
            //count 为计数后的值
            if (count.get()<1000){
                //自增1 返回更新后的值
                int c = count.incrementAndGet();
                //现场名称与自增后的值
                System.out.println(Thread.currentThread().getName()+":"+c);
            }
        }
        //开启五个线程进行计数
        public static void main(String[] args) {
            for (int i=0;i<5;i++){
                MySafeThread mySafeThread = new MySafeThread();
                Thread t = new Thread(mySafeThread);
                t.start();
            }

    }
}
3、多线程同步的方法

参考:https://www.cnblogs.com/xiaoxi/p/7679470.html

  • 1、同步方法 即有synchronized关键字修饰的方法。
    由于java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。

  • 2、同步代码块
    即有synchronized关键字修饰的语句块。被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必要同步整个方法,使用synchronized代码块同步关键代码即可。

  • 3、使用特殊域变量(volatile)实现线程同步
    (1)volatile关键字为域变量的访问提供了一种免锁机制;
    (2)使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新;
    (3)因此每次使用该域就要重新计算,而不是使用寄存器中的值;
    (4)volatile不会提供任何原子操作,它也不能用来修饰final类型的变量。

  • 4、使用重入锁实现线程同步
    在JavaSE5.0中新增了一个java.util.concurrent包来支持同步。ReentrantLock类是可重入、互斥、实现了Lock接口的锁,
    它与使用synchronized方法和块具有相同的基本行为和语义,并且扩展了其能力。 ReenreantLock类的常用方法有:
    ReentrantLock() :创建一个ReentrantLock实例 lock() :获得锁 unlock() :释放锁
    注:ReentrantLock()还有一个可以创建公平锁的构造方法,但由于能大幅度降低程序运行效率,不推荐使用。

  • 5、使用局部变量实现线程同步

     4、介绍一下生产者消费者模式?
    

在线程世界里,生产者就是生产数据的线程,消费者就是消费数据的线程。在多线程开发当中,如果生产者处理速度很快,而消费者处理速度很慢,那么生产者就必须等待消费者处理完,才能继续生产数据。同样的道理,如果消费者的处理能力大于生产者,那么消费者就必须等待生产者。为了解决这种生产消费能力不均衡的问题,所以便有了生产者和消费者模式
生产者消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。

5、线程,进程,然后线程创建有很大开销,怎么优化?

可以使用线程池

6、什么是线程池(thread pool)?线程池有什么好处?

线程池就是用来存放已经创建过的线程的容器,有任务时直接从线程池里获取,可以节省时间。
好处:

  • 1、线程池的重用
    线程的创建和销毁的开销是巨大的,而通过线程池的重用大大减少了这些不必要的开销,当然既然少了这么多消费内存的开销,其线程执行速度也是突飞猛进的提升。

  • 2、控制线程池的并发数

  • 3、线程池可以对线程进行管理
    线程池可以提供定时、定期、单线程、并发数控制等功能。比如通过ScheduledThreadPool线程池来执行S秒后,每隔N秒执行一次的任务。

    7、说说线程的基本状态以及状态之间的关系?

1.新建状态(New)
2.就绪状态(Runnable)
3.运行状态(Running)
4. 阻塞状态(Blocked)
5. 死亡状态(Dead)

8、如何保证线程安全?

线程安全:
线程安全就是多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可使用。不会出现数据不一致或者数据污染。
如何保证呢:
1、使用线程安全的类;
2、使用synchronized同步代码块,或者用Lock锁;
由于线程安全问题,使用synchronized同步代码块 原理:当两个并发线程访问同一个对象object中的这个synchronized(this)同步代码块时,一个时间内只能有一个线程得到执行。 另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。
3、多线程并发情况下,线程共享的变量改为方法局部级变量;

9、举例说明同步和异步

举例
对于写程序,同步往往会阻塞,没有数据过来,我就等着,异步则不会阻塞,没数据来我干别的事,有数据来去处理这些数据。但是同步在某些场景下也有它的优点。
1.异步的操作例子:
为了避免短时间大量的数据库操作,就使用缓存机制,也就是消息队列。先将数据放入消息队列,然后再慢慢写入数据库。
引入消息队列机制,虽然可以保证用户请求的快速响应,但是并没有使得我数据迁移的时间变短(即80万条数据写入mysql需要1个小时,用了redis之后,还是需要1个小时,只是保证用户的请求的快速响应。用户输入完http url请求之后,就可以把浏览器关闭了,干别的去了。如果不用redis,浏览器不能关闭)。
2.同步的价值
例如银行的转账功能,对数据库的保存操作。避免冲突或者不安全的操作。

10、请说出与线程同步以及线程调度相关的方法。

-wait():使一个线程处于等待(阻塞)状态,并且释放所持有的对象的锁;
-sleep():使一个正在运行的线程处于睡眠状态,是一个静态方法,调用此方法要处理InterruptedException异常;
-notify():唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由JVM确定唤醒哪个线程,而且与优先级无关;
-notityAll():唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态;

11、java中有几种方法可以实现一个线程? stop()和suspend()方法为何不推荐使用?

Java多线程实现方式主要有四种:继承Thread类、实现Runnable接口、实现Callable接口通过FutureTask包装器来创建Thread线程、使用ExecutorService、Callable、Future实现有返回结果的多线程。其中前两种方式线程执行完后都没有返回值,后两种是带返回值的。 stop会导致不安全,为啥呢,如果在同步块执行一半时,stop来了,后面还没执行完呢,锁没了,线程退出了,别的线程又可以操作你的数据了,所以就是线程不安全了。 suspend会导致死锁,因为挂起后,是不释放锁的,别人也就阻塞着,如果没人唤醒,那就一直死锁。

12、sleep() 和 wait() 有什么区别?

对于sleep()方法,我们首先要知道该方法是属于Thread类中的。而wait()方法,则是属于Object类中的。
sleep()方法导致了程序暂停执行指定的时间,让出cpu该其他线程,但是他的监控状态依然保持着,当指定的时间到了又会自动恢复运行状态。在调用sleep()方法的过程中,线程不会释放对象锁
而当调用wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备获取对象锁进入运行状态

13、概括的解释下线程的几种可用状态。
  • *1.*新建状态(New): 当用new操作符创建一个线程时, 例如new Thread,线程还没有开始运行,此时线程处在新建状态。 当一个线程处于新生状态时,程序还没有开始运行线程中的代码

  • *2.*就绪状态(Runnable) 一个新创建的线程并不自动开始运行,要执行线程,必须调用线程的start()方法。当线程对象调用start()方法即启动了线程,start()方法创建线程运行的系统资源,并调度线程运行run()方法。当start()方法返回后,线程就处于就绪状态。

    处于就绪状态的线程并不一定立即运行run()方法,线程还必须同其他线程竞争CPU时间,只有获得CPU时间才可以运行线程。因为在单CPU的计算机系统中,不可能同时运行多个线程,一个时刻仅有一个线程处于运行状态。因此此时可能有多个线程处于就绪状态。对多个处于就绪状态的线程是由Java运行时系统的线程调度程序(thread
    scheduler)来调度的。

  • *3.*运行状态(Running)
    当线程获得CPU时间后,它才进入运行状态,真正开始执行run()方法.

  • 4. 阻塞状态(Blocked) 线程运行过程中,可能由于各种原因进入阻塞状态: 1>线程通过调用sleep方法进入睡眠状态; 2>线程调用一个在I/O上被阻塞的操作,即该操作在输入输出操作完成之前不会返回到它的调用者;
    3>线程试图得到一个锁,而该锁正被其他线程持有; 4>线程在等待某个触发条件; …
    所谓阻塞状态是正在运行的线程没有运行结束,暂时让出CPU,这时其他处于就绪状态的线程就可以获得CPU时间,进入运行状态。

  • 5.死亡状态(Dead) 有两个原因会导致线程死亡:

    1. run方法正常退出而自然死亡,
    1. 一个未捕获的异常终止了run方法而使线程猝死。
      为了确定线程在当前是否存活着(就是要么是可运行的,要么是被阻塞了),需要使用isAlive方法。如果是可运行或被阻塞,这个方法返回true;如果线程仍旧是new状态且不是可运行的, 或者线程死亡了,则返回false.

❤6、锁

1、什么是死锁(deadlock)?

死锁 :是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。

  • 原因: (1) 因为系统资源不足。 (2) 资源分配不当等。 (3) 进程运行推进顺序不合适。
    如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。其次,进程运行推进顺序与速度不同,也可能产生死锁。
  • (1) 互斥条件:一个资源每次只能被一个进程使用。
  • (2) 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
  • (3)请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • (4)循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
    这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。 死锁的解除与预防:
  • 理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。所以,在系统设计、进程调度等方面注意如何不让这四个必要条件成立,如何确定资源的合理分配算法,避免进程永久占据系统资源。此外,也要防止进程在处于等待状态的情况下占用资源。因此,对资源的分配要给予合理的规划。
    其中最简单的方法就是线程都是以同样的顺序加锁和释放锁,也就是破坏了第四个条件。

2、请你简述synchronized和java.util.concurrent.locks.Lock的异同?
相同点:两者都是用来实现对某个资源的同步。
两者区别如下:
(1) 用法不一样。synchronized可以用于修饰方法,也可以用在代码块中。Lock需要指定起始和终点位置,一般放在try-finally结构中,try开始执行lock方法,finally中执行unlock方法。synchronized是托管给JVM执行的,Lock是通过代码执行的。
(2) 性能不一样。在资源竞争不激烈情况下,synchronized的性能比Lock好,而在资源竞争激烈时,synchronized的性能下降很快,而Lock基本保持不变。
锁机制不一样。synchronized获得锁和释放锁都是在块结构中,获取多个锁时必须以相反顺序释放,并且自动释放锁Lock需要开发人员手动释放锁,并且放在finally中

❤JVM

1、什么是Java虚拟机?为什么Java被称作是“平台无关的编程语言”?

一、什么是java虚拟机?
java虚拟机是执行字节码文件(.class)的虚拟机进程。
java源程序(.java)被编译器编译成字节码文件(.class)。然后字节码文件将由java虚拟机,解释成机器码(不同平台的机器码不同),利用机器码操作硬件和操作系统
二、为什么java被称为平台无关的编程语言?
因为不同的平台装有不同的JVM,它们能够将相同的.class文件,解释成不同平台所需要的机器码。正是因为有JVM的存在,java被称为平台无关的编程语言

 2、JVM内存模型是什么?

参考:https://www.cnblogs.com/fubaizhaizhuren/p/4976839.html在这里插入图片描述

  • java栈
    Java栈中存放的是一个个的栈帧每个栈帧对应一个被调用的方法,在栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向当前方法所属的类的运行时常量池(运行时常量池的概念在方法区部分会谈到)的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些额外的附加信息。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。


  • Java中的堆是用来存储对象本身的以及数组(数组引用是存放在Java栈中的)。堆是被所有线程共享的,在JVM中只有一个堆。

  • 方法区
    与堆一样,是被线程共享的区域。在方法区中,存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。
    在Class文件中除了类的字段、方法、接口等描述信息外,还有一项信息是常量池,用来存储编译期间生成的字面量和符号引用。
    方法区中有一个非常重要的部分就是运行时常量池,它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后, 对应的运行时常量池就被创建出来。当然并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的intern方法。

     3、JVM的生命周期
    
  1. JVM实例对应了一个独立运行的java程序它是进程级别
    a)启动。启动一个Java程序时,一个JVM实例就产生了,任何一个拥有public static void main(String[] args)函数的class都可以作为JVM实例运行的起点
    b)运行。main()作为该程序初始线程的起点,任何其他线程均由该线程启动。JVM内部有两种线程:守护线程和非守护线程,main()属于非守护线程,守护线程通常由JVM自己使用,java程序也可以表明自己创建的线程是守护线程
    c)消亡。当程序中的所有非守护线程都终止时,JVM才退出;若安全管理器允许,程序也可以使用Runtime类或者System.exit()来退出
  2. JVM执行引擎实例则对应了属于用户运行程序的线程它是线程级别的

❤GC

1、GC是什么? 为什么要有GC?

GC:Java的垃圾回收器。
Java有了GC,就不需要程序员去人工释放内存空间。当Java虚拟机发觉内存资源紧张的时候,就会自动地去清理无用变量所占用的内存空间。当然,如果需要,程序员可以在Java程序中显式地使用System.gc()或Runtime.getRuntime().gc()来强制进行一次立即的内存清理。垃圾回收可以有效的防止内存泄露,有效的使用可以使用的内存。

2、java中内存泄露是什么,什么时候出现内存泄露?

Java中的内存泄露,广义并通俗的说就是:不再会被使用的对象的内存不能被回收,就是内存泄露。如果长生命周期的对象持有短生命周期的引用,就很可能会出现内存泄露。

3、垃圾回收器的基本原理是什么?垃圾回收器可以马上回收内存吗?有什么办法主动通知虚拟机进行垃圾回收?

垃圾回收器通常是作为一个单独的低优先级的线程运行,不可预知的情况下对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收//垃圾回收器并不能保证马上回收内存//,因为垃圾回收器线程的级别较低,所以当另一个级别比它高的线程跟他同时竞争运行时间时,前者优先运行;要主动请求垃圾收集,可以调用下面的方法之一:System.gc()或Runtime.getRuntime().gc(),但JVM可以屏蔽掉显示的垃圾回收调用。

4、垃圾回收的优点和原理,并考虑2种回收机制    
  • 1、java语言最显著的特点就是引入了垃圾回收机制,它使java程序员在编写程序时不再考虑内存管理的问题

  • 2、由于有这个垃圾回收机制,java中的对象不再有“作用域”的概念,只有引用的对象才有“作用域”。

  • 3、垃圾回收机制有效的防止了内存泄露,可以有效的使用可使用的内存

  • 4、垃圾回收器通常作为一个单独的低级别的线程运行,在不可预知的情况下对内存堆中已经死亡的或很长时间没有用过的对象进行清除和回收。

  • 5、程序员不能实时的对某个对象或所有对象调用垃圾回收器进行垃圾回收。

    垃圾回收机制有分代复制垃圾回收、标记垃圾回收、增量垃圾回收

❤扩展常识

	1、怎么打印日志?

1、如果使用的是Jboss-logging-x.x.x(.Final).jar
如果maven项目中引入了JBoss的日志jar包,则可以这样构造日志对象:
private static final Logger logger = Logger.getLogger(LabelServiceImpl.class);

2,如果使用的是slf4j-api-x.x.x.jar
如果maven项目引入了slf4j的jar包,则可以这样构造日志对象:
private static Logger logger = LoggerFactory.getLogger(LabelServiceImpl.class);

3,普通的Java项目,如果要打印日志,那么就导入slf4j-api-x.x.x.jar,log4j-x.x.x.jar,slf4j-log4j12-x.x.x.jar这3个jar包,然后添加配置文件log4j.properties,其内容如下:

### 设置日志级别 ###
log4j.rootLogger=debug,stdout,logfile  

### 输出到控制台 ###
log4j.appender.stdout = org.apache.log4j.ConsoleAppender  
log4j.appender.stdout.layout = org.apache.log4j.PatternLayout  
log4j.appender.stdout.layout.ConversionPattern = [ %p ] - [ %l ] %m%n

### 输出到日志文件 ###
log4j.appender.logfile = org.apache.log4j.RollingFileAppender  
log4j.appender.logfile.File = log4j.log  
log4j.appender.logfile.MaxFileSize = 512KB  
log4j.appender.logfile.MaxBackupIndex = 3  
log4j.appender.logfile.layout = org.apache.log4j.PatternLayout  
log4j.appender.logfile.layout.ConversionPattern = %d{yyyy-MM-dd HH:mm:ss} [ %p ] - [ %l ] %m%n

2、最常见到的runtime exception
1,当试图将对象强制转换为不是实例的子类时,抛出该异常(ClassCastException)

Object x = new Integer(0);
System.out.println((String)x);

2,一个整数“除以零”时,抛出ArithmeticException异常。

int a=5/0;

3, 当应用程序试图在需要对象的地方使用 null 时,抛出NullPointerException异常

String s=null;
int size=s.size();

4, 指示索引或者为负,或者超出字符串的大小,抛出StringIndexOutOfBoundsException异常

"hello".indexOf(-1);

5,如果应用程序试图创建大小为负的数组,则抛出NegativeArraySizeException异常。

String[] ss=new String[-1];

3、error和exception有什么区别?
Error(错误)是系统中的错误,程序员是不能改变的和处理的,是在程序编译时出现的错误,只能通过修改程序才能修正。一般是指与虚拟机相关的问题,如系统崩溃,虚拟机错误,内存空间不足,方法调用栈溢等。对于这类错误的导致的应用程序中断,仅靠程序本身无法恢复和和预防,遇到这样的错误,建议让程序终止。

Exception(异常)表示程序可以处理的异常,可以捕获且可能恢复。遇到这类异常,应该尽可能处理异常,使程序恢复运行,而不应该随意终止异常。

4、Java中的异常处理机制的简单原理和应用。

(1)一种是当程序违反了java语规则的时候,JAVA虚拟机就会将发生的错误表示为一个异常.这里语法规则指的是JAVA类库内置的语义检查。

(2)另一种情况就是JAVA允许程序员扩展这种语义检查,程序员可以创建自己的异常,并自由选择在何时用throw关键字引发异常。所有的异常都是Thowable的子类。
异常处理是与程序执行是并行的.

Try{
   //可能发现异常的语句块 
}catch(异常类型,e){
   //发生异常时候的执行语句块
} finnally{
  //不管是否发生异常都执行的语句块
}

5、java中有几种类型的流?JDK为每种类型的流提供了一些抽象类以供继承,请说出他们分别是哪些类?
字节流,字符流。字节流继承于InputStream OutputStream,字符流继承于InputStreamReader OutputStreamWriter。在java.io包中还有许多其他的流,主要是为了提高性能和使用方便。
字符流和字节流是根据处理数据的不同来区分的。字节流按照8位传输,字节流是最基本的,所有文件的储存是都是字节(byte)的储存,在磁盘上保留的并不是文件的字符而是先把字符编码成字节,再储存这些字节到磁盘。

  • **读文本的时候用字符流,例如txt文件。读非文本文件的时候用字节流,**例如mp3。理论上任何文件都能够用字节流读取,但当读取的是文本数据时,为了能还原成文本你必须再经过一个转换的工序,相对来说字符流就省了这个麻烦,可以有方法直接读取。

  • 字符流处理的单元为2个字节的Unicode字符,分别操作字符、字符数组或字符串,而字节流处理单元为1个字节,操作字节和字节数组。所以字符流是由Java虚拟机将字节转化为2个字节的Unicode字符为单位的字符而成的,所以它对多国语言支持性比较好!

    6、什么是java序列化,如何实现java序列化?	   
    

当两个进程在进行远程通信时,彼此可以发送各种类型的数据。无论是何种类型的数据,都会以二进制序列的形式在网络上传送。发送方需要把这个Java对象转换为字节序列,才能在网络上传送;接收方则需要把字节序列再恢复为Java对象。

实现:只能将支持 java.io.Serializable 接口的对象写入流中。每个 serializable 对象的类都被编码,编码内容包括类名和类签名、对象的字段值和数组值,以及从初始对象中引用的其他所有对象的闭包。

7、运行时异常(非受检异常)与受检异常有什么区别?

使用受检异常时的合法性要在编译时刻由编译器来检查。正因为如此,受检异常在使用的时候需要比非受检异常更多的代码来避免编译错误。
**受检异常的特点在于它强制要求开发人员在代码中进行显式的声明和捕获,否则就会产生编译错误。**这种限制从好的方面来说,可以防止开发人员意外地忽略某些出错的情况,因为编译器不允许出现未被处理的受检异常;从不好的方面来说,受检异常对程序中的设计提出了更高的要求。不恰当地使用受检异常,会使代码中充斥着大量没有实际作用、只是为了通过编译而添加的代码。而非受检异常的特点是,如果不捕获异常,不会产生编译错误,异常会在运行时刻才被抛出。

**非受检异常的好处是可以去掉一些不需要的异常处理代码,而不好之处是开发人员可能忽略某些应该处理的异常。**一个典型的例子是把字符串转换成数字时会发生java.lang.NumberFormatException异常,忽略该异常可能导致一个错误的输入就造成整个程序退出。

猜你喜欢

转载自blog.csdn.net/weixin_43658429/article/details/89817731