1.java基础面试题

1.java基础面试题

java概述

1.JDK,JRE,JVM的关系

jdk是提供给开发人员使用的,包含了jre和java开发工具,所以安装了jdk就不需要安装jre了
jre是java程序运行环境,包含了jvm和java程序所属的核心类库,如果只需要运行java程序,只需要安装jre即可
jvm是java虚拟机,java程序需要运行到虚拟机上,不同的操作系统都有自己的jvm,一次编译到处运行。所以java语言可以实现跨平台
在这里插入图片描述

2.JAVA中的异常体系

(1)java中的所有异常都来自顶级父类Throwable
(2)Throwable下有两个子类Exception,Error
(3)Error是程序无法处理的错误,一旦出现这个错误,程序就会被迫停止运行,例如OOM
(4)Exception不会导致程序停止,又分为运行时异常RuntimeException和检查异常CheckdException
(5)RuntimeException常常发生在程序运行过程中,会导致程序的当前线程执行失败。CheckedException常常发生在程序编译过程中,会导致程序编译不通过

java基础语法

1.instanceof关键字的作用

instance严格来说是java的一个双目运算符,用来测试一个对象是不是一个类的实例。
当obj为Class的对象或者是其直接或者间接子类,或者是接口的实现类,结果都返回true,否则返回false;
需要注意的是编译器会检查obj能不能转换成右边的class类型,如果不能转换直接编译时就报错不通过,所以obj必须是引用数据类型不能是基础数据类型。

2.java关键字instanceof用法,判断的原理

(1)obj不能是基本数据类型,必须是引用数据类型。否则编译不通过
在这里插入图片描述
(2)obj如果是null,则返回false

System.out.println(null instanceof Object); //false

(3)obj为class类的实例对象(最常用)

public class TestInstanceof {
    public static void main(String[] args) {
        Integer a=1;
        System.out.println(a instanceof Integer);//true
    }
}

(4).obj为接口的实现类

package com.zcc.javase;
import java.util.ArrayList;
import java.util.List;
public class TestInstanceof {
    public static void main(String[] args) {
        System.out.println(new ArrayList<>() instanceof List);//true
        List<String> list=new ArrayList<String>();
        System.out.println(list instanceof ArrayList);//true
    }
}

(5).obj为class类的直接或者间接子类

public class TestInstanceof {
    public static void main(String[] args) {
        A a=new A();
        B b=new B();
        C c=new C();
        System.out.println(a instanceof A);//true
        System.out.println(b instanceof A);//true
        System.out.println(c instanceof A);//true
        System.out.println(a instanceof B);//false
        System.out.println(b instanceof B);//true
        System.out.println(c instanceof B);//true
        System.out.println(a instanceof C);//false
        System.out.println(b instanceof C);//falseSystem.out.println(c instanceof C);//true
    }
}
class A{}
class B extends A{}
class C extends B{}

(6).问题
在这里插入图片描述
如果obj强制转换为T时发生变异错误,则关系表达式的instanceof 同样会产生编译错误。换种说法,如果 obj不为null并且(T)obj不抛ClassCastException异常则该表达式值为true, 否则值为 false
所以为什么a instanceof String编译报错。因为(String)p1是不能通过编译的,而 (List)p1可以通过编译。
如果用伪代码描述:
在这里插入图片描述

3.java关键字break ,continue ,return 的区别及作用

break 跳出当前循环体
continue 跳出本次循环,继续执行下次循环
return 结束当前方法直接返回

4.泛型方法中的<T>有什么用

(1)如果想声明一个静态的泛型方法,那么static后一定要加<T>

public static <T> Result<T> build(T data)

(2)非静态的泛型方法中加修饰符和返回值之间加<T>,可以声明此方法独有某个类T,而不去和类中限定的T产生冲突
在这里插入图片描述
在这里插入图片描述
可以看出如果方法不加<T>,形参只能跟类指定的<T>一样。
如果加<T>,代表形参可以是自己独有的某个类,不跟类中限定T冲突

5.java变量和对象的作用域

变量的作用域:java用一对大括号作为语句块的范围,称为作用域。作为在作用域定义的变量,只能在作用域中才可以使用,离开作用域,定义变量所分配的内存空间会被JVM回收。
对象的作用域: java对象的存在时间会超过作用域的范围之外。例如下面这段代码

{
	String s = new String("a String");
}

对于变量s,会在作用域终点结束占用内存空间会被JVM回收,但是指向的String对象依然占据着内存空间。虽然仍然存在但是我们没有办法继续使用这个对象,因为指向它的唯一一个句柄已经超出作用域的边界。
这样造成结果就是:对于new创建的对象,只要我们愿意,就会一直保留下去。这个问题在c++里最为明显,每次完成工作,必须将对象手动清除。
java有一个特别的"垃圾收集器",它会查找用new创建的对象,并辨别其中哪些不再被引用。随后,它会自动释放闲置对象所占用的内存,以便新对象使用。这意味着我们根本必须操心内存的回收问题。只需要简单创建对象,一旦不再需要它们,它们会自动离去。

7.System.arraycopy()方法和Arrays.copyOf()的使用

两个方法的作用都是将一个数组指定长度的元素复制到另一个数组中。
System.arraycopy()的源码如下

/**
 * @param      src      the source array.
 * @param      srcPos   starting position in the source array.
 * @param      dest     the destination array.
 * @param      destPos  starting position in the destination data.
 * @param      length   the number of array elements to be copied.
 */
@HotSpotIntrinsicCandidate
public static native void arraycopy(Object src,  int  srcPos,
                                    Object dest, int destPos,
                                    int length);

src表示源数组,srcPos表示源数组起始位置,desc表示目标数组,destPos表示目标数组的起始位置,length表示要复制的长度。
在这里插入图片描述

