JAVA基础面试题2020

JVM、JRE和JDK的关系?

JVM
Java Virtual Machine是Java虚拟机,Java程序需要运行在虚拟机上,不同的平台有自己的虚拟机,因此Java语言可以实现跨平台。
JRE
Java Runtime Environment包括Java虚拟机和Java程序所需的核心类库等。核心类库主要是java.lang包:包含了运行Java程序必不可少的系统类,如基本数据类型、基本函数、字符串处理、线程、异常处理类等,系统缺省加载这个包

如果想要运行一个开发好的Java程序,计算机中只需要安装JRE即可。

JDK
Java Development Kit是提供给Java开发人员使用的,其中包含了Java的开发工具,也包括了JRE。所以安装了JDK,就无需再单独安装JRE了。其中的开发工具:编译工具(javac.exe),打包工具(jar.exe)等

JVM&JRE&JDK关系图
在这里插入图片描述

什么是跨平台性?原理是什么

所谓跨平台性,是指java语言编写的程序,一次编译后,可以在多个系统平台上运行。

实现原理:Java程序是通过java虚拟机在系统平台上运行的,只要该系统可以安装相应的java虚拟机,该系统就可以运行java程序。

Java语言有哪些特点

简单易学(Java语言的语法与C语言和C++语言很接近)

面向对象(封装,继承,多态)

跨平台性(Java虚拟机实现平台无关性)

支持网络编程并且很方便(Java语言诞生本身就是为简化网络编程设计的)

支持多线程(多线程机制使应用程序在同一时间并行执行多项任)

健壮性(Java语言的强类型机制、异常处理、垃圾的自动收集等)

安全性

什么是字节码?采用字节码的最大好处是什么

字节码:Java源代码经过虚拟机编译器编译后产生的文件(即扩展为.class的文件),它不面向任何特定的处理器,只面向虚拟机。

采用字节码的好处

Java语言通过字节码的方式,既解决了传统解释型语言执行效率低的问题,又保留了解释型语言可移植的特点。所以Java程序运行时比较高效,而且,由于字节码并不专对某一种计算机,因此,Java程序经过一次编译便可在多种不同的计算机上运行。

用最有效率的方法计算 2 乘以 8

2 << 3(左移 3 位相当于乘以 2 的 3 次方,右移 3 位相当于除以 2 的 3 次方)。

float f=3.4;是否正确

不正确。3.4 是双精度数,将双精度型(double)赋值给浮点型(float)属于下转型(down-casting,也称为窄化)会造成精度损失,因此需要强制类型转换
float f =(float)3.4; 或者写成 float f =3.4F;。

short s1 = 1; s1 = s1 + 1;有错吗?short s1 = 1; s1 += 1;有错吗

对于 short s1 = 1; s1 = s1 + 1;由于 1 是 int 类型,因此 s1+1 运算结果也是 int型,需要强制转换类型才能赋值给 short 型。