Arrays.copyOf()实际上底层调用的还是System.arraycopy()方法
如果新的数组长度小于原数组长度,第五个参数就用新数组长度相当于截取,否则用旧数组长度相当于扩容
在这里插入图片描述

8.浮点类型中更精确的数据类型

BigDecimal

java面向对象

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

重载是方法名相同,形参列表不同(个数,顺序,参数类型),与方法的返回值和修饰符无关。所以重载的方法不能根据返回值类型区分
重写是方法名相同,形参列表也相同,发生在父子类中。返回值要小于等于父类,抛出异常要小于等于父类,修饰符要大于等于父类。
如果父类方法的访问修饰符是private,那么子类就不能重写这个方法。
在这里插入图片描述

2.final有什么用?

final是java的关键字,用来修饰变量,方法,类
修饰变量时,表示该变量是一个常量,不可以重新赋值。需要注意的是如果修饰的是引用类型变量的话,指的是变量的引用不可变,引用的内容是可以变化的。
修饰方法时,表示该方法不可以被重写。
修饰类时,表示该类不可以被继承

3.super和this的区别

this可以理解为当前对象本身的指针。
主要用法有
(1)当成员变量名与形参名重复时,可以用this加以区分。
(2)在同一个类中,方法之间可以相互调用用this.方法,this一般省略不写。
(3)可以引用本类的构造方法this()

super指的是直接父类
主要用法有
(1)如果子类跟父类成员变量同名时,可以用super进行区分,
(2)如果子类跟父类方法同名时,可以用super区分。
(3)可以引用父类的构造方法super().

super和this的区别
(1)super指的是当前对象的直接父类
(2)this指的是对象本身
(3)super()和this()都可以在构造方法中调用别的构造方法,super()调用的是父类的构造方法,this()调用的是本类中其它的构造方法
(4)当调用构造方法发时,this()和super()都必须在第一行,而且不能同时出现。因为其它的构造方法也会有super()语句,所以相同的语句会失去意义,编译器不会通过
(5)this和super指的都是对象,不能在static修饰的资源中使用。(static修饰的都是先于对象存在的,属于类范畴的东西,而this和super属于对象范畴的)

4.谈谈你对static的了解

1.static主要作用就是创建类的实例对象共享的成员变量和方法。所以如果某个成员变量是被所有对象所共享的,就应该用static修饰
2.静态资源是类初始化加载的,而非静态的资源是创建对象的时候产生的。所以被static修饰的内容优先于对象存在。所以对于静态资源来说,是不可能知道类中有哪些非静态资源的;
因此静态方法和静态代码块是不能引用非静态资源的。而实例方法对静态资源和非静态资源都可以引用。
常用的应用场景
1.修饰成员变量
2.修饰方法 可以用类名调用
3.静态代码块,它只会在类加载的时候执行一次,因此很多初始化的操作都会放到静态代码块中。
4.修饰内部类(静态内部类,用new 类名.内部类名()调用)
5.静态导包(import static),如果一个静态方法复用性比较高,那么就可以静态导入可以直接用方法名调用。

5.讲讲类的实例化顺序(静等石垢)

父类的静态变量/静态代码块—>子类的静态变量/静态代码块—>父类的实例变量/构造代码块—>父类的构造方法—>子类实例变量/构造代码块—>子类的构造方法
值得注意的是静态变量跟静态代码块的顺序,构造代码块和实例变量的顺序都是根据指令的顺序决定的。发生在类加载器加载类过程的的initialization初始化阶段.

public class Father {
    private static int id=getId();
    static{
        System.out.println("父类静态代码块");
    }
    private static int getId(){
        System.out.println("父类静态方法给静态变量传值");
        return 1;
    }
    private String name=getName();
    {
        System.out.println("父类构造代码块");
    }
    private String getName(){
        System.out.println("父类的非静态方法给非静态变量传值");
        return "zcc";
    }

    public Father() {
        System.out.println("父类构造方法");
    }
}
public class Son extends Father {
    private static int age=getAge();
    static{
        System.out.println("静态代码块");
    }
    private static int getAge(){
        System.out.println("静态方法给静态变量传值");
        return 1;
    }
    private String sex=getSex();
    {
        System.out.println("构造代码块");
    }
    private String getSex(){
        System.out.println("非静态方法给非静态变量传值");
        return "zcc";
    }

    public Son() {
        System.out.println("子类构造方法");
    }

    public static void main(String[] args) {
        new Son(); //===>show console
    }
}
父类静态方法给静态变量传值
父类静态代码块
静态方法给静态变量传值
静态代码块
父类的非静态方法给非静态变量传值
父类构造代码块
父类构造方法
非静态方法给非静态变量传值
构造代码块
子类构造方法

6.什么是面向对象?跟面向过程的区别是什么?面向对象的特征有哪些方面?

面向对象和面向过程是处理问题的两种不同的角度,面向过程更注重事情的每一个的步骤及顺序,面向对象更注重事情的参与者、各自需要做什么。

抽象
抽象是将一些对象的共同特征总结出来构造成一个类的过程,包括数据抽象和行为抽象。抽象只关注这些对象有哪些属性和行为,而不关注这些行为的细节是什么。

封装
封装就是隐藏类中一切可隐藏的东西,只向外界提供必要的接口,这样使用者无法修改类内部的重要数据。提高了程序代码的安全性,也更方便我们维护

继承
继承是把已经存在的类为基础定义新的类,新的类可以继承父类的所有成员变量和方法,减少代码的复用性。也可以自己定义新的数据和功能。

多态
同一个操作被不同的对象接收时会产生不同的执行结果。
程序中定义的引用变量所指向的具体类型和该引用变量发出的方法到底是调用哪个类的实现方法在编译时期是不确定的,必须在程序运行期间才能决定。(编译看左边,运行看右边)
实现多态必须有三个条件。1.继承 2.方法的重写 3.父类的引用指向子类对象

7抽象类和接口的区别

抽象类是用来捕捉子类的通用特征的,接口是抽象方法的集合。
从设计层面来讲,抽象类是是一种模板设计,接口是行为规范。

相同点:
抽象类和接口都不能实例化
都是为了让其它类继承和实现
都包含抽象方法,其子类必须重写这些抽象方法

不同点:

参数抽象类接口

抽象类使用abstract关键字声明接口使用interface关键字声明

子类使用extends关键字来继承抽象类。如果子类不是抽象类的话,它需要重写抽象类所有声明抽象方法子类使用implements关键字来实现接口。它需要提供接口中所有声明的方法的实现


抽象类可以有构造器接口不能有构造器
访



抽象类中的方法可以是任意修饰符接口方法默认修饰符是public,并且不允许定义为private或者protected


一个类最多只能继承一个抽象类一个类可以实现多个接口



抽象类的字段声明可以是任意的接口的字段默认都是static和final的

接口和实现类各有优缺点,在接口和抽象类选择上应遵循以下原则:
1.行为模型最好选择接口,尽量少用抽象类。
2.如果是要定义子类的行为,又要为子类提供通用的功能,选择抽象类。

8.接口可以有非抽象方法吗?

接口中成员变量默认是public static final修饰,可以省略不写。
接口中的方法默认是public abstract修饰,可以省略不写。
JDK1.8之后 JDK可以添加default和static修饰的非抽象方法。默认是public修饰的,可以省略不写。

9.抽象类和普通类的区别

1.抽象类不能实例化
2.抽象类的子类必须重写抽象类中所有的抽象方法,除非这个子类也是个抽象类

10.内部类的分类有哪些?

静态内部类,成员内部类,局部内部类,匿名内部类

(1)静态内部类
定义在类内部的静态类就是静态内部类,静态内部类中,可以访问外部类所有的静态资源,但不能访问非静态资源。
创建方式:用new 外部类名.内部类名()

package com.zcc.javase;
public class Outer {
    private static int num=100;
    private String share="篮子";
    public static class Inner{
        public void eat(){
            System.out.println(num);
            System.out.println("吃东西");
        }
    }
}
class Test{
    public static void main(String[] args) {
        Outer.Inner inner=new Outer.Inner();
        inner.eat();
    }
}

(2)成员内部类
定义在类内部的非静态类就是成员内部类,成员内部类中可以调用外部类中所有的静态资源和非静态资源。
创建方式: 外部类实例.new 内部类名()

package com.zcc.javase;
public class Outer {
    private static int num=100;
    private String share="篮子";
    public class Inner{
        public void eat(){
            System.out.println(num);
            System.out.println(share);
            System.out.println("吃东西");
        }
    }
}
class Test{
    public static void main(String[] args) {
        Outer outer = new Outer();
        Outer.Inner inner = outer.new Inner();
        inner.eat();
    }
}

(3)局部内部类
定义在方法中的类就是局部内部类
定义在实例方法中的局部类可以访问外部类的所有变量和方法,定义在静态方法中的局部类只能访问外部类的静态资源。
局部内部类创建方法必须在方法内: new 内部类名()

package com.zcc.javase;
public class Outer {
    private static int num=100;
    private String share="面包";
    public void TestInner(){
        class Inner{
            private String food="糯米藕";
            public void eat(){
                System.out.println("吃东西:"+num);
                System.out.println("吃什么:"+share+food);
            }
        }
        Inner inner=new Inner();
        inner.eat();
    }
    public static void TestInner2(){
        class Inner2{
            private String food="面包";
            public void eat(){
                System.out.println("吃东西"+num);
                System.out.println("吃什么:"+food);
            }
        }
        Inner2 inner2=new Inner2();
        inner2.eat();
    }
}
class Test{
    public static void main(String[] args) {
        new Outer().TestInner();
        new Outer().TestInner2();
    }
}

(4)匿名内部类
匿名内部类就是没有名字的内部类,日常开发中使用的比较多。
有如下特点

  • 匿名内部类必须继承一个抽象类或者实现一个接口
  • 匿名内部类不能是抽象的,所以要继承或者实现所有的抽象方法
  • 匿名内部类中不能有静态的资源(静态资源需要类名调用,而匿名内部类没有名字)
  • 所在方法的形参需要被内部类使用时,需要加final关键字匿名内部类JDK8.0不用final
package com.zcc.javase;
import com.sun.deploy.services.Service;
public class Outer {
    private static int num=100;
    private String share="面包";
    public void TestInner(){
        new Runnable() {
            @Override
            public void run() {
                System.out.println("跑起来");
            }
        }.run();
    }
}
class Test{
    public static void main(String[] args) {
        new Outer().TestInner();
    }
}

11.匿名内部类,局部内部类使用局部变量为啥要加final?为什么JDK1.8之后就不用加final了?

当匿名内部类所在的方法执行结束后,局部变量就会被销毁。所以内部类会拷贝一份局部变量作为自己的成员变量使用。
如果此时局部变量值改变了,会导致拷贝的值跟局部变量的数据不一致。
加上final关键字,局部变量的值就不会改变。保护了数据的一致性。

package com.zcc.javase;
public class Outer {
    private static int num=100;
    private String share="面包";
    public void TestInner(){
        final String name="abc";
        new Runnable() {
            @Override
            public void run() {
                System.out.println("跑起来"+name);
            }
        }.run();
    }
}
class Test{
    public static void main(String[] args) {
        new Outer().TestInner();
    }
}

在JDK8中如果我们在匿名内部类中需要访问局部变量,那么这个局部变量不需要用final修饰符修饰。看似是一种编译机制的改变,实际上就是一个语法糖(底层还是帮你加了final)。但通过反编译没有看到底层为我们加上final,但我们无法改变这个局部变量的引用值,如果改变就会编译报错。

12.当一个对象被当作参数传递到一个方法后,是值传递还是引用传递