而 short s1 = 1; s1 += 1;可以正确编译,因为 s1+= 1;相当于 s1 = (short(s1 + 1);其中有隐含的强制类型转换。

访问修饰符 public,private,protected,以及不写(默认)时的区别

private : 在同一类内可见。使用对象:变量、方法。 注意:不能修饰类(外部类)
default (即缺省,什么也不写,不使用任何关键字): 在同一包内可见,不使用任何修饰符。使用对象:类、接口、变量、方法。
protected : 对同一包内的类和所有子类可见。使用对象:变量、方法。 注意:不能修饰类(外部类)。
public : 对所有类可见。使用对象:类、接口、变量、方法

Java中&&和&,||和|的区别

java当中的逻辑运算符,&&(短路与)和&表示逻辑与,||(短路或)和|表示逻辑或

&&和&

&&和&都可以表示逻辑与,但他们是有区别的,共同点是他们两边的条件都成立的时候最终结果才是true

不同点是&&只要是第一个条件不成立为false,就不会再去判断第二个条件,最终结果直接为false,而&判断的是所有的条件;

||和|

共同点是只要两个判断条件其中有一个成立最终的结果就是true,区别是||只要满足第一个条件,后面的条件就不再判断,而|要对所有的条件进行判断。

final 有什么用?

被final修饰的类不可以被继承
被final修饰的方法不可以被重写
被final修饰的变量不可以被改变

关键字: throws,throw ,try,catch, finally分别代表什么意义?

try代码块表示可能会产生异常,也可能不会,出现了进行异常就进行捕获catch。finally表示无论是否有异常都会执行的代码块。
throws用在方法上,进行异常的声明,后面可以跟多个异常,它只是声明,不进行处理,告诉别人我可能会产生下列异常,谁调用我就交给谁处理异常。
throw在方法体内,则是抛出一个具体的异常对象,并进行处理。

public String login(UcenterMember member) {
    
    
        //得到登录用户的手机和密码
        String mobile = member.getMobile();
        String password = member.getPassword();

        //判断手机号和密码是否为空
        if(StringUtils.isEmpty(mobile)||StringUtils.isEmpty(password)){
    
    
            throw new GuliException(20001,"登录失败");
        }

final finally finalize区别

final可以修饰类、变量、方法,修饰类表示该类不能被继承、修饰方法表示该方法不能被重写、修饰变量表示该变量是一个常量不能被重新赋值。

finally一般作用在try-catch代码块中,在处理异常的时候,通常我们将一定要执行的代码放在finally代码块中,表示不管是否出现异常,该代码块都会执行,一般用来存放一些关闭资源的代码。

finalize是一个方法,属于Object类的一个方法,而Object类是所有类的父类,该方法一般由垃圾回收器来调用,当我们调用System.gc() 方法的时候,由垃圾回收器调用finalize(),回收垃圾。

this关键字的用法

this是自身的一个对象,代表对象本身,可以理解为:指向对象本身的一个指针。

1.普通的直接引用,this相当于是指向当前对象本身。
2.区分形参与成员变量重名

public Person(String name, int age) {
    
    
    this.name = name;
    this.age = age;
}

3.引用本类的构造函数

class Person{
    
    
    private String name;
    private int age;
    
    public Person() {
    
    
    }
 
    public Person(String name) {
    
    
        this.name = name;
    }
    public Person(String name, int age) {
    
    
        this(name);
        this.age = age;
    }
}

super关键字的用法

super可以理解为是指向自己超(父)类对象的一个指针,而这个超类指的是离自己最近的一个父类。
1.普通的直接引用

与this类似,super相当于是指向当前对象的父类的引用,这样就可以用super.xxx来引用父类的成员。
2.区分子类成员变量与父类成员变量同名,用super进行区分

class Person{
    
    
    protected String name;
 
    public Person(String name) {
    
    
        this.name = name;
    }
 
}
 
class Student extends Person{
    
    
    private String name;
 
    public Student(String name, String name1) {
    
    
        super(name);
        this.name = name1;
    }
 
    public void getInfo(){
    
    
        System.out.println(this.name);      //Child
        System.out.println(super.name);     //Father
    }
}

public class Test {
    
    
    public static void main(String[] args) {
    
    
       Student s1 = new Student("Father","Child");
       s1.getInfo();
    }
}

3、引用父类构造函数(应该为构造函数中的第一条语句)

static存在的主要意义

  • static修饰变量和方法,随着类的加载而被初始化,不属于任何一个实例对象,而是被类的实例对象所共享。

  • 静态代码块随着类的加载而加载,只执行一次

static注意事项

1、静态只能访问静态。
2、非静态既可以访问非静态的,也可以访问静态的。

在 Java 中,如何跳出当前的多重嵌套循环

使用标号:
可以在外面的循环语句前定义一个标号,然后在里层循环体的代码中使用带有标号的break 语句,即可跳出外层循环。例如:

public static void main(String[] args) {
    
    
    ok:
    for (int i = 0; i < 10; i++) {
    
    
        for (int j = 0; j < 10; j++) {
    
    
            System.out.println("i=" + i + ",j=" + j);
            if (j == 5) {
    
    
                break ok;
            }
        }
    }
}

面向对象和面向过程的区别

面向过程:

优点:性能比面向对象高,因为类调用时需要实例化,开销比较大,比较消耗资源;比如单片机、嵌入式开发、Linux/Unix等一般采用面向过程开发,性能是最重要的因素。

缺点:没有面向对象易维护、易复用、易扩展

面向对象:

优点:易维护、易复用、易扩展,由于面向对象有封装、继承、多态性的特性,可以设计出低耦合的系统,使系统更加灵活、更加易于维护

缺点:性能比面向过程低

Java 面向对象编程三大特性:

封装 继承 多态
封装:隐藏对象的属性和实现细节,仅对外提供公共访问方式,将变化隔离,便于使用,提高复用性和安全性。

继承:继承是使用已存在的类的定义作为基础建立父类的技术,父类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。通过使用继承可以提高代码复用性。继承是多态的前提。

多态性:父类或接口定义的引用变量可以指向子类或具体实现类的实例对象。提高了程序的拓展性。

在Java中有两种形式可以实现多态:继承(子类对父类方法的重写)和接口(实现接口并覆盖接口中同一方法)

方法重载(overload)实现的是编译时的多态性(也称为前绑定),而方法重写(override)实现的是运行时的多态性(也称为后绑定)。

抽象类和接口的区别

相同点

接口和抽象类都不能实例化

用于被其他实现或继承
都包含抽象方法,其子类都必须重写这些抽象方法
不同点

抽象类 接口
抽象类使用abstract关键字声明 接口使用interface关键字声明
子类使用extends关键字来继承抽象类。如果子类不是抽象类的话,它需要提供抽象类中所有声明的方法的实现 子类使用implements关键字来实现接口。它需要提供接口中所有声明的方法的实现
抽象类可以有构造器 接口不能有构造器
抽象类中的方法可以是任意访问修饰符(也可以有非抽象方法) 接口中的所有方法只能为:public abstract ****。并且不允许定义为 private 或者 protected
一个类最多只能继承一个抽象类 一个类可以实现多个接口
抽象类的字段声明可以是任意的 接口的字段默认都是public static final ****

接口继承、类继承、类实现三者区别?

一个接口可以继承多个接口,例如:interface m extends intercls1,intercls2{}
一个类可以实现多个接口,
一个类只能继承一个类,这就是JAVA的继承特点

普通类和抽象类有哪些区别?

普通类不能包含抽象方法,抽象类可以包含抽象方法
抽象类不能直接实例化,普通类可以直接实例化。

抽象类能使用 final 修饰吗?

不能,定义抽象类就是让其他类继承的,如果定义为 final 该类就不能被继承,这样彼此就会产生矛盾,所以 final 不能修饰抽象类

成员变量与局部变量的区别有哪些?

作用域
成员变量:针对整个类有效。
局部变量:只在方法内有效。
存储位置
成员变量:存储在堆内存中。
局部变量:存储在栈内存中。
生命周期
成员变量:随着对象的创建而存在,随着对象的消失而消失
局部变量:当方法调用完,或者语句结束后,就自动释放。
初始值
成员变量:有默认初始值。
局部变量:没有默认初始值,使用前必须赋值。

静态变量和实例变量区别

静态变量: 静态变量由于不属于任何实例对象,属于类的,所以在内存中只会有一份,在类的加载过程中,JVM只为静态变量分配一次内存空间。

实例变量: 每次创建对象,都会为每个对象分配成员变量内存空间,实例变量是属于实例对象的,在内存中,创建几次对象,就有几份成员变量。

在一个静态方法内调用一个非静态成员为什么是非法的?

静态方法里,不能调用其他非静态变量,也不可以访问非静态变量成员

静态内部类

定义在类内部的静态类,就是静态内部类。

public class Outer {
    
    

    private static int radius = 1;

    static class StaticInner {
    
    
        public void visit() {
    
    
            System.out.println("visit outer static  variable:" + radius);
        }
    }
}

静态内部类可以访问外部类所有的静态变量,而不可访问外部类的非静态变量;静态内部类的创建方式,new 外部类.静态内部类(),如下:

Outer.StaticInner inner = new Outer.StaticInner();
inner.visit();

成员内部类
定义在类内部,成员位置上的非静态类,就是成员内部类。

public class Outer {
    
    

    private static  int radius = 1;
    private int count =2;
    
     class Inner {
    
    
        public void visit() {
    
    
            System.out.println("visit outer static  variable:" + radius);
            System.out.println("visit outer   variable:" + count);
        }
    }
}

成员内部类可以访问外部类所有的变量和方法,包括静态和非静态,私有和公有。成员内部类依赖于外部类的实例,它的创建方式外部类实例.new 内部类(),如下:

Outer outer = new Outer();
Outer.Inner inner = outer.new Inner();
inner.visit();

局部内部类
定义在方法中的内部类,就是局部内部类。

public class Outer {
    
    

    private  int out_a = 1;
    private static int STATIC_b = 2;

    public void testFunctionClass(){
    
    
        int inner_c =3;
        class Inner {
    
    
            private void fun(){
    
    
                System.out.println(out_a);
                System.out.println(STATIC_b);
                System.out.println(inner_c);
            }
        }
        Inner  inner = new Inner();
        inner.fun();
    }
    public static void testStaticFunctionClass(){
    
    
        int d =3;
        class Inner {
    
    
            private void fun(){
    
    
                // System.out.println(out_a); 编译错误,定义在静态方法中的局部类不可以访问外部类的实例变量
                System.out.println(STATIC_b);
                System.out.println(d);
            }
        }
        Inner  inner = new Inner();
        inner.fun();
    }
}

定义在实例方法中的局部类可以访问外部类的所有变量和方法,定义在静态方法中的局部类只能访问外部类的静态变量和方法。局部内部类的创建方式,在对应方法内,new 内部类(),如下:

 public static void testStaticFunctionClass(){
    
    
    class Inner {
    
    
    }
    Inner  inner = new Inner();
 }

匿名内部类
匿名内部类就是没有名字的内部类,日常开发中使用的比较多。

public class Outer {
    
    
    private void test(final int i) {
    
    
        new Service() {
    
    
        	@Override
            public void method() {
    
    
                for (int j = 0; j < i; j++) {
    
    
                    System.out.println("匿名内部类" );
                }
            }
        }.method();
    }
 }
 //匿名内部类必须继承或实现一个已有的接口 
 interface Service{
    
    
    void method();
}
public class JavaTest2 {
    
    
	public static void main(String[] args) {
    
    
		Person per = new Person() {
    
    
			public void say() {
    
    // 匿名内部类自定义的方法say
				System.out.println("say方法调用");
			}
			@Override
			public void speak() {
    
    // 实现接口的的方法speak
				System.out.println("speak方法调用");
			}
		};
		per.speak();// 可调用
		per.say();// 出错,不能调用
	}
}
 
interface Person {
    
    
	public void speak();
}

这里per.speak()是可以正常调用的,但per.say()不能调用,为什么呢?注意Person per = new Person()
创建的是Person的对象,而非匿名内部类的对象。其实匿名内部类连名字都没有,你咋实例对象
去调用它的方法呢?

若你确实想调用匿名内部类的自定义的方法say(),当然也有方法:

public class JavaTest2 {
    
    
	public static void main(String[] args) {
    
    
		new Person() {
    
    
			public void say() {
    
    // 匿名内部类自定义的方法say
				System.out.println("say方法调用");
			}
 
			@Override
			public void speak() {
    
    // 实现接口的的方法speak
				System.out.println("speak方法调用");
			}
		}.say();// 直接调用匿名内部类的方法
	}
}
interface Person {
    
    
	public void speak();
}

除了没有名字,匿名内部类还有以下特点:

匿名内部类必须继承一个抽象类或者实现一个接口。
匿名内部类不能定义任何静态成员和静态方法。
当所在的方法的形参需要被匿名内部类使用时,必须声明为 final。
匿名内部类不能是抽象的,它必须要实现继承的类或者实现的接口的所有抽象方法。

匿名内部类创建方式:

new/接口{
    
     
  //匿名内部类实现部分
}

局部内部类和匿名内部类访问局部变量的时候,为什么变量必须要加上final?

先看这段代码:

public class Outer {
    
    

    void outMethod(){
    
    
        final int a =10;
        class Inner {
    
    
            void innerMethod(){
    
    
                System.out.println(a);
            }
        }
    }
}

以上例子,为什么要加final呢?是因为生命周期不一致, 局部变量直接存储在栈中,当方法执行结束后,局部变量就被销毁。而局部内部类对局部变量的引用依然存在,如果局部内部类要调用局部变量时,就会出错。加了final,可以确保局部内部类使用的变量与外层的局部变量区分开,解决了这个问题。

成员内部类相关,看程序说出运行结果

public class Outer {
    
    
    private int age = 12;

    class Inner {
    
    
        private int age = 13;
        public void print() {
    
    
            int age = 14;
            System.out.println("局部变量:" + age);
            System.out.println("内部类变量:" + this.age);
            System.out.println("外部类变量:" + Outer.this.age);
        }
    }

    public static void main(String[] args) {
    
    
        Outer.Inner in = new Outer().new Inner();
        in.print();
    }

}

运行结果:

局部变量:14
内部类变量:13
外部类变量:12

重载(Overload)和重写(Override)的区别。重载的方法能否根据返回类型进行区分?

方法的重载和重写都是实现多态的方式,区别在于前者实现的是编译时的多态性,而后者实现的是运行时的多态性。

重载:发生在同一个类中,方法名相同参数列表不同(参数类型不同、个数不同、顺序不同),与方法返回值和访问修饰符无关,即重载的方法不能根据返回类型进行区分

重写:发生在父子类中,方法名、参数列表必须相同,返回值小于等于父类,抛出的异常小于等于父类,访问修饰符大于等于父类(里氏代换原则);如果父类方法访问修饰符为private则子类中就不是重写。

== 和 equals 的区别是什么(掌握)

== 的作用

基本类型:比较的是值是否相同。
引用类型:比较的是引用是否相同。

equals 的作用:比较的都是值是否相同。

情况1:类没有覆盖 equals() 方法。则通过 equals() 比较该类的两个对象时,等价于通过“==”比较这两个对象。

情况2:类覆盖了 equals() 方法。若它们的内容相等,则返回 true (即,认为这两个对象相等)。

String str1 = "hello"; //str1指向静态区
String str2 = new String("hello");  //str2指向堆上的对象
String str3 = "hello";
String str4 = new String("hello");
System.out.println(str1.equals(str2)); //true
System.out.println(str2.equals(str4)); //true
System.out.println(str1 == str3); //true
System.out.println(str1 == str2); //false
System.out.println(str2 == str4); //false
System.out.println(str2 == "hello"); //false
str2 = str1;
System.out.println(str2 == "hello"); //true

说明
当创建String类型的对象时,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果有就把它赋给当前引用。如果没有就在常量池中重新创建一个String对象

为什么重写了equals方法还要重写hashcode方法?(掌握)

重写hashcode方法提高效率。

public clasS Iest0o
//1. 只重写equals方法
@Test
public void test () {
    
    
User usl= new User ( name: “张三",age: 10) ;
User us2=new User (name: "张三",age: 10) ;
System. out.println (usl.equals (us2) ) ;//true 

System.out.print ln (us2.hashCode () ) ;//716157500
System.out.print ln (usl.hashCode () ) ;//729864207
Map <User, Integer> mp =new HashMap <> ();
mp.put (us1, 1) ;
mp.put (us2, 2) ;
System. out.print ln (mp.size () ) ;//2

首先有对象相等,他们的hashcode值也相等这个原则,当我们在用hashmap、hashtable、hashset的时候,如果只重写equals方法,不重写hashchode方法,会发现对象相等,但是它们的hashcode值却不等,当调用map的put方法的时候,两个对象都被put进去了,但是实际上只能put进去一个,因为两个对象是同一个对象。

public clasS Iest0o
//1. 重写equals方法和hashcode方法
@Test
public void test () {
    
    
User usl= new User ( name: “张三",age: 10) ;
User us2=new User (name: "张三",age: 10) ;
System. out.println (usl.equals (us2) ) ;//true 

System.out.print ln (us2.hashCode () ) ;//24022530
System.out.print ln (usl.hashCode () ) ;//24022530
Map <User, Integer> mp =new HashMap <> ();
mp.put (us1, 1) ;
mp.put (us2, 2) ;
System. out.print ln (mp.size () ) ;//1

我们重写equals和hashcode方法,会发现对象相等,hashcode值也相等,并且,只能put进去一个对象,验证了我们的原则。
原则:两个对象相等(equals方法),他们的hashcode()值一定相等。
两个对象不等(equals方法),他们的hashcode()值不一定不等
两个对象的hashcode()值相等,对象不一定等
两个对象的hashcode()值不等,对象一定不等

对象的相等与指向他们的引用相等,两者有什么不同?

对象的相等 比的是内存中存放的内容是否相等而 引用相等 比较的是他们指向的内存地址是否相等。

什么是字符串常量池?

字符串常量池位于堆内存中,专门用来存储字符串常量,可以提高内存的使用率,避免开辟多块空间存储相同的字符串,在创建字符串时 JVM 会首先检查字符串常量池,如果该字符串已经存在池中,则返回它的引用,如果不存在,则实例化一个字符串放到池中,并返回其引用。

String str="i"与 String str=new String(“i”)一样吗?

不一样,因为内存的分配方式不一样。String str="i"的方式,java 虚拟机会将其分配到常量池中;而 String str=new String(“i”) 则会被分到堆内存中。

String s = new String(“xyz”);创建了几个字符串对象

两个对象,一个是在字符串常量池中的"xyz",一个是用new创建在堆上的对象。

如何将字符串反转?

使用 StringBuilder 或者 stringBuffer 的 reverse() 方法。

// StringBuffer reverse
StringBuffer stringBuffer = new StringBuffer("hello world").reverse()
System. out. println(stringBuffer ); // dlrow olleh
// StringBuild  reverse
StringBuffer stringBuild = new StringBuild("hello world").reverse()
System. out. println(stringBuild ); // dlrow olleh

String 类的常用方法都有那些?

indexOf():返回指定字符的索引。
charAt():返回指定索引处的字符。
replace():字符串替换。
trim():去除字符串两端空白。
split():分割字符串,返回一个分割后的字符串数组。
getBytes():返回字符串的 byte 类型数组。
length():返回字符串长度。
toLowerCase():将字符串转成小写字母。
toUpperCase():将字符串转成大写字符。
substring():截取字符串。
equals():字符串比较。

substring()右边取值和数组右边取值

substring(0,n)方法截取一段字符串,后面一位截取的是空,应该截取到的是第n-1位
字符串、数组等,长度是n,最后一位索引应该是n-1

在使用 HashMap 的时候,用 String 做 key 有什么好处?

HashMap 内部实现是通过 key 的 hashcode 来确定 value 的存储位置,因为字符串是不可变的,所以当创建字符串时,它的 hashcode 被缓存下来,不需要再次计算,所以相比于其他对象更快。

String和StringBuffer、StringBuilder的区别是什么?String为什么是不可变的(掌握)

可变性
String中private final char value[]来实现字符串的存储,所以string对象是不可变的。
StringBuilder与StringBuffer都继承自AbstractStringBuilder类,在AbstractStringBuilder中也是使用字符数组保存字符串,char[] value,这两种对象都是可变的。
线程安全性
String是线程安全。
StringBuffer是线程安全的。
StringBuilder不是线程安全的。
性能
stringbuilder性能高于stringbuffer

对于三者使用的总结

如果要操作少量的数据用 = String
单线程操作字符串缓冲区 下操作大量数据 = StringBuilder
多线程操作字符串缓冲区 下操作大量数据 = StringBuffer

int与Integer有什么区别?

从 Java 5 开始引入了自动装箱/拆箱机制,使得二者可以相互转换。

Java 为每个原始类型提供了包装类型:
原始类型: boolean,char,byte,short,int,long,float,double
包装类型:Boolean,Character,Byte,Short,Integer,Long,Float,Double

如果整型字面量的值在-128到127之间,那么自动装箱时不会new新的Integer对象,而是直接引用常量池中的Integer对象。如果不在常量池范围内,在自动装箱过程中需new 新的Integer对象,所以地址不一样。

public static void main(String[] args) {
    
    
    Integer a = new Integer(3);
    Integer b = 3;  // 将3自动装箱成Integer类型
    int c = 3;
    System.out.println(a == b); // false   两个引用没有引用同一对象
    System.out.println(a == c); // true   a自动拆箱成int类型再和c比较
    System.out.println(b == c); // true   b自动拆箱成int类型再和c比较

    Integer a1 = 128;
    Integer b1 = 128;
    System.out.println(a1 == b1); // false

    Integer a2 = 127;
    Integer b2 = 127;
    System.out.println(a2 == b2); // true
}

注意
包装类和基本类型比较时,只要两个变量的值是相等的,则结果为true(因为jvm会将包装类自动拆包装为基本类型,然后进行比较,实际上就变为两个变量的值)

猜你喜欢

转载自blog.csdn.net/weixin_44421869/article/details/108295604