1.java方法的调用都是值传递,方法得到的是所有参数值的一个拷贝,所以方法改变的是拷贝变量的内容,改变不了传递变量的内容。
2.只是当对象作为参数传递给方法时,方法得到是对象引用的拷贝,传递的对象和拷贝的对象都是指向同一个内存地址。所以改变方法中对象参数的状态,传递对象也会发生改变。
3.但是拷贝的对象如果引用了一个新的对象,原对象的指向是不会改变的。

package com.zcc.javase;
import java.util.ArrayList;
import java.util.List;
public class TestPass {
    public static void exchange(Object a,Object b){
        Object c=a;
        a=b;
        b=c;
        System.out.println("a = " + a);
        System.out.println("b = " + b);
    }
    public static void main(String[] args) {
        int num1=20;
        int num2=10;
        exchange(num1,num2);//a=10  b=20
          //基本数据类型的传递变量不会因方法发生值的改变
        System.out.println("num1 = " + num1);//20
        System.out.println("num2 = " + num2);//10
        String arr1[]=new String[]{"嘿嘿"};
        String arr2[]=new String[]{"哈哈"};
        exchange(arr1,arr2);
        //引用数据类型的传递变量不会因方法引用新的对象而改变
        System.out.println("arr1 = " + arr1[0]);//嘿嘿
        System.out.println("arr2 = " + arr2[0]);//哈哈
        change(arr1);
        //引用数据类型的传递变量会因为方法参数状态的变化而变化(因为都是指向同一个内存地址)
        System.out.println("arr1 = " + arr1[0]);//嘻嘻
    }
    public static void change(Object[] obj){
        obj[0]="嘻嘻";
    }
}

IO流

1.什么是序列化和反序列化?

序列化: 将java对象转换为字节序列的过程叫序列化
反序列化: 是将字节序列转化为java对象的过程

2.为什么需要序列化?

我们知道不同线程/进程进行远程通信可以相互发送各种数据,包括文本图片视频等,java对象不能直接传输,所以需要转化为二进制传输,所以需要序列化。

3.什么情况下需要序列化?

当你想把内存中对象的状态保存到文件或者数据库中的时候;
RPC框架消费者调用的参数需要先序列化成二进制数据传输给提供者,提供者在把二进制数据反序列成java对象完成方法的远程调用。之后返回结果还是要序列化成二进制数据传输给消费者,消费者再反序列化成java对象;
当你想用套接字在网络上传输对象的时候;
当你想通过RMI传输对象的时候

4.怎么序列化?

实现Serializable接口,实现这个接口就会添加一个serialVersionUID的版本号。
这个版本号用来验证反序列化时是否用的是同一个类。
序列化是通过ObjectOutputStream类的writeObject()方法将对象直接写出
反序列化是通过ObjectInputStream类的readObject()方法从流中读取数据

5.反序列化可能会需要什么问题?

可能会导致InvalidClassException异常
如果没有显式的声明序列版本UID,如果对对象进行了改动,那么验证反序列化时会因为版本不一致,导致运行时出现InvalidClassException异常

6.BIO、NIO和AIO的区别

BIO同步阻塞I/O模式: 一个连接一个线程,数据的读取和写入必须阻塞在一个线程内等待其完成,客户端有连接请求时服务器就需要开启一个线程进行处理,线程开销大。
NIO同步非阻塞的I/O模型: 一个请求一个线程,但客户端发送的连接都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理
AIO异步非阻塞的IO模型: 一个有效的请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理。
BIO是面向流的,NIO是面向缓冲区的;BIO的各种流都是阻塞的,而NIO是非阻塞的;BIO的stream是单向的,NIO的channel是双向的

反射

1.什么是反射机制?

java反射机制是运行状态中,对于任意一个类,都能知道这个类的所有属性和方法;对于任意一个对象,都能调用它的任意一个属性和方法,这种动态获取信息和动态调用对象方法的功能称为java语言的反射机制

静态编译 在编译时确定类型,绑定对象
动态编译 在运行时确定类型,绑定对象

2.反射机制的应用场景有哪些

1.jdbc连接数据库的时候需要通过反射加载数据库的驱动程序
2.Spring框架的ioc(动态加载管理bean)还有Aop动态代理功能都用到了反射机制

3.Java获取反射的三种方法

1.通过类名.class方法获得 Object.class
2.通过实例对象的getClass()方法获得 obj.getClass()
3.通过类的完全限定名获得 Class.forName()

常用API

1.String和StringBuffer、StringBuilder的区别是什么?String为什么是不可变的

可变性。
String是用final修饰的字符数组保存字符串,所以String对象是不可变的。
而StringBuffer和StringBuilder都继承自AbstarctStringBuilder,里面也是是用字符数组(char[] value)保存字符串,但是没有用final修饰,所以是可变的。

线程安全性。
因为String对象是不可变的,可以理解为常量,所以线程是安全的。
StringBuffer对方法加了同步锁,也是线程安全的。
StringBuilder没有加锁,是非线程安全的。

性能
每次对String对象进行改变的时候,都会生成一个新的String对象,然后将指针指向新的String对象。
StringBuffer,StringBuilder每次都是对象本身进行操作,相同情况下使用StringBuilder对比StringBuffer能获得10%-15%的性能提升,却要冒多线程不安全的风险。

三者使用的总结
如果是少量数据操作用String
如果是多线程情况下,有大量数据操作用 StringBuffer
如果是单线程情况下,有大量数据操作用 StringBuilder

2.String.format()的详细用法

String类的format()方法用于创建格式化的字符串以及连接多个字符串对象。

转换符详细说明示例
%s字符串类型“appendId=%s”
%c字符“sex=%c”
%b布尔类型“flag=%b”
%d整数类型“id=%d”
public static void main(String[] args) {
    String str="%s%c%b%d";
    String format = String.format(str, "String", 'c', true, 12);
    System.out.println("format="+format);
}
output:
format=Stringctrue12

3.什么是字符串常量池

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

4.String有哪些特性

1.不变性
String类利用了final修饰的char[]数组存储字符(private final char value[]),对字符串进行的任何操作,其实都只是创建了一个新的对象,再把引用指向该对象,原来String的内容是不会改变的。

2.常量池优化
String创建对象后,会把字符串存在常量池中,下次如果有相同对象创建,就直接返回引用。避免了为相同的字符串多次开辟空间,提高了内存的使用率。

3.final
String类也是final修饰的,表示该类不能被继承,提高了系统的安全性。

5.String真的是不可变的吗

我觉得如果别人问这个问题的话,回答不可变就可以了。下面只是给大家看两个有代表性的例子:
1.String不可变但不代表变量引用不可以变,只是原来String对象的内容不可变
2.通过反射是可以修改所谓的“不可变”对象

public class TestString {
    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
        String str="zcchenglq";//zcchenglq
        System.out.println(str);
        Class<String> clazz = String.class;
        Field value = clazz.getDeclaredField("value");
        value.setAccessible(true);
        char valueStr[] = (char[])value.get(str);
        valueStr[0]='w';
        System.out.println(str);//wcchenglq
    }
}

反射其实是可以获取到String中的私有成员变量char value[]属性的,通过改变value的状态就会改变String内容,但是我们一般不会这么做。只要知道就行了。

6.int 和 Integer 有什么区别

为了能够将基本数据类型当成对象操作,Java为每个基本输出类型提供对应的包装类型,int的包装类就是Integer

int和Integer区别

  1. Integer是int的包装类型,int是基本数据类型
  2. Integer必须实例化后才能使用,int不需要
  3. Integer的默认值是null,int的默认值为0。
  4. Integer是对象的引用,指向new的Integer对象,int是直接存储数据值。

int和Integer的深入对比
1.两个new生成的Integer对象比较,永远是不相等的,因为指向不同的内存地址
2.new Integer()对象跟int比较,如果两个变量的值相等,结果就是true.因为包装类跟基本数据比较,会自动拆箱转化为基本数据类型进行比较
3.非new生成的Integer对象跟new生成的Integer对象比较是,结果是false,因为非new生成的Integer对象是通过Integer.valueOf方法获取Integer对象,最终也是通过new关键字生成。
在这里插入图片描述

4.对于两个非new生成的Integer对象进行比较时,如果两个变量的值都在[-128,127]之间而且相等,则比较结果为true,否则为false.因为Integer类中定义了一个静态内部类是IntegerCache,这个内部类封装一个存储数值为-128至127之间Integer对象的数组cache.所以对于两个非new产生的Integer对象,如果数值相等且在-128到127之间,那么它们都是从数组cache中获取,相当于对象的复用,所以是相等的。
在这里插入图片描述

java集合

1.HashMap与HashTable的区别(线程安全性,key的null值,起始长度和扩容,数据结构,效率)

  • HashMap是非线程安全的,HashTable是线程安全的。因为HashTable的方法中加入了synchronized关键字,变成了同步方法;
  • HashMap的key可以存储为null值,HashTable不可以
  • HashMap的初始容量是16每次扩容是原来的2倍,HashTable初始容量是11,每次扩容都是原来的2n+1。传入指定的长度创建HashTable时,容量就是指定的长度,而传入指定长度创建HashMap时,容量是大于该长度的最小的2的幂次方。
  • HashTable的数据结构是数组+链表。JDK1.8以后HashMap的数据结构是数组+链表+红黑树。
  • 因为HashTable是线程安全的,所以性能要低于HashMap

2.ArrayList 和 LinkedList 的区别是什么?ArrayList 和 Vector 的区别是什么?

ArrayList 和 LinkedList 的区别是什么?

  • 数据结构实现: ArrayList实现是数组,LinkedList实现是双向链表
  • 随机访问效率: ArrayList的效率要高于LinkedList,因为LinkedList是线性存储结构,需要指针从前往后依次查找
  • 插入和删除的效率: LinkedList的效率比ArrayList高,因为ArrayList是用数组存取数据,删除插入操作需要操作点之后的所有数据进行移动。
  • 内存空间占用:LinkedList更占据内存,因为除了节点要存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素
  • 线程安全: LinkedList和ArrayList都是不同步的,都是线程不安全的。

ArrayList 和 Vector 的区别是什么?

  • 线程安全:Vetor使用了Synchronized来实现线程同步,是线程安全的。ArrayList非线程安全。
  • 性能:ArrayList性能要高于Vetor
  • 扩容:ArrayList和Vetor都会根据实际需要动态调整容量,ArrayList每次会增加50%,而Vetor会增加1倍

3.HashMap的长度为什么是2的幂次方?为什么进行2次扰动?

为什么HashMap的长度必须是2的幂次方
(1)因为计算key的存储下标是用hash值跟数组长度进行取模运算得到的,如果此时数组长度为2的幂次方,那么取模运算就等效于 hash值跟(数组长度-1)的按位与运算,提高了运算的效率。
(2)另外一点就是当数组长度为偶数时,那数组长度-1就是奇数,奇数的二进制最后一位肯定是1,这样便保证了按位与运算结果即可能是偶数也可能是奇数。若数组长度为奇数,任何hash值都会被散列到数组的偶数下标位置。这样便浪费近一半的空间。所以数组长度取2的幂次方也为了散列表分布更加均匀,减少hash碰撞的概率。

为什么进行2次扰动
如果直接用hashcode的值跟数组长度-1做按位与运算计算存储下标的话,因为hashMap初始长度都不高,所以hashcode值的高位相当于没有参与运算,这将大大增加hash碰撞的概率,所以HashMap中hash()方法是用key的hashcode值跟它的高16位进行异或运算后,得出的hash值再跟数组长度-1进行与运算。这样两个步骤下来得到的存储下标更具有随机性,减少hash碰撞概率。

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

4.谈谈你对hash的了解?什么是哈希冲突?HashMap是怎么解决哈希冲突的?

Hash一般翻译成"散列",就是把任意长度的输入通过散列算法,变换成固定长度的输出,该输出就是散列值(哈希值)。这种转换是一种压缩映射,通常散列值的空间要远小于输入的空间。
当两个不同的输入值,通过散列函数计算出相同的散列值的现象,我们就把它叫做哈希冲突(哈希碰撞)。

Hash的特点
1.哈希值不可以反向推导出原始数据
2.输入数据的微小变化会得到完全不同的hash值,相同数据会得到相同的hash值
3.hash算法的计算要高效,长的文本也能快速计算出hash值
4.hash算法的冲突概率要小

简单说一下HashMap是使用哪些方法有效解决哈希冲突。

1.使用拉链法来连接拥有相同hash值的数据
数组的特点是查询效率高,删除插入效率低;链表的特点是查询效率低,插入删除效率高。所以hash将数组和链表结合在一起,发挥各自的优点,使用拉链法解决哈希冲突。

2.使用2次扰动函数(hash函数)来降低哈希冲突的概率,使得数据分布更平均
如果直接用hashcode的值跟数组长度-1做按位与运算计算存储下标的话,因为hashMap初始长度都不高,所以hashcode值的高位相当于没有参与运算,这将大大增加hash碰撞的概率,所以HashMap中hash()方法是用key的hashcode值跟它的高16位进行异或运算后,得出的hash值再跟数组长度-1进行与运算。这样两个步骤下来得到的存储下标更具有随机性,减少hash碰撞概率。

3.引入红黑树进一步降低了时间复杂度,使得遍历更快
当HashMap有大量数据时,每个节点的链表长度就会比较多,因为链表查询效率低,需要从前往后把节点挨个遍历,时间复杂度是O(n),为了解决这个问题,JDK1.8新增红黑树数据结构,让复杂度降低至O(logn);

5.Map遍历的5种方式

首先了解3个概念
Entry:由于map中存放的元素均为键值对,每一个键值对都存在一个映射关系。所以Map中采用Entry内部类来表示一个映射项,映射项包含key和value
所以Map.Entry有getKey()和getValue()方法

entrySet:看名字就知道这是键值对的set集合,里面存放的都是Map.Entry类型,一般可以通过map.entrySet()得到

keySet: keySet是键的集合,Set里面存放的是key类型

五种遍历方式

//第一种:通过map.entrySet()遍历key和value
for (Map.Entry<String, Object> entry : map.entrySet()) {
    System.out.println(entry.getKey()+"====>"+entry.getValue());
}

//第二种:通过map.keySet()遍历key和value
for (String key : map.keySet()) {
    System.out.println(key+"====>"+map.get(key));
}

//第三种:通过迭代器遍历map的entrySet,这种效率高比较推荐
Iterator<Map.Entry<String, Object>> iterator = map.entrySet().iterator();
while (iterator.hasNext()){
    Map.Entry<String, Object> entry = iterator.next();
    System.out.println(entry.getKey()+"====>"+entry.getValue());
}

//第四种:JDK8.0新增的遍历方法,最简单灵活,内部是利用map.entrySet()方法遍历
map.forEach((key,value)->{
    System.out.println(key+"====>"+value);
});

//第五种:只能遍历值,不能遍历键
for (Object value : map.values()) {
    System.out.println(value);
}

6.为什么大部分 hashcode 方法使用 31?

通常我们会选一个质数作为hashCode的乘子,但是质数过小(2),算出的哈希值不会很大,分布在一个较小数值区间,较大概率造成哈希冲突。
质数也不宜过大,因为hashcode是int类型,算出的哈希值过大会造成乘法溢出,造成数据的丢失。所以31,37,41,43都是不错的选择。
31有个很好的性能,31*i=(i<<5)-i,虚拟机可以自动完成优化,用移位和减法代理了乘法,性能会提高。
在这里插入图片描述

7.Iterator 怎么使用?有什么特点?

通过Collection的iterator()方法获取迭代器的一个实例。
hasNext()方法判断Itertor内是否还有下个元素
next()方法返回Itertor内的下一个元素。同时指针也会向后移动一位。
remove()方法是移除指针的上一个元素。(上次next()方法获得的元素)

特点:
Iterator遍历集合元素的过程中不允许其它线程对集合元素进行修改,否则会抛出ConcurrentModificationException的异常。
Iterator必须依附于一个集合对象而存在,自身并不具有装载数据对象的功能

8.集合和数组的区别

数组是固定长度的,集合是可变长度的。
数组既能存储基本数据类型,又能存储引用数据类型。集合只能存储引用数据类型。
数组存储的元素必须是同一个数据类型,集合中存储的对象可以是不同的引用数据类型。

9.多线程场景下如何使用 ArrayList?

1.可以使用Collections的synchronizedList方法将其转成线程安全的容器。
2.可以使用CopyOnWriteArrayList

10.List怎么去除重复元素?

使用LinkedHashSet删除List中重复的元素

public static void main(String[] args) {
	List<String> list = Arrays.asList("a", "b", "c", "d", "a");
    Set<String> set = new LinkedHashSet<>(list);
    list=new ArrayList<>(set);
    System.out.println(set);
}

使用contain方法剔除重复元素

public static void main(String[] args) {
    List<String> list = Arrays.asList("a", "b", "c", "d", "a");
    List<String> newList  = new ArrayList();
    for (String s : list) {
        if(!newList.contains(s)){
            newList.add(s);
        }
    }
    list.clear();
    list.addAll(newList);
}

用双重for循环
这种方式不用新建变量所以满足空间复杂度。就是拿第一个元素跟它后面的元素挨个比较,如果相等就把后边元素剔除,然后循环使用第二个元素跟它后面的元素比较。

public static void main(String[] args) {
    List list = new ArrayList<>();
    list.add("a");
    list.add("g");
    list.add("b");
    list.add("a");
    list.add("b");
    for (int i = 0; i < list.size() ; i++) {
        for (int j = i; j < list.size() ; j++) {
            if(list.get(i)==list.get(j) && i!=j){
                list.remove(j);
            }
        }
    }
    System.out.println(list);
}

11.HashMap中链表转红黑树的条件

1.首先数组长度要大于等于64,当链表长度为8,插入第9个元素后会转换为红黑树
2.当节点个数小于等于6时又会转回链表

12.为什么HashMap中String、Integer这样的包装类适合作为key?

1.String和Integer底层的value属性都是final修饰,具有不可变性,保证了key的不可更改性,不会出现放入和获取时哈希值不同的情况
2.String和Integer内部重写了equals()和hashcode()方法,遵守了HashMap内部的规范,不容易出现hash值计算错误的情况。

13.comparable 和 comparator的区别?

comparable接口实际上是出自java.lang包,它有一个 compareTo(Object obj)方法用来排序(内部比较器)

public class Person implements Comparable<Person>{
    private String name;
    private Long id;
    
    @Override
    public int compareTo(Person person) {
        Long value=this.getId()-person.getId();
        //返回值=0表示this=person
        //返回值>=1表示this>person
        //返回值<=-1表示this<person
        return value==0?0:value>0?1:-1;
    }
}

comparator接口实际上是出自 java.util 包,它有一个compare(Object obj1, Object obj2)方法用来排序(外部比较器)

public static void main(String[] args) {
        Set<Person> treeSet = new TreeSet<Person>(new Comparator<Person>() {
        	//这里o1表示新元素,o2表示对比的元素。容易混
        	//返回值为0表示o1=o2;
        	//返回值为1表示o1>o2;
        	//返回值为-1表示o1<02
            @Override
            public int compare(Person o1, Person o2) {
                long value=o1.getId()-o2.getId();
                return value==0?0:value>0?1:-1;
            }
        });
        Person person1 = new Person("zccheng",1L);
        Person person2 = new Person("lq",2L);
        treeSet.add(person1);
        treeSet.add(person2);
        System.out.println(treeSet);
}

这两个比较器如何排列,跟哪个是比较的元素,哪个是被比较元素没有关系。只看你的逻辑条件>0,返回的是1还是-1。如果是1就正序排,如果是-1就倒序排。

14.TreeMap 和 TreeSet 在排序时如何比较元素?Collections 工具类中的 sort()方法如何比较元素?

TreeSet 要求存放的对象所属的类必须实现 Comparable 接口,该接口提供了比较元素的 compareTo()方法,当插入元素时会回调该方法比较元素的大小。

TreeMap 要求存放的键值对映射的键必须实现Comparable 接口从而根据键对元素进行排序。

Collections 工具类的 sort 方法有两种重载的形式,第一种要求传入的待排序容器中存放的对象比较实现 Comparable 接口以实现元素的比较;第二种不强制性的要求容器中的元素必须可比较,但是要求传入第二个参数,参数是Comparator 接口的子类型(需要重写 compare 方法实现元素的比较),相当于一个临时定义的排序规则,其实就是通过接口注入比较元素大小的算法,也是对回调模式的应用(Java 中对函数式编程的支持)。

Collections.sort(list, new Comparator<Person>() {
    @Override
    public int compare(Person o1, Person o2) {
        return o1.getAge()-o2.getAge();
    }
});

15.ConcurrentHashMap原理,jdk7和jdk8版本的区别

jdk7:
数据结构:ReentrantLock+Segment+hashEntity,一个segment包含一个hashEntity数组,每个hashEntity又是一个链表结构
元素查询:2次hash,第一次定位到Segment,第二次定位到元素所在链表的头部
锁:Segment分段锁继承了ReentrantLock,锁定操作的Segment,其它Segment不受影响,并发度为Segment的个数。
get方法读操作无需加锁,因为Node的val用volitale修饰保证可见性。
在这里插入图片描述

jdk8:
数据结构:Synchronized+CAS+Node+红黑树,Node的val和next都用volitale修饰,保证可见性
查找,赋值和替换操作都使用CAS
锁:锁链表的head节点,不影响其它元素的读写,锁粒度更细,效率更高,扩容时,阻塞所有的读写操作,并发扩容
读操作无锁:Node的val和next使用volitale修饰,读写线程对该变量互相可见
数组也用volitale修饰,保证扩容时被读线程感知
在这里插入图片描述
jdk1.7使用的是ReentrantLock+Segment+HashEntry jdk1.8使用的是Synchronized+CAS+Node+红黑树
jdk1.7Segment分段锁继承了ReentrantLock,锁定的是操作的Segment,其它Segment的读写不受影响,并发度为Segment的个数,JDK1.8锁的是链表的头节点,其它元素的读写不受影响,锁的粒度更细,效率更高效。
jdk1.7和jdk1.8的读操作都是不加锁的,因为Node的val属性都用volatile修饰,保证线程对该变量的可见性。

16.HashMap的put方法的具体流程?

(1)判断数组是否为空,如果为空就调用resize()方法对数组长度进行初始化
(2)根据key计算出存储的下标,判断数组中这个下标位置是否已经有元素,如果没有就直接插入
(3)如果有就判断插入的key跟这个元素的key是否相同,如果相同就直接覆盖value
(4)如果不相同,就判断这个元素所在的节点是否已经树化,如果已经树化直接在红黑树中插入键值对
(5)如果没有树化还是链表结构,就先遍历链表看看是否有元素的key跟要插入的元素的key相同,如果存在就直接覆盖value,如果不存在就在链表中插入节点,插入之后还要判断没添加前链表长度是否大于等于8(也就是添加后大于等于9),如果大于等于8了添加完节点后要转换为红黑树结构
(6)最后插入成功后,要判断实际的键值对数量size是否超过最大容量threshold,如果超过要调用resize()方法进行扩容
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

17.HashMap的扩容操作是怎么实现的?

计算新的数组长度和阈值
	 对于散列表已经存在的且数组长度大于等于16的newThr=2oldThr  newCap=2oldCap;
     对于散列表已经存在的且数组长度小于16的newCap=2oldCap newThr=newCap*0.75(这里解释下为什么要写成newThr=2oldThr, 因为小于16肯定是有参创建的,有参创建的会把大于这个参数的最小的2的幂次方传给threadsold,之后threadsold=(int)newCap*0.75,由于强制类型转换损失精度,因为假如2*0.75最后只会变为1,所以我们用newCap*0.75更精确)
     对于散列表不存在,无参创建的newThr=12  newCap=16
     对于散列表不存在,有参创建的,newCap=oldThr newThr=newCap*0.75

数据迁移
	1.先判断散列表是否为空,如果不为空,遍历旧数组,如果数组元素不为空,就赋值给一个节点Node<K,V> e,并清空自己方便GC
	2.判断e.next==null,如果等于null,说明该节点还没有链化,直接用e.hash&newCap-1计算出存储下标,给新数组的这个下标处赋值。
	3.如果e.next!=null说明已经链化,我们再判断该节点是不是树节点,如果是树节点就调用treeNode的split方法对当前节点作为根节点的红黑树进行修剪
	4.如果不是树节点那就是链节点。判断链表中每个元素的e.hash&oldCap==0,如果等于0就是低位链,否则就是高位链。如果是低位链就放到原来的索引位置,高位链就放到原来的索引+原数组长度的位置。
	为什么要用e.hash&oldCap来判断高位链还是低位链呢?
	hash值跟16或者32的取模运算是否结果相同====hash值除以16的商是否是偶数(如果偶数取模运算就相等,奇数就等于结果+16)====hash值&16是否等于0

在jdk1.8中,当hashmap的实际存在键值对数量size大于阈值或者初始化时,就调用resize()方法
每次扩展的时候都是扩展2倍
扩展后Node对象的位置要么在原位置,要么移动到原偏移量+原数组长度的位置
在1.7中,扩容之后需要重新去计算其Hash值,根据Hash值对其进行分发,但在1.8版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)是否为0,重新进行hash分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上

resize流程

resize流程

1.newCap和newThr赋值
oldCap=table.length oldThr=threshold  newCap  newThr
(1)先判断oldCap是否大于0;大于0表示散列表存在,进行正常的扩容,  newcap=2oldCap之后再判断oldCap是大于等于初始化长度16的,
newThr=2oldThr
(2)如果oldCap=0,说明散列表是空的
这里又分为两种
(2.1)oldThr>0 有参构造创建的开始会根据传入的容量,把threashold赋值为最小的2的幂次方   newCap=oldThr  newThr未定义
(2.2)oldThr=0 无参构造创建的,支持newCap=16  newThr=12
(2.3)再判断newThr==0,对应(2.1)和(1)不成立情况,实际上就是定义有参构造的newThr=newCap*0.75
以上newCap和newThr赋值完毕。
总结:对于散列表已经存在的且数组长度大于等于16的newThr=2oldThr  newCap=2oldCap;
     对于散列表已经存在的且数组长度小于16的newCap=2oldCap newThr=newCap*0.75(这里解释下为什么要写成newThr=2oldThr, 因为小于16肯定是有参创建的,有参创建的会把大于这个参数的最小的2的幂次方传给threadsold,而oldThr=threadsold,所以要写成newCap*0.75)
     对于散列表不存在,无参创建的newThr=12  newCap=16
     对于散列表不存在,有参创建的,newCap=oldThr newThr=newCap*0.75

2.数据迁移
table=newTab=Node<K,V>[]   Node<K,V> e
1>如果oldTab!=null(散列表不为空),遍历oldTab 如果头结点不为空,赋值给e,自己清空方便回收内存。
        2>判断e.next==null,如果等于null说明没有链化 newTable[e.hash&(newCap-1)]=e
        3>如果!=null,已经链化了
                3.1>判断是不是树节点
                3.2>不是树节点,那就是链节点
                        判断e.hash&oldCap==0就是低位链  否则是高位链。以数组原来长度16为例 二进制是1 0000,如果e.hash是1                                                                                                                         xxxx就!=0,是0 xxxx就是=0
                        低位链首尾节点lohead lotail   高位链首尾节点 hihead hitail
                        最后newTab[j]=lohead   newTab[j+oldCap]=hilead
总结:
1.先判断散列表是否为空,如果不为空,遍历旧数组,如果数组元素不为空,就赋值给一个节点Node<K,V> e,并清空自己方便GC
2.判断e.next==null,如果等于null,说明该节点还没有链化,直接用e.hash&newCap-1计算出存储下标,给新数组的这个下标处赋值。
3.如果e.next!=null说明已经链化,我们再判断该节点是不是树节点,如果是树节点就调用treeNode的split方法对当前节点作为根节点的红黑树进行修剪
4.如果不是树节点那就是链节点。判断e.hash&oldCap==0,如果等于0就是低位链,否则就是高位链。如果是低位链就放到原来的索引位置,高位链就放到原来的索引+原数组长度的位置。
为什么要用e.hash&oldCap来判断高位链还是低位链呢?
hash值跟16或者32的取模运算是否结果相同====hash值除以16的商是否是偶数(如果偶数取模运算就相等,奇数就等于结果+16)====hash值&16是否等于0
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
    	//超过最大值就不再扩充了,就只好随你碰撞去吧
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //没超过最大值就扩充为原来的2倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //计算新的resize上限
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
    	//把每个bucket都移动到新的buckets中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { //链表优化重hash的代码块
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        //原索引
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        //原索引+oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    //原索引放到bucket里
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    //原索引+oldCap放到bucket里
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值