Java后端面试高频八股文

一、Java基础

1. 面向对象

①Java面向对象的特点

Ⅰ. 封装:将数据和方法封装在对象内部,隐藏对象的内部实现细节,只暴露必要的接口供外部访问;——提高代码的安全性和可靠性

Ⅱ. 继承:允许子类继承父类的属性和方法。子类可以重用父类的代码,并可以通过扩展和重写来增加新的功能或修改现有功能;——提高了代码的复用性和可维护性

Ⅲ. 多态:允许不同对象对同一消息作出不同的响应。在Java中,多态性通过方法重载和方法重写来实现——提高了代码的灵活性和扩展性

 

 

②重载和重写有什么区别

Ⅰ. 重载是同一个类中的同名方法,但参数列表不同(对返回值、抛出的异常、权限修饰符没有要求);

Ⅱ. 重写是子类重写父类的方法,同名同参数列表,且子类返回值类型、抛出的异常必须比父类更小或相等,访问权限必须比父类更大或相等;(如果方法的返回类型是 void 和基本数据类型,则返回值重写时不可修改)

Ⅲ. 重载发生在编译期,而重写发生在运行期;

 

 

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

面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题;

面向对象会先抽象出对象,然后用对象执行方法的方式解决问题;

面向对象开发的程序一般更易维护、易复用、易扩展;

 

 

 

 

2.基本类型与包装类

①Java 中的几种基本数据类型了解么?

Java 中有 8 种基本数据类型,分别为:

  • 6 种数字类型:
    • 4 种整数型:byteshortintlong
    • 2 种浮点型:floatdouble
  • 1 种字符类型:char
  • 1 种布尔型:boolean

 

为什么像 byteshortintlong能表示的最大正数都减 1 了?

因为在计算机中以二进制补码来表示整型,其中首位是用来作为符号位的,0表示正数,1表示负数,

当符号位为0,数值位也全为0时表示0,而当符号位为1时,数值位全为0时表示-2^n,所以0开头的正数有一个用来表示0了,所以比负数少了一个;

 

 

②基本数据类型和包装类型的区别

Ⅰ. 用途不同:包装类型可以用于泛型,而基本数据类型不可以;

Ⅱ. 占用空间不同:基本数据类型占用空间比包装类型小;

Ⅲ. 比较方式不同:基本数据类型用==比较的是值,而包装类型比较的是对象的内存地址,要想比较包装类型的值应当用equals()方法;

Ⅳ. 默认值不同:基本数据类型中数值的默认值是0,boolean连续的默认值是false,而包装类型属于引用类型,默认值是null;

 

 

③包装类型的缓存机制/常量池技术了解吗?

包装类型的常量池技术指的是Byte、Short、Integer、Long这四种整型的包装类默认创建了[-128, 127]的缓存数据,Character创建了数值在[0, 127]的缓存数据,Boolean直接返回TURE或FALSE,

对于在上述范围内的包装类型,会直接从缓存中返回对应的对象,无需创建新的对象,只有在超出范围或是手动new一个对象时,才会创建新的对象;

 

 

④自动装箱与拆箱了解吗?原理是什么?

自动拆装箱是一种语法糖,

其中自动装箱指的是将基本数据类型转换成对应的包装类型,实际上是调用了对应包装类型的静态方法Xxx.valueOf(),例如Integer a = Integer.valueOf(1)

自动拆箱指的是将包装类型转换成对应的基本数据类型,实际上是调用了包装类型的xxxValue()方法,例如Integer a = 1;  int b = a.intValue();

 

 

⑤了解自动拆箱引发的NPE(Null Pointer Error 空指针异常)问题吗?

因为自动拆箱实际上就是调用了包装类型的xxxValue()比如intValue()方法,所以当调用者为null时,就会引发空指针异常,

比如在三目运算符中,当一个表达式为基本数据类型,另一个为包装类型时,就会触发自动拆箱,此时若包装类型为空,就会触发空指针异常,

解决办法是尽量让三目运算符中的两个表达式都是包装类型、或是都是基本数据类型,这样就不会触发自动拆箱了;

 

 

 

 

 

3.相等的判断:== 和 equals() 的区别

①使用==判断是否相等:对于基本数据类型,判断的是数值,对于引用类型,判断的是对象的内存地址;

 

②使用equals()方法判断引用类型是否相等:

对于重写了equals()方法的类,会使用重写的equals()方法进行比较,通常都是对类中的各个属性的值进行比较,

对于没有重写equals()方法的类,则会使用父类Object的equals()方法进行比较,实际上就是使用==比较对象的内存地址;

public boolean equals(Object obj) {
     return (this == obj);
}

 

③因为在使用对象调用equals()方法时若调用者为null,可能会出现空指针异常,所以更推荐使用JDK7中引入的工具类Objects的equals()方法,

其逻辑是若两个对象内存地址相等,或第一个对象不为空,且使用第一个对象调用其equals()方法与第二个对象比较的结果为true,则返回true,否则返回false;

    public static boolean equals(Object a, Object b) {
        return (a == b) || (a != null && a.equals(b));
    }

 

 

 

 

 

4.深拷贝和浅拷贝区别了解吗?引用拷贝呢?

首先说引用拷贝,顾名思义就是不创建新的对象,只新建一个引用来指向同一个对象;

 

然后说浅拷贝,浅拷贝会创建一个新的对象,对于其中基本数据类型的成员变量会复制值,而对于引用类型的成员变量,则只会复制其引用,

因此新对象中引用类型的成员变量和原始对象中的成员变量指向同一对象,如果原始对象中该成员变量发生改变,新对象也会随之改变;

浅拷贝可以通过重写clone()方法实现:

自定义引用类型Address
public class Address{
    private String name;

    public Address(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
重写clone()方法实现了浅拷贝的Person类
public class Person implements Cloneable {
    private int id;
    private Address address;

    //浅拷贝
    @Override
    public Person clone() throws CloneNotSupportedException {
        return (Person) super.clone();
    }

    public Person(int id, Address address) {
        this.id = id;
        this.address = address;
    }

    public int getId() {
        return id;
    }

    public Address getAddress() {
        return address;
    }

    public void setAddress(Address address) {
        this.address = address;
    }
}
测试浅拷贝的特性
    public static void main(String[] args) throws CloneNotSupportedException {

        Person person = new Person(1, new Address("武汉"));
        Person personCopy = person.clone();
        // true
        System.out.println(person.getId() == personCopy.getId());
        // true,说明原始对象person和新对象personCopy的Address成员变量指向同一个对象
        System.out.println(person.getAddress() == personCopy.getAddress());

        person.getAddress().setName("惠州");
        // 惠州,说明当原始对象中引用类型的成员变量发生改变时,新对象也会随之改变
        System.out.println(personCopy.getAddress().getName());
    }

 

再说深拷贝:深拷贝和浅拷贝的区别在于对于引用类型的成员变量也会进行拷贝并创建新的对象,而非仅仅复制引用;

因此新对象中引用类型的成员变量和原始对象中的成员变量并非同一对象,原始对象中该成员变量发生改变,不会导致新对象的改变;

深拷贝也可以通过重写clone()方法实现:

自定义引用类型Address
 public class Address implements Cloneable {
    private String name;

    @Override
    protected Address clone() throws CloneNotSupportedException {
        return (Address)super.clone();
    }

    public Address(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
重写clone()方法实现了深拷贝的Person类
public class Person implements Cloneable {
    private int id;
    private Address address;

    //深拷贝
    @Override
    public Person clone() throws CloneNotSupportedException {
        Person person = (Person) super.clone();
        person.setAddress(person.getAddress().clone());
        return person;
    }

    public Person(int id, Address address) {
        this.id = id;
        this.address = address;
    }

    public int getId() {
        return id;
    }

    public Address getAddress() {
        return address;
    }

    public void setAddress(Address address) {
        this.address = address;
    }
}
测试代码
     public static void main(String[] args) throws CloneNotSupportedException {

        Person person = new Person(1, new Address("武汉"));
        Person personCopy = person.clone();
        // true
        System.out.println(person.getId() == personCopy.getId());
        // false,说明原始对象person和新对象personCopy的Address成员变量不是同一个对象
        System.out.println(person.getAddress() == personCopy.getAddress());

        person.getAddress().setName("惠州");
        // 武汉,说明当原始对象中引用类型的成员变量发生改变时,新对象不会随之改变
        System.out.println(personCopy.getAddress().getName());
    }

 

 

 

 

 

5.String、StringBuffer、StringBuilder

①String、StringBuffer、StringBuilder 的区别?

Ⅰ. 可变性:String是不可变的,StringBuilder、StringBuffer都是可变的;

Ⅱ. 线程安全性:String具有不可变性,所以线程安全,StringBuffer加了同步锁,也是线程安全的,StringBuilder没有加同步锁,所以是线程不安全的;

Ⅲ. 性能:StringBuilder > StringBuffer > String;

一般来说,如果操作少量数据,可以使用String,如果操作大量数据,单线程情况下使用StringBuilder,多线程情况下使用StringBuffer;

 

 

②String 为什么是不可变的?

首先,String类本身由final修饰,不可继承,无法通过创建子类来破坏其不可变性;

其次,String内部用于保存字符串的数组value也被final修饰,且String内部没有提供任何可以改变value值的方法;

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    private final char value[];
	//...
}

 

 Java 9 为何要将 String 的底层实现由 char[] 改成了 byte[] ?

新版的 String 通常采用 Latin-1编码方案,在此编码方案下,byte 占一个字节(8 位),char 占用 2 个字节(16),byte 相较 char 节省一半的内存空间;

 

 

 

③用“+” 实现字符串拼接的原理?

使用+进行字符串拼接实际上就是创建一个StringBuilder,然后调用其append()方法实现拼接,最后再调用toString()方法转换为字符串;

 

 

 

④什么是字符串常量池?有什么作用?

字符串常量池StringTable是JVM在堆中开辟的一块内存区域,用于存储对字符串常量的引用,可以提升性能和减少内存消耗;

——只要是以字符串常量形式出现过,或是手动调用了intern()方法的的字符串,都会自动在StringTable中添加对其的引用;

 

 

 

⑤字符串的intern()方法的作用?

对一个字符串对象的引用使用intern()方法,其作用是:

若StringTable中存在内容和该对象相同的字符串的引用,则直接返回StringTable中的引用;

若不存在,则将该引用放入StringTable中并返回;

@Test
    public void test2(){
        String s1 = "ab";
        //对"ab"的引用已存在于字符串常量池中
        String s2 = s1.intern();
        //true——s1和s2都是字符串常量池中的引用
        System.out.println(s1 == s2);

        String s3 = new String("cd");
        //对"ab"的引用已存在于字符串常量池中
        String s4 = s3.intern();
        //fasle————s3是对堆中新建的内容为"ef"的字符串的引用,s4则是字符串常量池中的引用
        System.out.println(s3 == s4);

        String s5 = new String("e") + new String("f");
        //由字符串变量的拼接可知,对拼接结果"ef"的引用不存在于字符串常量池中,故此时直接将s5放入字符串常量池中(不新建对象),同时将s5作为返回值
        String s6 = s5.intern();
        //true
        System.out.println(s5 == s6);
        //true
        System.out.println(s5 == "ef");
    }

 

 

 

 

 

 

6.SPI——暂时先不作了解,因为其应用日志框架slf4j、DriverManager都不怎么熟悉

参考文章:https://zhuanlan.zhihu.com/p/655096182

 

 

 

 

7. 设计模式——代理模式

参考文章:Java 代理模式的基本概念、使用场景、应用示例和实现方法

通俗易懂 快速理解 JDK动态代理 和 cglib动态代理

①什么是代理模式?

就是通过添加代理对象来控制对目标对象的访问,进而实现一些额外的功能,例如访问计数、延迟加载、权限控制等;

代理模式的实现方式主要有静态代理和动态代理;

 

 

②静态代理和动态代理的区别

静态代理在编译期间就确定了代理对象,需要为每一个代理对象创建一个代理类,当代理对象较多时,会导致代码冗余和维护成本增加;

动态代理在运行期间根据需要动态生成代理对象,不需要手动创建代理类,可以减少代码冗余和维护成本;

(Spring AOP功能就用到了动态代理,例如创建HandlerInterceptor的实现类来对访问URL进行拦截,实现权限控制)

 

 

③动态代理中JDK 动态代理和 CGLIB 动态代理对比

Ⅰ.实现原理:

JDK动态代理是jdk原生的实现方式,基于反射机制实现,在运行时通过实现目标接口的方式来代理目标类,因此要求目标类必须实现一个或多个接口,

而CGLIB动态代理是第三方CGLIB库提供的实现方式,在运行时通过继承目标类来代理目标类,因此要求目标类不能是被final修饰的类,或是有被final修饰的方法;

Ⅱ.性能表现:

JDK动态代理因为要实现目标类的接口,所以性能相对较低;

 

 

④代理模式代码实现

public interface Calculator {

    int add(int a, int b);

}
public class RealCalculator implements Calculator{
    @Override
    public int add(int a, int b) {
        System.out.println("a + b = " + (a + b));
        return a + b;
    }
}

Ⅰ. 静态代理

public class CalculatorProxy implements Calculator{

    private final RealCalculator realCalculator;

    public CalculatorProxy(RealCalculator realCalculator) {
        this.realCalculator = realCalculator;
    }

    @Override
    public int add(int a, int b) {
        System.out.println("执行加法操作:");
        return realCalculator.add(a, b);
    }
}
@Test
public void staticProxyTest() {
    CalculatorProxy proxy = new CalculatorProxy(new RealCalculator());
    System.out.println(proxy.add(1, 2));
}

 

Ⅱ. jdk动态代理:

实现InvocationHandler接口,在方法执行前后添加自定义操作;

public class CalculatorInvocationHandler implements InvocationHandler {

    private final Object object;

    public CalculatorInvocationHandler(Object object) {
        this.object = object;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("方法执行前:");
        Object result = method.invoke(object, args);
        System.out.println("方法执行后:");
        return result;
    }
}

使用Proxy的静态方法newProxyInstance()创建代理对象;

@Test
public void jdkProxyTest() {
    RealCalculator realCalculator = new RealCalculator();
    CalculatorInvocationHandler handler = new CalculatorInvocationHandler(realCalculator);
    Calculator proxyInstance = (Calculator) Proxy.newProxyInstance(
            realCalculator.getClass().getClassLoader(),
            realCalculator.getClass().getInterfaces(),
            handler);
    proxyInstance.add(1, 2);
}

 

Ⅲ. cglib动态代理

实现MethodInterceptor接口,在方法执行前后添加自定义操作;

public class CalculatorInterceptor implements MethodInterceptor {
    @Override
    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        System.out.println("执行方法前:");
        Object result = methodProxy.invokeSuper(o, objects);
        System.out.println("执行方法后:");
        return null;
    }
}

创建Enhancer类对象,设置好属性之后,调用其create()方法创建代理对象;

@Test
public void cglibProxyTest() {
    Enhancer enhancer = new Enhancer();
    enhancer.setCallback(new CalculatorInterceptor());
    enhancer.setSuperclass(RealCalculator.class);
    RealCalculator calculator = (RealCalculator) enhancer.create();
    calculator.add(1, 2);
}

 

 

 

 

8.设计模式——单例模式

①什么是单例模式?

就是保证一个类仅有一个实例,并提供一个全局访问点,

例如线程池、数据库连接池都是单例模式;

其优点在于单个实例可以减少内存开销、避免对资源的多重占用,设置全局访问点,严格控制访问;

 

 

②单例模式需要满足的条件

Ⅰ. 私有构造器;

Ⅱ. 线程安全(保证多线程环境下的单例);

Ⅲ. 延迟加载(不要在编译阶段就初始化,会浪费内存空间);

Ⅳ. 防止反射攻击(要求通过反射也无法创建出多个实例);

Ⅴ. 序列化和反序列化也不能破坏单例性质;

 

 

③推荐使用的两种单例模式实现方式

Ⅰ. 改进后可以防范反射攻击、序列化和反序列化的的内部类式单例

public class StaticInnerClassSingleton implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * ObjectInputStream在调用readObject()方法实现反序列化时,会查看实例类内部是否有readResolve方法,
     * 如果存在readResolve方法,则直接通过反射获取此方法的返回值,并作为readResolve方法的返回值;
     * 如果不存在,则会重新创建一个实例对象作为返回值;
     * */
    private Object readResolve(){
        return SingletonHolder.INSTANCE;
    }

    /**
     * 外部类初次加载,会初始化静态变量、静态代码块、静态方法,但不会加载内部类和静态内部类,
     * 只有调用静态内部类的方法,静态域,或者构造方法的时候才会加载静态内部类,故能实现延迟加载;
     * */
    private static class SingletonHolder{
        private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
    }

    private StaticInnerClassSingleton(){
        if(SingletonHolder.INSTANCE != null){
            throw new RuntimeException("不允许非法创建多个实例");
        }
    }

    public static StaticInnerClassSingleton getInstance(){
        return SingletonHolder.INSTANCE;
    }

}

静态内部类的静态常量,保证了线程安全和延迟加载;

通过给私有构造器添加一个判断条件:若静态内部类的INSTANCE不为空,直接抛出异常,避免利用反射通过私有构造器创建非法实例;

通过在单例类中添加一个readResolve()方法来解决,保证反序列化之后的对象和序列化前的对象是同一个对象;

 

Ⅱ. 枚举类式单例

public enum EnumSingleton {

    INSTANCE;

    public static EnumSingleton getInstance(){
        return INSTANCE;
    }

}

枚举类型天生具有线程安全性,

序列化和反序列化不可破坏单例(因为枚举类型通过类名和常量名确定枚举常量,而这里常量名及其对应的枚举常量是唯一的,故不会创建新的对象),

反射不可破坏单例(无法通过反射来创建枚举类型,会直接抛出异常IllegalArgumentException:"Cannot reflectively create enum objects"),

且相比改进的内部类式单例,代码优雅;

缺点在于不支持延迟加载,加载时就会创建对象,不过影响不大,是非常推荐的一种单例式写法;

 

总结:

当需要单例时,只要不是特别重的对象,都建议使用枚举类式实现(因为加载时就会创建对象,对象太重会浪费大量内存空间),其他情况下使用改进的内部类式单例;

 

 

 

 

9.设计模式——工厂模式

参考文章:Java工厂模式(随笔)

①什么是工厂模式?

将对象的创建和使用分离,客户端只需知道产品的接口,无需关心具体实现;

例如MyBatis 中的SqlSessionFactory,专用于创建SqlSession;

 

 

②常见的工厂模式有哪些?

Ⅰ. 简单工厂模式:提供了一个工厂类,用于根据传入的参数来决定创建哪种类的实例;

缺点:违反了开闭原则,若要扩展工厂类的功能,只能修改工厂类的代码;

 

Ⅱ. 工厂模式:提供了一个工厂接口,用户可以根据要创建的类来实现相应的工厂类;

优点:符合开闭原则,若要扩展工厂类的功能,只需添加一个工厂类即可,不需要修改代码;

缺点:一个工厂类只能创建一种类的实例,当产品类型较多时会需要过多的工厂类,导致代码复杂度增加;

 

Ⅲ. 抽象模式:工厂接口中包括多个方法,一个工厂类可以生成同类型的多个产品;

缺点:当产品结构发生改变时,仍需要修改工厂类,可能违反开闭原则;

 

Ⅳ. 超级工厂:利用反射机制完美的解决了简单工厂模式存在的开闭原则的问题,且一个工厂类就能生成所有的产品;

public class SuperFactory {

    // 输入全类名
    public static <T> T create(String className){
        Class<?> clazz = null;
        try {
            clazz = Class.forName(className);
            // 只要有空参构造器就能创建实例
            T instance = (T) clazz.getDeclaredConstructor().newInstance();
            return instance;
        } catch (Exception e) {
            throw new RuntimeException("Create instance fail", e);
        }
    }

}

缺点:暂无;

 

Ⅴ. 容器工厂模式:暂不作了解;

 

 

 

 

10. 设计模式——适配器模式

参考文章:Java 设计模式——适配器模式

①什么是适配器模式?

通过添加适配器类,将一个类的接口转换成客户端所期望的另一个接口,来解决接口不匹配的问题;

根据是通过组合或是继承两种方式来创建适配器类,可以分为类适配器、对象适配器,其中前者的耦合度更高;

适配器模式的优点:可以在不修改被适配类原有代码的前提下,使其能够兼容不同的接口,符合开闭原则;

(适配器模式是用来后续有新需求时打补丁的,初始设计时当然是尽量让已有接口都互相兼容)

 

②代理模式和适配器模式的区别?

代理模式是通过添加代理对象,来控制对目标对象的访问,进而实现一些额外的功能,如访问计数、权限控制等,

适配器模式是通过添加适配器类,将一个类的接口转换成客户端所期望的另一个接口,来解决接口不匹配的问题,通常在对旧系统进行升级改造时用到;

 

 

 

 

11. 设计模式——观察者模式

参考文章:Java设计模式之一:观察者模式

①什么是观察者模式?

定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并自动更新;

优点:观察者和被观察者解耦,二者可以独立进行扩展和修改,订阅操作由观察者执行,被观察者不关心谁订阅了;

使用场景:订阅通知场景;

 

 

 

 

13. 设计模式——策略模式

参考文章:Java特性之设计模式【策略模式】

①什么是策略模式?

指定义一系列功能类型、对外接口都相同的算法,并将每一个算法单独封装起来作为一种策略,通过一个上下文类来切换不同的策略,使得相同的接口在不同策略下可以实现不同的功能;

优点:符合开闭原则,扩展性好;

缺点:策略类会比较多,且所有策略类都需要对外暴露;

上下文类
 public class Context {

    private Strategy strategy;

    public Strategy getStrategy() {
        return strategy;
    }

    public void setStrategy(Strategy strategy) {
        this.strategy = strategy;
    }

    public Context(Strategy strategy) {
        this.strategy = strategy;
    }

    public int executeStrategy(int num1, int num2){
        return strategy.doOperation(num1, num2);
    }
}

 

 

 

 

 

 


 

二、Java集合

1. List

①ArrayList

Ⅰ. 基本介绍:

ArrayList底层就是一个Object[]数组,通过在合适的时机对数组进行扩容,实现动态容量的数组,线程不安全;

 

Ⅱ. ArrayList扩容机制:

以空参构造器创建ArrayList时,实际分配的是一个空的默认数组,在放入第一个元素时,会将数组容量扩充为10,

之后当元素数量超过数组当前容量时,会先将数组容量扩充至1.5倍(old + old >> 2),——add()方法添加元素这一步就够了

如果仍不够,则直接将数组容量扩充至可容纳全部元素的容量,——addAll()方法添加元素可能就需要这一步

也可以使用ensureCapacity()方法手动将容量扩充至需要的大小;

    private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // minCapacity is usually close to size, so this is a win:
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

 

Ⅲ. ArrayList中get()、set()、add()、remove()操作的时间复杂度:

支持随机访问,故get()操作的时间复杂度为O(1),set()也是O(1);

如果是尾部插入/删除,则无需进行数组拷贝操作,时间复杂度为O(1);

而如果是头部或是中间位置插入/删除,则必须进行数组拷贝操作,时间复杂度为O(n);

 

Ⅳ. ArrayList的最大容量

ArrayList的默认最大容量是Integer.MAX_VALUE - 8,

因为ArrayList的容量size是int类型的,故最大可表示为Integer.MAX_VALUE,

又因为在有些虚拟机中例如HotSpot虚拟机还需要32字节来存储对象头信息,所以需要预留8个int的空间来存放这些对象头信息,

不过实际在扩容时是可以将ArrayList的最大容量扩充到Integer.MAX_VALUE的,只是有可能会导致OutOfMemoryError;

 

 

②LinkedList

Ⅰ. 基本介绍:

底层通过双向链表来实现,不支持随机访问,实际很少使用,几乎都能用ArrayList替代且性能更好,线程不安全;

 

Ⅱ. LinkedList中add()、remove()操作的时间复杂度:

因为是双向链表,所以除了在头、尾进行插入、删除操作的时间复杂度是O(1);

其他操作如在中间插入、删除,或是查找、修改查找的时间复杂度都是O(n);

 

 

③ArrayList和LinkedList的区别——其实就是数组和双向链表的区别

Ⅰ. 线程安全性:都不保证线程安全;

Ⅱ. 底层数据结构:ArrayList底层通过Object[]数组实现,支持随机访问,LinkedList底层通过双向链表实现,不支持随机访问,二者都可以存入null值(可以但不推荐);

Ⅲ. 增删查改时间复杂度:ArrayList在尾部增删、或是任意位置的查改操作都是O(1)时间复杂度,在头部或是指定位置增删操作都是O(n)时间复杂度,因为需要移动后面的元素;

LinkedList除了在头尾部的增删操作是O(1)时间复杂度,在指定位置增删、以及查改操作都是O(n)时间复杂度;

Ⅳ. 内存占用:ArrayList需要在结尾预留一定空间,LinkedList每个节点都要花费额外空间存储前驱、后继结点地址;

 

 

 

 

2. Set

①HashSet、LinkedHashSet、TreeSet的异同

Ⅰ. 都是Set接口的实现类,都能保证数据的不可重复性,都是线程不安全的;

Ⅱ. 从底层数据结构来看,

  HashSet:底层通过HashMap实现,以要存储的数据为key,以一个Object常量作为value;

  LinkedHashSet:是HashSet的子类,底层通过LinkedHashMap实现,在HashSet基础上多了一个可以按照添加顺序读取数据的特性,即FIFO——很少使用;

  TreeSet:底层通过TreeMap红黑树实现,可以对插入元素进行自然排序或是自定义排序,一般应用于需要插入元素有序的场景;

Ⅲ. 性能:HashSet  > LinkedHashSet > TreeSet(后两者还需要维护一定顺序,操作更复杂);

Ⅳ. HashSet、LinkedHashSet可以存储null值,TreeSet不支持(null值无法排序);

 

 

 

 

3. Queue

①Queue和Deque的区别

Ⅰ. Deque接口本身继承了Queue接口,单独实现Queue的集合只有PriorityQueue,实现了Deque接口(当然同时也实现了Queue接口)的有LinkedList和ArrayDeque;

Ⅱ. 一个是单端队列,一个是双端队列,后者提供的额外接口更多:

除此之外,Deque还提供了push()、pop()方法用于模拟栈;

Ⅲ. 应用场景:单端用Queue,双端用Deque;

 

 

②LinkedList和ArrayDeque的区别

Ⅰ. 底层数据结构:LinkedList通过双向链表实现,ArrayDeque通过动态数组和头、尾指针实现;

Ⅱ. LinkedList可以存储Null数据,ArrayDeque不支持;

Ⅲ. ArrayDeque的性能比LinkedList更好;

 

 

③PriorityQueue

Ⅰ. 基本介绍

总是让优先级最高的元素出列的队列,线程不安全,不支持存储null和不可排序的对象;

 

Ⅱ. 底层数据结构

底层使用Object[]数组存储数据,默认初始容量为11,创建PriorityQueue时可以指定初始容量,

扩容机制(了解即可不用记):

        // Double size if small; else grow by 50%
        int newCapacity = oldCapacity + ((oldCapacity < 64) ?
                                         (oldCapacity + 2) :
                                         (oldCapacity >> 1));

通过建立二叉堆(大根堆或小根堆),保证了入队、出队的时间复杂度均为O(logn),

默认使用小根堆(每次弹出最小值),也可以通过接收Comparator参数来自定义优先级规则(o1 - o2递增,小根堆,o2 - o1递减,大根堆),

 

Ⅲ. 手动实现一个小根二叉堆

(这里实现的是只接收Integer的小根堆,省去了泛型、Comparator,省去了扩容机制,使用ArrayList替代数组作为底层结构)

核心方法:

siftUp(int index):对下标为index的元素进行上浮操作,思路就是令该元素不断与其父节点交换,直至元素值不小于父节点的值、或已没有父节点;

注意:进入循环的条件应当为index > 0,即当前元素存在父节点,当index <= 0时,parentIndex已经小于0 ,不存在父节点,此时再进入循环读取父节点会报空指针异常;

    private void siftUp(int index){
        while(index > 0){
            int parentValue = list.get(parentIndex(index));
            if(parentValue > list.get(index)){
                swap(parentIndex(index), index);
                index = parentIndex(index);
            }else{
                break;
            }
        }
    }

 

siftDown(int index):对下标为index的元素进行下沉操作,思路就是先确定该元素是否存在右孩子,若存在比较左、右孩子节点哪个值更小,然后令该元素不断于其较小的孩子节点交换,直至元素值不大于较小的孩子节点值、或是已没有左孩子节点;

注意:进入循环的条件应当为左孩子下标 < list.size(),即当前元素存在左孩子,因为如果不存在左孩子,说明当前节点是叶子节点,不存在孩子节点,此时再进入循环读取孩子节点会报空指针异常;

    private void siftDown(int index){
        while(leftChildIndex(index) < list.size()){
            int childIndex = leftChildIndex(index);
            if(rightChildIndex(index) < list.size() && list.get(rightChildIndex(index)) < list.get(childIndex)){
                childIndex = rightChildIndex(index);
            }
            if(list.get(index) > list.get(childIndex)){
                swap(index, childIndex);
                index = childIndex;
            }else{
                break;
            }
        }
    }

 

heaptify():给定一个未排序的List,在此基础上建立小根堆,思路就是对所有分支节点从后往前依次进行下沉操作;

    public MyMinHeap(Integer[] nums){
        list = new ArrayList<>(Arrays.asList(nums));
        heapify();
    }

    private void heapify(){
        for(int i = parentIndex(list.size() - 1); i >= 0; i--){
            siftDown(i);
        }
    }

 

offer(int num):将元素num加入队列,也是小根堆插入元素,思路就是先插入到List尾部,再对该元素进行上浮操作;

poll():弹出队头元素,也是小根堆弹出堆顶元素,思路就是取List的第一个元素作为返回值,接着将其替换为List尾部的元素,并删除尾部原本的元素,再对其进行下沉操作,最后返回刚才暂存的返回值;

    public void offer(int num){
        list.add(num);
        siftUp(list.size() - 1);
    }

    public Integer poll(){
        if (list == null || list.isEmpty()) {
            return null;
        }
        int num = list.get(0);
        list.set(0, list.get(list.size() - 1));
        list.remove(list.size() - 1);
        siftDown(0);
        return num;
    }

 

 

 

 

 

4. Map

①HashMap

参考文章:简述HashMap的扩容机制

Ⅰ. 基本介绍:

HashMap底层就是一个动态的Node[]数组+链表/红黑树,通过用键值对的key值计算索引,将其存入数组的指定位置,

当发生哈希冲突时,若节点数小于8,就用单链表存储,当节点数达到8,且哈希表容量达到64时,将单链表转换为红黑树;

当哈希表中的元素数量超过threshold时,执行扩容,将容量扩大为当前的2倍;

HashMap是线程不安全的;

 

Ⅱ. 关键属性

size:记录当前哈希表中存储的元素的数量;

loadFactor:负载因子,用于规定哈希表中元素数量占据总容量的多大比例时进行扩容,默认值为0.75;

threshold:等于哈希表容量×loadFactor,当size > threshold时,进行扩容;

初始容量:16(HashMap中没有用于记录当前容量的属性,需要时直接获取数组长度即为当前容量,数组长度.length是数组的一个属性,获取的时间复杂度为O(1))

Node[]数组table:存储键值对数据的数组,Node中存储了hash值、key、value和next指针,Node还有一个子类TreeNode-红黑树节点;

 

Ⅲ. 构造器

空参:仅初始化loadFactor为默认值;

指定初始容量:初始化loadFactor为默认值,初始化容量为与其最接近的 2 的幂次方大小;

指定初始容量、负载因子:初始化loadFactor为指定值,初始化容量为与其最接近的 2 的幂次方大小;

传入另一个Map:略;

 

Ⅳ. HashMap添加元素底层原理

若数组未初始化或长度为0,则先对其进行扩容;

根据键值对的key值的hashCode计算索引值,

  若索引位置为空,则直接将创建包含键值对的节点放入即可,

  若索引位置不为空,则判断是否是红黑树节点,若是则生成树节点并插入红黑树;

  若是链表节点,则生成链表节点并以尾插法插入链表,若插入此节点之后链表长度达到了8,则视情况将此链表转换为红黑树:

    若此时哈希表的容量>=64,则将链表转换为二叉树;

    若此时哈希表的容量 < 64,则选择对哈希表进行扩容;

 

衍生问题:为什么不一开始就用红黑树,而是链表长度达到8时才转为红黑树?

参考文章:面试官:Hashmap链表长度为8时转换成红黑树,你知道为什么是8吗

因为在红黑树的查找效率在节点数量较少时相比单链表也没有明显优势,而红黑树节点占用空间比链表节点更大,如果一开始就用红黑树会导致哈希表过于臃肿,

当单链表长度达到8时,红黑树O(logn)的查找效率明显超过了单链表的O(n),即使多占用一些空间也是值得的,这体现了时间和空间平衡的思想;

不过值得注意的是,在hashCode分布良好的情况下,链表长度达到8的可能性非常小,因此实际应用中链表转红黑树的操作也是很少执行的,除非用户自定义生成的hashCode本身分布过于集中导致出现大量哈希碰撞;

 

Ⅳ. HashMap的扩容机制

主要有两种情况会触发扩容:

一种是首次put元素时会触发扩容:如果是空参构造器就将容量扩充为16,如果指定了初始容量就将容量扩充为与其最接近的2的幂次方;

另一种是当哈希表中元素数量 > threshold时,触发扩容机制,每次将容量扩充为原来的两倍(添加元素时触发的扩容虽然条件不一样,但执行的操作是一样的),具体步骤如下:

首先生成一个容量为原来两倍的新数组,然后遍历老数组,

  对于单个节点,直接计算新索引并放入新数组即可;

  对于链表节点,顺序遍历链表并将其分成两个子链表,其中一个链表的头节点在新数组中的索引与在旧数组中一样,后一个链表的头节点在新数组中的索引=原索引+扩充的容量,分别将链表链表放入新数组中即可;

  对于红黑树节点,用next指针顺序遍历红黑树中的节点,同样分成索引不变和索引=原索引+扩充的容量两部分,同时统计两部分节点的数量,若数量小于等于6,就以将这些节点转换为链表存入新数组,若数量大于6,则仍以红黑树的形式放入新数组;

(注意:由索引的计算方式hashCode & (容量 - 1)可知,因为当容量扩充一倍时,(容量 - 1)的二进制会在高位多个1,故新索引只有两种情况:要么等于旧索引,要么等于旧索引+原容量,换句话说,在旧数组中不冲突的节点,在新数组中必定也不冲突,在旧数组中冲突的节点,在新数组中也未必冲突,因此这里的节点放入都是使用数组直接赋值,而非复杂度更高的put()方法)

 

Ⅴ. 为何HashMap的容量必须是2的幂次方?

因为在HashMap中散列函数计算索引是用key的hashCode与(容量 - 1)做按位与运算,为了保证各元素在哈希表中均匀分布,必须保证哈希表的容量是2的幂次方;

(为什么不用hashCode对容量取余作为索引?因为位运算的效率比取余运算高得多)

 

Ⅵ. 1.7版本的HashMap在多线程情况下可能导致死循环

因为1.7版本HashMap在插入链表节点时使用的是头插法,在多线程情况下可能会形成环形链表,导致查询列表时死循环;

1.8版本改成了尾插法,不会导致死循环,但在多线程情况下仍可能导致数据覆盖的问题,多线程环境下推荐使用ConcurrentHashMap;

 

Ⅶ. HashMap的遍历

通常使用HashMap中的entrySet()方法获取全部键值对实现遍历,

推荐使用迭代器iterator()实现遍历,还可以在遍历过程中使用iterator.remove()实现安全的删除操作;

(遍历过程中不能使用map.remove()方法进行删除,会抛出异常ConcurrentModificationException)

    @Test
    public void test2(){
        HashMap<Integer, Integer> map = new HashMap<>(16);
        map.put(1, 1);
        map.put(2, 2);
        map.put(3, 3);
        Iterator<Map.Entry<Integer, Integer>> iterator = map.entrySet().iterator();
        while(iterator.hasNext()){
            Map.Entry<Integer, Integer> entry = iterator.next();
            if(Objects.equals(entry.getKey(), 1)){
//                map.remove(entry.getKey());
                iterator.remove();
                
            }else{
                System.out.println("key: " + entry.getKey() + ", value: " + entry.getValue());
            }
        }

 

 

②ConcurrentHashMap

参考文章:ConcurrentHashMap 源码分析

Ⅰ. ConcurrentHashMap是如何保证线程安全的?

在jdk1.7中,将底层数组分为多个Segment,默认是16个,Segment继承自ReentrantLock,通过给每个分段加锁来保证线程安全,最多同时允许16个线程并发访问;

在jdk1.8中,取消了Segment分段锁,改为使用synchronized和CAS操作对底层数组的Node节点上锁,这些被上锁的Node节点也是链表/红黑树的首节点,

  在执行put()操作时,若节点为空,则使用CAS操作添加节点,若节点不为空,发生哈希冲突,则对首节点使用synchronized上锁,然后再向链表/红黑树中添加节点;

  在执行get()操作时无需获得锁,直接访问即可;

相比jdk1.7,jdk1.8中锁粒度更细,并发度更高,且因为只要不发生哈希冲突就不会触发上锁操作,因而读写效率更高;

 

Ⅱ. 为什么ConcurrentHashMap 的 key 和 value 不能为 null,而HashMap却可以?

参考文章:为什么ConcurrentHashMap不允许插入null值?

当一个线程对某个key执行get()操作,返回了null值,那么有两种情况:一是该键值不存在,二是该键值对就是存入了null值,

在单线程情况下,可以再执行一次containsKey()方法,根据底层数组中对应哈希值下标的Node节点是否存在来进一步判断该键值对是否存在,

而在多线程情况下,如果还需要再调用一次containsKey()方法,那么在这两次方法之间,可能会有其他线程进行了put()或是remove()操作修改了该节点,此时得到的结果就变成了修改后的状态,但我们期望的是修改前的状态,

想要确保得到我们期望的结果,就需要给两次操作加锁,但这无疑会降低程序的性能,所以最终选择了不允许向ConcurrentHashMap中存入null值;

 

 

③LinkedHashMap

Ⅰ. 基本介绍:

LinkedHashMap是HashMap的一个子类,在继承了HashMap的全部属性和方法的同时,维护了一条双向链表,

使得LinkedHashMap支持按照元素插入顺序,或是访问顺序进行排序,可以利用这一特性实现LRU缓存;

性能方面,LinkedHashMap因为要维护一条双向链表,因此插入效率比HashMap差,但迭代遍历效率高于HashMap;

 

Ⅱ. 如何开启LinkedHashMap的按照访问顺序排序的特性

使用全参构造器创建LinkedHashMap对象,并将accessOrder参数设为true即可(默认为false,即按插入顺序访问);

 

Ⅲ.  如何使用LinkedHashMap实现LRU缓存

创建LRU缓存类LRUCache并继承LinkedHashMap,

添加一个属性capacity作为LRU缓存的容量,

添加一个构造器,初始化capacity和accessOrder设为true的LinkedHashMap,

重写removeEldestEntry()方法,当缓存内元素数量大于capacity时返回true——每次执行插入操作后,若此方法返回true,都会将链表头的节点移除;

public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;

    public LRUCache(int capacity) {
        super(capacity, 0.75f, true);
        this.capacity = capacity;
    }

    /**
     * 每次执行插入操作之后,若本方法返回true,都会将链表首的节点移除,
     * 判断size超过容量时返回true,告知LinkedHashMap移除最老的缓存项(即链表的第一个元素);
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;
    }
}

 

 

④一些不太常用的Map接口的实现类

Ⅰ. HashTable

一种线程安全的哈希表,其内部的方法基本都用synchronized加锁了,同时底层只使用了链表来解决哈希冲突,没有像HashMap那样还引入红黑树;

 

Ⅱ. TreeMap

底层由红黑树实现,能够对哈希表内的元素对key值按照指定规则进行排序;

 

 

 

 

 


 

三、Java并发

1. 并发&并行、同步&异步

①并发&并行

  • 并发:多个任务在同一 时间段 内执行。
  • 并行:多个任务在同一 时刻 执行。

 

②同步&异步

  • 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
  • 异步:调用在发出之后,不用等待返回结果,该调用直接返回;

 

 

 

 

2. 进程 & 线程

①进程和线程的区别

进程是程序执行一次的过程,系统运行一个程序的过程就是一个进程从创建、运行到消亡的过程,

线程是比进程更小的执行单位,一个进程可以有多个线程,这些线程共享进程的方法区资源,但每个线程有自己的程序计数器虚拟机栈本地方法栈

 

为什么程序计数器线程私有?——每个线程有各自的执行进度,为了线程切换后能恢复到正确的执行位置;

为什么虚拟机栈和本地方法栈私有?——为了保证每个线程中的局部变量不被别的线程访问到;

一句话简单了解堆和方法区——堆:主要用于存放新创建的对象 ,方法区:主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据;

 

 

②线程的生命周期/状态

Java线程在运行的生命周期中有6种状态:(比操作系统中的线程状态少了一个READY就绪状态,多了等待、超时等待两个状态)

  NEW初始状态:线程被创建之后,被调用start()之前;

  RUNNABLE可运行状态:线程被调用start()等待运行的状态,和正在运行的状态;

  BLOACKED阻塞状态:等待锁释放;

  WAITITNG等待状态:线程调用了wait()或sleep()方法,需等待其它线程做出指定动作方可返回RUNNABLE状态,例如通知或中断;

  TIME_WAITING超时等待状态:等待指定时间,超过时间后自动返回;

  TERMINATED终止状态:线程运行完毕;

 

 

③什么是线程上下文切换?

上下文指的是线程在执行过程中的运行状态,包括程序计数器、JVM栈和本地方法栈中的信息;

上下文切换就是在切换不同的线程来执行时对上下文的相应变更;

线程在下列几种情况下会发生上下文切换:

  通过调用sleep()、wait()方法等主动让出CPU;——RUNABLE变为TIME-WAITING或是WAITING

  时间片用完;——都属于RUNABLE

  调用了阻塞类型的系统中断,例如请求IO和线程被阻塞;——RUNABLE变为BLOACKED

 

 

 

 

3. 死锁

①死锁的四个必要条件

  互斥:资源同一时刻只允许一个线程占用;

  不可剥夺:线程在获取资源之后,除非主动释放,其他线程无法强行剥夺;

  请求与保持:线程在因请求资源而阻塞时,会继续保持已有的资源不释放;

  循环等待:多个线程之间的请求资源形成了循环;

 

 

②死锁的处理策略——预防死锁

  破坏互斥条件:将互斥资源改造为共享资源;

  破坏不可剥夺条件:允许优先级高的线程剥夺优先级低的线程占用的资源;

  破坏请求保持条件:一次性请求所有的资源,待获取全部所需资源之后再开始运行;

  破坏循环等待条件:可以使用资源有序分配法,按照顺序申请资源,反序释放资源;——最实用

 

 

死锁的处理策略——避免死锁

使用银行家算法,在进行资源分配时按照全部进程都能获取资源的安全序列进行分配;

 

 

④死锁的处理策略——死锁的检测和解除

使用资源分配图检测是否发生了死锁,若发生死锁,

可以通过下列三种方法解除死锁:

Ⅰ. 资源剥夺法:将某些死锁进程挂起,抢占其资源并分给其它进程;

Ⅱ. 撤销进程法:强制撤销部分死锁进程,并剥夺其资源;

Ⅲ. 进程回退法:让一个或多个进程回退到足以避免死锁的阶段,这种方法要求系统记录进程的历史信息,设置还原点;

 

 

 

 

4. 并发编程三个特性

参考文章:多线程——并发编程三大特性

JMM(Java 内存模型)详解

①可见性:一个线程对共享变量进行修改,另一个线程应当立即得到修改后的值;——可以通过volatile、各种锁来实现

②原子性:一组操作,要么全部成功执行,要么一个都不执行,不会被其他线程中断而部分成功;——可以通过加锁来实现,悲观锁、乐观锁都可以

③有序性:为了提高执行效率,程序在编译运行的时候可能会对指令进行重排,这种重排在单线程情况下不会改变执行结果,但在多线程情况下可能会出现问题;——可以通过用volatile修饰来解决

 

 

 

 

5. volatile的作用?

参考文章:一篇文章彻底搞懂volatile关键字底层实现原理

①volatile关键字是如何保证内存可见性的?底层是怎么实现的?

volatile关键字通过两种机制来保证内存可见性:

  内存屏障(Memory Barrier):在多核处理器架构下,每个线程都有自己的缓存,volatile关键字通过在写操作后插入写屏障(Write Barrier),强制将写入缓存的数据写入主存,通过在读操作前插入读屏障(Read Barrier),强制从主存中加载数据,这样一来就可以确保一个线程中变量的更新能够立即被其他线程看到,保证内存可见性。

  禁止指令重排序:在程序运行时,为了提高性能,编译器可能会对指令进行重排序,这可能会导致变量的更新操作被延迟或打乱,使得其他线程无法立即看到最新的值。使用volatile关键字修饰的变量会禁止指令重排序,保证变量的更新操作按照代码顺序执行。

 

 

②为什么需要保证内存可见性?

如果不保证内存可见性,就可能会在并发环境下出现数据不一致的情况,例如一个线程修改了共享变量的值,但其他线程无法立即看到最新值,而是读取到了过期数据,从而产生错误的结果。

 

 

③volatile为什么要禁止指令重排,能举一个具体的指令重排出现问题的例子吗

禁止指令重排是为了确保程序的执行顺序与代码编写顺序一致,特别是在多线程环境下,避免出现意外的结果。

举例来说,假设有如下代码片段:

int a = 0;
boolean flag = false;

// 线程1
a = 1;
flag = true;

// 线程2
if (flag) {
    System.out.println(a);
}

如果发生指令重排,可能会导致线程2在判断flag时先于a的赋值操作,那么线程2就会打印出0,而不是预期的1。这种情况下,禁止指令重排可以确保线程2在看到flagtrue时,也能看到a被正确赋值为1,避免出现问题。

因此,通过禁止指令重排,可以保证程序的执行顺序符合代码逻辑,避免出现意外的行为,特别是在涉及多线程并发的情况下更为重要。

 

 

 

 

 

 

6. synchronized——保证被它修饰的方法/代码块同一时刻只有一个线程执行

①synchronized锁的锁范围有几种?

两种,

一种是类锁,进入加锁代码前必须获得该类的锁;——一个类只有一把锁

另一种是对象锁,进入加锁代码前必须获得该对象的锁;——这个类的每个对象实例都有一把锁(如果是静态对象,那其实和类锁范围也一样大了)

(类锁和对象锁不互斥,是两把不同的锁,例如一个类内既有synchronized修饰的实例方法,也有synchronized修饰的静态方法,则这两个方法是可以由不同的线程同时分别执行的)

 

 

②synchronized有几种加锁方式?

主要有三种:

Ⅰ. 修饰实例方法(给当前对象加对象锁)

synchronized void method() {
    //业务代码
}

 

Ⅱ. 修饰静态方法(给当前类加类锁)

synchronized static void method() {
    //业务代码
}

 

Ⅲ. 修饰代码块,对括号里的指定对象/类加对象/类锁:

  synchronized(object):给指定对象加对象锁;

  synchronized(.class):给指定类加类锁;

synchronized(this) {
    //业务代码
}

 

 

③synchronized底层原理?synchronized锁升级的过程?

参考文章:https://tech.youzan.com/javasuo-yu-xian-cheng-de-na-xie-shi/

https://xie.infoq.cn/article/da31f834180773eb37cd1b30a

根据对象头中的标记字段,锁在JVM中有四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态;

Ⅰ. 在JVM启动时会延时初始化偏向锁,默认时间为4s,在此期间创建的锁对象都处于无锁状态,如果有线程访问会直接升级为轻量锁,

Ⅱ. 之后创建的锁对象则都为匿名偏向状态,当第一个线程获取该锁对象时,会通过CAS无锁竞争的方式在对象头中存储线程id,进入偏向锁状态,之后若同一线程连续获取该锁对象,就可以直接进入临界区;

Ⅲ. 但当有其他线程试图获取该锁对象时,就会撤销偏向锁并升级为轻量级锁,此时线程需要通过CAS无锁竞争的方式尝试将对象头中的锁记录替换为当前线程的锁记录,

  如果替换成功,表示获取了锁,可以进入临界区;

  如果替换失败,说明此时有其他线程竞争锁,(有一种说法是此时会进行自旋操作,仍获取锁失败才会升级锁),直接升级为重量级锁;

Ⅳ. 重量级锁状态下,会先自旋尝试获得锁,

  如果成功了则可以访问临界区,

  如果失败了则会使用操作系统提供的互斥机制(此时会由用户态进入内核态,性能开销较大),将线程阻塞并放入等待队列,待操作系统唤起;

(重量级锁是通过竞争对monitor监视器的所有权来实现的:

当synchronized修饰代码块时,会使用monitorenter、monitorexit指令分别指向代码块开始和结束的位置,分别用于获取锁和释放锁;

当synchronized修饰方法时,会使用ACC-SYNCHRONIZED标识来指明这是一个同步方法,JVM在调用同步方法前会先获取对应的实例对象或是类class的锁;)

 

为什么需要延迟初始化?

因为在JVM启动时会有大量同步的操作,并不适合偏向锁,如果使用偏向锁会导致频繁的锁撤销和锁升级操作,降低了JVM的启动效率;

 

三种锁分别适用于什么场景?

偏向锁适用于单个线程重入的场景

轻量级锁适用于少量线程的交替执行的场景

重量级锁适用于大量线程同步执行的场景;

 

偏向锁可以重入,轻量级锁也可以重入,那偏向锁还有什么存在的必要?

目前看来,偏向锁存在的价值是为历史遗留的Collection类如Vector和HashTable等做优化,迟早药丸,Java 15中已经默认不开启。

 

 

 

 

7. ReentrantLock——实现了Lock接口,是一个可重入且独占式的锁,比synchronized提供了更多功能

①ReentrantLock和synchronized的异同

相同点:二者都是可重入且独占式的锁;

不同点:

  Ⅰ. 用法不同:synchronized是一个关键字,可以用来修饰方法和代码块,会自动加锁和解锁,

    ReentrantLock通过手动调用API来加锁和释放锁,且只能用在代码块上;

  Ⅱ. 锁类型不同:synchronized时非公平锁,ReentrantLock可以是公平锁,也可以是非公平锁;

  Ⅲ. ReentrantLock的锁等待过程可以被其他线程中断(使用lockInterruptibly()获取锁,等待过程可以通过interrupt()方法中断),可以用来解除死锁,

    synchronized则属于不可中断锁,一旦申请获得锁,就只能等到获得锁之后才可以进行其他逻辑;

  Ⅳ. ReentrantLock锁还支持超时获取锁失败功能(tryLock()方法可以获取锁超时的阈值),synchronized不支持;

  Ⅴ. 底层实现不同:synchronized底层是JVM通过监视器实现的,ReentrantLock是JDK层面基于AQS实现的;

 

②底层原理

/***********waitStatus 的取值定义***********/
// 表示此线程取消了争抢这个锁请求
static final int CANCELLED =  1;
// 官方的描述是,其表示当前node的后继节点对应的线程需要被唤醒
static final int SIGNAL    = -1;
// 表示节点在等待队列中,节点线程等待唤醒
static final int CONDITION = -2;
// 当前线程处在SHARED情况下,该字段才会使用
static final int PROPAGATE = -3;

Ⅰ. ReentrantLock加锁流程(以非公平锁为例)

  首先通过CAS操作设置AQS的state值来抢占锁,

    如果抢占成功,则直接将AQS的持有锁进程设置为当前线程;

    如果没有抢到,则需要将当前线程包装成AQS的Node节点并进入AQS的队列排队,

  不过在排队之前,还会再尝试一次AQS抢锁,同时判断持有锁的线程是否是当前线程,如果是则执行可重入的逻辑;

  如果还是没有抢到,也不是可重入的情况,则将当前线程包装成AQS的Node节点,进入AQS的队列排队并挂起线程,同时为了保证这个节点中的线程可以被唤醒,还需要确保此节点前面有一个状态为SIGNAL的节点;

  (节点状态waitStatus:CANCELLED = 1表示放弃争抢这个锁,其余状态均<0,如SIGNAL = -1表示后继结点中的线程等待被唤醒,为了确保新加入队列的节点前面有一个状态为SIGNAL的节点,会往前遍历,对于状态>0的节点跳过,找到第一个状态<0的节点,如果是SIGNAL则刚好,不是则设为SIGNAL)

 

Ⅱ. ReentrantLock公平锁和非公平锁的区别:

  公平锁就是优先让AQS队列头的节点中的线程去抢占锁,而非公平锁则没有这样的限制,所有线程一视同仁;

 

Ⅲ. ReentrantLock解锁流程:

  令AQS的状态state减去指定值,如果state变为0,表示当前线程已不再使用这个锁,则将AQS的持有锁线程设为NULL,并将state设为0,表示当前锁没有被占用;

  (当前线程释放锁之后,还会执行从等待队列选择第一个正常状态的节点唤醒,不过严格意义来说这也不算是解锁流程了)

 

 

 

 

8. AQS

①什么是AQS?底层原理是什么?

AQS全名是AbstractQueuedSynchronizer,是一个抽象类,提供了线程同步功能;

其底层维护了一个双向链表作为存放等待获取锁的线程的队列,

  当一个线程请求锁而锁已被占用时,这个线程就会被封装成队列节点加入队列中,进入等待状态,

  当持有锁的线程释放锁时,就从队列中选择一个线程唤醒并让其获得锁;

还有一个所有线程可见的变量state表示锁的占用状态,通过设定这个变量为1或者n,可以实现独占锁和允许n个线程共享的锁;

  当有线程需要获得锁时,就使用CAS操作令state减一(state>0才可以获得锁),当有线程需要释放锁时,就通过CAS操作令state加一;

 

 

②AQS的具体应用有哪些?

有独占锁ReentrantLock,还有共享锁Semaphore、CountDownLatch等;

 

 

 

 

9. ThreadLocal——线程局部变量

①什么是ThreadLocal?

ThreadLocal是一个线程局部变量,它可以让在多线程情况下让每个线程都独立地维护自己的变量副本,互不干扰;

其应用场景主要是一些需要每个线程存储各自信息的场景,例如用户身份信息传递、数据库连接管理、事务管理等;

 

 

②ThreadLocal的使用

Ⅰ. ThreadLocal有两种创建方式:一种是带初始值的,使用ThreadLocal.withInitial()来创建,一种不带初始值,直接用空参构造器;

Ⅱ. ThreadLocal类型的变量要用final修饰:因为ThreadLocal和其中泛型所存的值的关系是key和value的关系,Map存在于每个线程中,一个ThreadLocal对应一个值,如果ThreadLocal可变,那在改变之后通过就找不到原来的value了;

Ⅲ. 给ThreadLocal对象赋值和获取值用set()、get()方法即可;

Ⅳ. 为防止内存泄漏,在使用完ThreadLocal后,应手动调用remove()进行清除;

 

 

③底层原理

每个线程内部都会维护一个ThreadLocalMap,其中存储着以ThreadLocal为key,Object为value的键值对,ThreadLocal的get()、set()操作本质上就是对线程内的这个哈希表的操作,这就保证了线程间的隔离性;

但由于ThreadLocalMap中使用的key是对ThreadLocal的弱引用,而value是对Object的强引用,因此在JVM进行垃圾回收的时候,key会被回收,而value不会被回收,

此时就会出现key为null的Entry,除非手动清理,否则value不会被垃圾回收,就会产生内存泄漏,

不过ThreadLocal在设计时就考虑到了这一点,当调用get()、set()、remove()方法时都会清除key为null的value值,实际使用时推荐使用完ThreadLocal之后调用remove()方法清理;

 

衍生问题:既然可能产生内存泄漏,为什么要将ThreadLocalMap的key设计为弱引用?

因为如果设计成强引用的话,即使线程结束销毁,其ThreadLocal中存储的Entry(包括key和value)仍不会被垃圾回收,会造成更严重的内存泄漏,

而将key设计为弱引用的话,可以保证key会被回收掉,同时调用get()、set()、remove()方法时都会清除key为null的value值,避免了内存泄漏;

 

那能不能将value设计为弱引用?

不能,因为弱引用只要JVM进行垃圾回收就会被回收掉,这就可能导致get()方法得到的是null值;

 

 

 

 

10. 乐观锁与悲观锁

①悲观锁:

总是假设最坏的情况,每次拿数据的时候都认为别的线程会修改,所以每次在获取资源的时候都会上锁,这样别的线程要获取这个资源就需要先等待获得锁,

常见的ReentrantLock和synchronized都是悲观锁;

悲观锁的优点是数据一致性高;

悲观锁的缺点是每次获取资源前都要先获得锁,效率比较低,可能还会导致死锁或是线程堵塞;

适用场景:读少写多,竞争激烈;

 

 

②乐观锁:

总是假设最好的情况,每次拿数据的时候都认为别的线程不会修改,所以每次在获取资源的时候都不上锁,只会在提交修改的时候验证资源是否已被其他线程修改了;

常见的乐观锁可以通过CAS算法实现:核心思想就是比较线程携带的标记和访问对象中对应的标记,在两者相同时进行更新数据的操作,这两步是通过一个原子操作实现的,不会被其他线程打断;

乐观锁的优点是因为不用获取锁,效率较高;

乐观锁的缺点在于首先它只能保证单个变量的原子性,

  其次在多线程竞争激烈的情况下,乐观锁可能会一直自旋,无法对共享变量进行修改,性能反而不如悲观锁,

  最后乐观锁还存在ABA的问题,即访问对象中的标记可能经过多次修改又变回的原来的值,导致错误判断,不过这一点可以通过添加版本号或是时间戳来解决;

适用场景:读多写少,竞争较少;

 

 

 

 

11. 线程池

参考文章:线程池详解

①什么是线程池?有什么好处?

线程池就是管理一系列线程的资源池,使用线程池可以降低重复创建、销毁线程的资源消耗,同时提高线程的可管理性;

 

 

②线程池核心参数

Ⅰ. corePoolSize:核心线程数量,默认情况下核心线程即使空闲也不会回收,不过可以通过调用allowCoreThreadTimeOut(true)来让核心线程也可以被回收;

Ⅱ. maximumPoolSize:线程池中允许存在的最大线程数量;

Ⅲ. workQueue:任务队列,用于存放等待执行的任务;

其他参数还有:

Ⅳ. keepAliveTime:超过核心线程数量的空闲线程处于空闲多长时间会被回收,一般取默认值60s即可;

Ⅴ. unit:keepAliveTime的时间单位;

Ⅵ. handler:拒绝策略,当线程池达到最大线程且任务队列已满时,如何处理新的任务;

Ⅶ. threadFactory:线程工厂,用于创建新的线程;——了解即可,不常用

 

 

③线程池处理任务的流程

Ⅰ. 如果当前线程数小于核心线程数,则立即创建一个新线程来处理任务;

Ⅱ. 如果当前线程数大于核心线程数,且任务队列未满,则将任务放进任务队列中等待;

Ⅲ. 如果当前线程数大于核心线程数,但小于最大线程数,且任务队列已满,则创建一个新线程来处理任务;

Ⅳ. 如果当前线程数等于最大线程数,且任务队列已满,则执行拒绝策略;

 

 

④线程池大小如何确定?

一般来说,最佳线程数=CPU核数*(1 + (线程等待时间/线程计算时间)),

对于CPU密集型任务,就是1+N,对于IO密集型任务(磁盘IO、网络IO),就是2*N;

在实际应用中也可以考虑动态地对线程池大小进行修改;

 

 

⑤任务队列有哪些?如何选择?

任务队列要求是线程安全的BlockingQueue,用来保存实现了Runnable接口的任务,主要分为三类:

Ⅰ. 无界队列:这种队列大小无限制,默认为Integer.MAX_VALUE,常见的有LinkedBlockingQueue,在任务耗时较长时可能会导致任务队列堆积大量任务,最终导致OOM;

(LinkedBlockingQueue:用双向链表实现的阻塞队列,创建时无需指定容量大小(也可以指定最大容量),生产和消费使用的锁分离)

 

Ⅱ. 有界队列:这种队列在创建时就会指定容量大小,常见的有FIFO的ArrayBlockingQueue、还有支持优先级排序的PriorityBlockingQueue,

——有界队列需要合理配置队列大小,否则可能会降低系统吞吐量;

(ArrayBlockingQueue:用数组实现的阻塞队列,创建时需要指定容量大小,支持公平和非公平两种锁机制,生产和消费使用同一个锁)

 

Ⅲ. 同步移交队列:这种队列不存放任务,而是直接将任务移交给线程去执行,常见的有SynchronousQueue;

 

 

⑥常见的拒绝策略有哪些,如何选择?

Ⅰ. AbortPolicy:直接抛出异常,终止任务,这也是默认的拒绝策略;

Ⅱ. CallerRunsPolicy:使用调用者线程本身执行任务,可以确保任务完成,不过当任务较多时效率较低;

Ⅲ. DiscardPolicy:直接丢弃任务,不做任何处理;

Ⅳ. DiscardOldestPolicy:丢失任务队列中最老的任务,再将新任务加入到任务队列中;

 

 

⑦如何给线程池里的线程命名?——ThreadFactory

默认情况下创建的线程名字类似 pool-1-thread-n 这样的,没有业务含义,不利于我们定位问题。

可以通过guava的ThreadFactoryBuilder生成ThreadFactory,再在创建线程池时传入参数即可给线程池里的线程命名(具体命什么名看公司要求):

    ThreadFactory threadFactory = new ThreadFactoryBuilder()
                .setDaemon(true)
                .setNameFormat("My Thread" + "-%d ")
                .build();
    ThreadPoolExecutor executor = new ThreadPoolExecutor(
                CORE_POOL_SIZE,
                MAX_POOL_SIZE,
                KEEP_ALIVE_TIME,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(QUEUE_CAPACITY),
                threadFactory,
                new ThreadPoolExecutor.CallerRunsPolicy()
        );

需要导入guava的依赖:

        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>30.1-jre</version>
        </dependency>

 

 

⑧线程池状态有哪些?

RUNNING:运行状态,可以接收任务;

SHUTDOWN:执行shutdown()方法后进入此状态,不再接收新任务,但会把正在执行和任务队列中的任务处理完;

STOP:执行shutdownNow()方法后进入的状态,不再接收新任务,同时中断正在处理的任务,任务队列中的任务也全部返回;

TIDYING:线程池自主整理状态,自行执行terminated()方法对线程池进行整理;

TERMINATED:线程池终止状态;

 

 

⑨线程池常用方法

Ⅰ. 创建线程池——new ThreadPoolExecuter()

使用ThreadPoolExecuter()的带参构造器即可;

(不推荐使用工具类Executers自带的线程池,因为有些例如FixedThreadPool、SingleThreadPool任务队列的最大长度为Integer.MAX_VALUE,可能会堆积大量请求,导致OOM

有些例如CachedThreadPool允许创建Integer.MAX_VALUE个线程,可能会创建大量线程,导致OOM)

 

Ⅱ. 预热线程池——prestartCoreThread()、prestartAllCoreThreads()

刚创建的线程池中是没有线程的,为避免刚开始就传来大量任务,因为需要创建大量线程而导致响应速度慢,可以提前创建好线程,

prestartCoreThread()、prestartAllCoreThreads():提前创建一个、或全部核心线程;

 

Ⅲ. 执行线程池——submit()和excute()

excute()方法只能传入Runnable,不会有返回值,

submit()方法既能传入Callable,也能传入Runnable,传入Callable的话可以用一个Future来异步接收返回值;

 

Ⅳ. 关闭线程池——shutdown()和shutdownNow()

shutdown():执行shutdown()方法后线程池进入SHUTDOWN状态,不再接收新任务,但会把正在执行和任务队列中的任务处理完;

shutdownNow():执行shutdownNow()方法后进入的状态,不再接收新任务,同时中断正在处理的任务,任务队列中的任务也全部返回;

 

Ⅴ. 等待线程池完全关闭进入TERMINATED状态——awaitTermination()

因为线程池无论是执行shutdown()还是shutdownNow()都是异步通知线程池关闭,线程池本身还需要经历TIDYING状态整理线程池,之后才会正式终止进入TERMINATED状态,

所以如果需要同步等待线程池关闭,之后才能进行后续操作,可以使用awaitTermination()方法同步等待线程池关闭,可以设定最长等待时间避免等待过长,还需要使用try-catch捕获异常;

// ...
// 关闭线程池
executor.shutdown();
try {
    // 等待线程池关闭,最多等待5分钟
    if (!executor.awaitTermination(5, TimeUnit.MINUTES)) {
        // 如果等待超时,则打印日志
        System.err.println("线程池未能在5分钟内完全关闭");
    }
} catch (InterruptedException e) {
    // 异常处理
}
System.out.println("Finished all threads");

 

 

⑩如何动态设置线程池的参数

参考文章:如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答

Ⅰ. 修改核心线程数——setCorePoolSize()

使用ThreadPoolExecuter提供的setCorePoolSize()方法即可在运行时修改线程池的核心线程数;

线程池运行时corePoolSize发生改变时的处理策略:

  首先和当前工作线程数比较,若小于当前工作线程数,则对空闲线程进行回收,否则继续判断;

  然后和原始值比较,若小于原始值,则结束判断,否则继续判断;

  最后查看任务队列中有无任务,若有则创建新线程;

 

Ⅱ. 修改最大线程数——setMaximumPoolSize()

线程池运行时maximumPoolSize发生改变时的处理策略:

  若最大线程数小于当前工作线程数,则对工作线程进行回收,否则什么都不做;

 

Ⅲ. 修改任务队列容量

可以复制LinkedBlockingQueue的代码自定义一个阻塞队列,将capacity属性去掉final修饰,改为volatile,然后添加get()/set()方法,

然后用这个自定义阻塞队列作为线程池的任务队列,这样就可以在线程池运行时,使用setCapacity()方法修改任务队列容量了;

 

 

 

 

12. 如何控制线程的执行顺序?

①使用CountDownLatch倒计时器

初始化:new CountDownLatch(int count),这里count为几,就是让一个线程等待多少线程执行完之后才能执行;

核心方法:

  .countDown()——令计时器减一;

  .await()——同步判断计时器是否为0,若大于0则一直阻塞,直至计时器为0;

因此只要在需要先执行的线程结尾调用countDown(),在后执行的线程开头调用await(),即可实现按序执行,

不过每需要实现一次先后顺序,就需要一个CountDownLatch,比如需要线程1->线程2->线程3,那就需要两个初始化为1的CountDownLatch,

如果需要线程1到n ->线程n+1,那就需要一个初始化为n的CountDownLatch;

 

 

②使用join()方法——需要显示地创建线程,才能使用线程调用join()方法,使用线程池时不能显示地创建线程,故不适用

thread.join():令执行此方法的线程阻塞,直至thread线程执行完毕;

因此如果需要执行顺序为thread1->thread2->thread3,

可以这样写:

        thread1.start();
        thread1.join();
        thread2.start();
        thread2.join();
        thread3.start();

也可以把thread1.join()、thread2.join()写在分别线程2、3的run()方法开头;

 

 

③使用wait()和notify()——因为是对对象调用,所以线程池中也能用

在需要先执行的线程结尾调用notify(),在需要后执行的线程开头调用wait()方法,使用逻辑和CountDownLatch差不多;

——注意对object调用wait()、notify()都需要在synchronized给对应的object加对象锁的情况下;(wait()/notify()底层应该也是通过监视器monitor实现的)

 

 

 

 

 


 

四、JVM

1. 简单介绍一下对JVM的理解

JVM就是在计算机上运行字节码,执行java程序的虚拟机,主要功能包括字节码解释、垃圾回收、即时编译等;

字节码解释就是将Java源代码编译成字节码文件(.class文件),然后由JVM解释执行字节码,因为JVM在不同操作系统上都提供了对应的实现,所以JVM具有跨平台的特性,一次编译,多处运行;

JVM还有内存管理的功能,主要是对堆内存和栈内存,JVM通过垃圾回收机制自动管理堆内存中不再使用的对象,减轻了程序员手动释放内存的负担;

此外,JVM还支持即时编译(JIT)功能,可以将频繁执行的热点代码编译成本地机器码,提高程序的执行效率;

 

 

 

 

2. JVM内存分区?

JVM内存结构分为5个区域:程序计数器、虚拟机栈、本地方法栈、堆内存、方法区;

Ⅰ. 程序计数器:当前线程所指向的字节码的行号的指示器,通过程序计数器实现对代码的流程控制;

Ⅱ. 虚拟机栈:虚拟机栈由多个栈帧组成,每个方法对应一个栈帧,每次方法调用时都会有一个栈帧入栈,方法调用结束就会有一个栈帧弹出,

  栈帧中通过局部变量表、操作数栈等结构存放了执行Java方法在运行时的各种数据,包括入参、局部变量、返回值等;

Ⅲ. 本地方法栈:本地方法栈就是虚拟机栈的本地方法版本,用于执行Native本地方法;

Ⅳ. 堆内存:堆用于存储对象实例,可以细分为老年代、新生代(Eden、From Survivor、To Survivor),是垃圾回收的主要区域;

Ⅴ. 方法区:用于存储加载的类信息、常量、静态变量、编译后的代码等数据;

 

 

 

 

3. GC垃圾回收

①常见的垃圾回收算法有哪些?

Ⅰ. 标记清除算法

利用可达性去遍历内存,对存活对象和垃圾对象进行标记,标记结束后统一将所有标记的垃圾对象回收掉,

这种算法效率较低,会产生大量不连续的内存碎片;

 

Ⅱ. 标记整理算法

标记过程和标记清除法一样,但后续步骤是让所有存活的对象都向一端移动,之后直接清理掉边界外的内存;

 

Ⅲ. 复制算法

将内存分为大小相同的两块,每次使用其中的一块,用完之后就将存活的对象复制到另一块,然后将之前的那块全部清理掉;

这种算法效率较高,但会浪费一半的内存空间;

 

Ⅳ. 分代收集算法

根据处于新生代还是老年代选用不同的垃圾回收算法:

对于新生代,每次垃圾收集存活对象只占很小一部分,复制成本较低,所以用复制算法,

对于老年代,对象存活率高,所以使用标记清除或是标记整理算法;

 

 

②常见的垃圾回收器有哪些?

Ⅰ. Serial收集器:单线程的垃圾回收器,特点是实现简单,但在进行垃圾回收时需要暂停所有其他线程,适用于小型应用程序或是客户端应用程序;

Ⅱ. parNew收集器:Serial收集器的多线程版本;

Ⅲ. Parellel收集器:多线程的垃圾回收器,特点是可以自适应调节参数来让系统可以获得最大吞吐量,适用于对吞吐量要求较高的中型应用程序;

Ⅳ. Parellel Old收集器:Parellel收集器的老年代版本;

Ⅴ. CMS收集器:多线程的垃圾回收器,特点是以获取最短回收停顿时间为目标,适用于对响应时间有要求的中型应用程序;

Ⅵ. G1收集器:一款面向服务器的垃圾回收器,适用于有多个处理器和大容量内存的机器,回收停顿时间很短,且具有高吞吐量;(JDK9之后的默认垃圾回收器)

 

 

 

 

4. 类加载器

①new一个对象的过程?结合类加载说一下

Ⅰ. 加载:首先JVM会检查该类是否已被加载,若没有则通过类加载器进行加载,类加载器负责将类的字节码加载到内存中,

常见的类加载器有BootStrapClassLoader、ExtensionClassLoader、ApplicationClassLoader;

Ⅱ. 连接:这部分又分为验证、准备、解析三步,

  验证:JVM对类的字节码进行验证,确保符合Java语言规范;

  准备:JVM为类的静态变量分配内存,并赋初始值为默认值;

  解析:JVM将常量池中的符号引用替换为直接引用;(符号引用包括类和接口的全限定名、字段和方法的名称、描述符)

Ⅲ. 初始化:如果父类还没初始化就先初始化父类,然后执行类的初始化代码,包括静态变量的赋值、静态代码块的执行和静态方法的调用;

Ⅳ. 创建对象:为对象分配内存空间、设置对象头信息和执行构造方法;

 

 

 

 

5. 如果我要在 JVM 上跑一个项目,JVM 参数一般怎么设置?

Ⅰ. 堆内存设置:可以使用-Xmx、-Xms参数分别设置堆的最大和初始大小;

例如,设置为-Xmx1024m和-Xms512m将分配1GB的最大堆和512MB的初始堆。

 

Ⅱ. 垃圾回收器选择:根据项目需求选择合适的垃圾回收器,可以使用-XX:+UseXxxGC参数设置垃圾回收器;

例如,可以使用-XX:+UseParallelGC启用并行垃圾回收器,-XX:+UseConcMarkSweepGC启用并发标记清除垃圾回收器,或-XX:+UseG1GC启用G1垃圾回收器

 

Ⅲ. 并发线程设置:可以使用-XX:ParellelGCThreads参数来设置多线程垃圾回收器的线程数;

例如,-XX:ParallelGCThreads=4将设置4个并行线程。

 

Ⅳ. 线程栈设置:可以使用-Xss参数设置线程栈的大小;

例如,-Xss256k将设置线程栈大小为256KB

 

Ⅴ. 类加载优化:可以使用-XX:+UseFastClassInitialization参数来启用快速类加载初始化,可以通过类加载性能;

 

Ⅵ. GC日志设置:可以使用-XX:+PrintGC、-XX:+PrintGCDetails参数来启用详细的垃圾回收日志,便于调试和分析性能;

 

Ⅶ. 其他参数:例如-XX:MaxMetaspaceSize设置元空间的最大大小,-XX:CompileThreshold设置JIT编译的阈值等;

 

 

 

 

6. JVM可能会出现哪些异常?

OutOfMemoryError:堆内存不够用;

StackOverFlowError:JVM栈内存溢出;

PermGen space:方法区内存溢出;

 

 

 

 

 

 


 

五、MySQL

1. MySQL基础

①说说delete、drop、truncate的异同?

相同点:都用来进行删除操作;

不同点:delete用来删除表中的数据,可以加where条件语句,delete属于DML,在事务中可以通过commit和rollback来选择提交操作或是回滚操作;

  truncate删除表中的全部数据,自增id也会重新开始计数,truncate属于DDL,操作立即生效不可回滚,且速度比delete快;

  drop则是用来删除整个表,包括其中的数据和索引等都会被删除,drop属于DDL,操作立即生效不可回滚;

DELETE FROM account;

DELETE FROM account
WHERE id = 1;

TRUNCATE account;

drop table account;

 

 

②char和varchar有什么区别?

char是定长字符串,在建表时不指定长度则默认为1个字符,指定长度则所有数据均以该长度进行存储,不足长度的以空格填充;

varchar是变长字符串,在建表时必须指定长度,数据以其实际长度进行存储,不过还需要1到2KB在变长字段长度列表中存储其实际长度信息;

通常,若存储的字符串长度基本完全相同,则用char,若长度不确定,则用varchar;

(如果字符串长度超过5000,建议使用text,且应独立出来一张表,用主键来对应,避免影响其它字段索引效率)

 

 

③说一说你理解的外键约束?

外键约束主要是为了维护相关联的表之间的数据一致性,

通过在主表中的某个字段建立外键约束,并与子表中的相同字段关联起来,对主表的更新/删除操作进行某种约束,

默认的行为是NO ACTION,即若该记录在子表中也存在,则不允许对主表中的记录进行更新/删除操作;(对子表进行操作不受限制,只约束父表)

还有CASCADE级联行为,即若父表对一条记录进行更新/删除操作,则也对子表中的关联的记录进行相同操作;

(注意:建立外键约束的父表的字段应当建立索引,否则无法对其建立外键约束)

ALTER TABLE foreign_account 
ADD CONSTRAINT fk_name 
FOREIGN KEY (NAME) 
REFERENCES account(NAME);

 

 

④ASCII、Unicode和UTF-8编码的区别?

ASCII 只有127个字符,只能表示英文字母的大小写、数字和一些符号;

Unicode将各种语言的编码格式统一起来,通常用两个字节表示一个字符,相比ASCII码用一个字节表示一个字符,多了一倍的存储空间;

UTF-8是一种可变长编码,将Unicode中的字符按数字大小编为1-6个字节,例如英文字母用一个字节表示,常用汉字用3个字节表示;

一般在计算机内存中统一使用Unicode,当需要保存到硬盘或是传输的时候,就转为UTF-8编码

 

 

⑤DECIMAL 和 FLOAT/DOUBLE 的区别是什么?

DECIMAL是定点数,通过以整数的形式存储小数,可以存储精确的小数值,

FLOAT/DOUBLE是浮点数,只能存储近似的小数,有精度损失,

在对精度有要求的场景下,小数应使用DECIMAL类型;

 

 

⑥NULL 和 '' 的区别是什么?

Ⅰ. NULL代表一个不确定的值,不能用运算符判断,只能用IS NULL/IS NOT NULL来判断,本身占用一个字节,

  而''是空字符串,可以用运算符判断,且不占用空间;

Ⅱ. NULL会影响聚合函数的结果,MAX()、MIN()等聚合函数会忽略NULL,COUNT(列名也会忽略NULL值),但COUNT(*)会将NULL值也算上;

推荐使用0或者""来代替NULL值作为字段默认值;

 

 

 

 

2. 基础架构&存储引擎

①一条SQL语句在MySQL中的执行过程

Ⅰ. 首先连接器验证用户身份和权限;

Ⅱ. 查询缓存:若缓存命中直接返回,否则进行下一步;

Ⅲ. 分析器,对SQL语句进行词法分析和语法分析,提取SQL语句的关键元素并检查是否存在语法错误;

Ⅳ. 优化器:对SQL语句进行优化,选择最优的执行方案;

Ⅴ. 执行器:查看用户是否有执行权限,然后调用存储引擎提供的接口;

Ⅵ. 存储引擎:按照执行方案执行SQL语句,并返回结果;

 

 

②MySQL 一行记录是怎么存储的?

在常见的COMPACT格式中,一条完整的记录分为额外信息和真实数据两部分,

其中额外信息包括:

  变长字段长度列表:逆序存放记录中所有变长字段的真实数据的长度,不大于255KB用一个字节记录即可,大于255KB需要两个字节;

  NULL值列表:逆序存放记录中所有可以为NULL的字段是否为NULL,一个字段用一位来表示,不足的补齐1个字节,若所有字段均为非空约束,则可以省略此列表;

  记录头信息:记录本条记录是否已被逻辑删除、下一条记录的地址等信息;

真实数据中还有三个隐藏字段:

  row_id:如果建表时指定了主键或是唯一约束列就没有此字段,否则会添加此字段来指定某一列作为主键,占用6KB;

  trx_id:记录本条记录由哪个事务生成,占用6KB;

  roll_ptr:记录上一版本的指针,占用7KB;

 

变长字段列表/NULL值列表为什么要逆序存放?

这样的话位置靠前的真实数据更有可能和自己的列表信息存放在同一个CPU Cache Line中,提高Cache命中率;

 

varchar(n) 中 n 最大取值为多少?

首先,MySQL 规定除了 TEXT、BLOBs 这种大对象类型之外,其他所有的列(不包括隐藏字段和记录头信息)占用的字节长度加起来不能超过 65535 个字节;

这里我们只考虑单字段记录的情况,由于不包括隐藏字段和记录头信息,故只考虑变长字段长度列表和NULL值列表所占空间即可,

因为单列最大字节数>255KB,故变长字段长度列表占2KB,允许该列为NULL的话,再加上1KB的NULL值列表,

故在数据库表只有一个 varchar(n) 字段且字符集是 ascii 的情况下,varchar(n) 中 n 最大值 = 65535 - 2 - 1 = 65532;

 

行溢出后,MySQL 是怎么处理的?

对于Compact行格式:当发生行溢出时,在记录的真实数据处会存放该列的一部分数据,将剩余的数据放在溢出页中,并在真实数据除用20KB存放溢出页的地址;

对于Compressed 和 Dynamic 这两种格式:记录的真实数据处只会用20KB存放溢出页的地址,全部数据都放在溢出页中;

 

 

③MyISAM 和 InnoDB 有什么区别?

Ⅰ. InnoDB是MySQL5.5.5版本之后的默认存储引擎,支持事务、MVCC、行级锁、外键约束、数据库异常崩溃后的安全恢复,这些MyISAM都不支持;

Ⅱ. 虽然InnoDb和MyISAM都使用B+数作为索引结构,但InnoDB中主键索引采用聚集索引,B+树叶子节点存储数据,

而MyISAM中主键索引采用非聚集索引,B+树叶子节点只存储指向数据文件的指针,还需要访问磁盘获取数据,因此InnoDB的读写效率比MyISAM更高;

Ⅲ. MyISAM支持压缩表和空间数据索引,InnoDB不支持;

总体来说,大部分情况下都是选择InnoDB更好,除非不需要InnoDB提供的那些特性;

 

InnoDB为什么要使用自增id作为主键?

因为这样在插入新的记录时,可以直接添加到当前索引节点的后续位置,一页写满再开辟新的页,

而如果使用非自增列作为主键,由于主键分布近乎随机,因此新的记录经常会插入到现有索引页中,需要频繁的进行移动记录和分页操作,效率低下,还会产生大量内存碎片;

 

 

 

 

3. 索引

①聚簇索引和非聚簇索引?覆盖索引?联合索引?

聚簇索引就是索引结构和数据一起存放的索引,InnoDB中主键索引就是聚簇索引,其叶子节点中存储了索引结构和数据;

非聚簇索引就是索引结构和数据分开存放的索引,InnoDB中二级索引就是非聚簇索引,其叶子节点中只存储了主键;

(MyISAM中主键/二级索引都是非聚簇索引,其叶子节点中存储了指向数据块的指针)

 

覆盖索引:指二级索引中叶子节点存储的数据刚好可以满足本次查询所需的数据,无需使用主键再去主键索引中回表查询,这个过程就称为覆盖索引;

 

联合索引:使用表中多个字段创建的索引;

 

 

②InnoDB中索引的底层数据结构是什么?为什么选用B+树?

Ⅰ. 相比于哈希表:

哈希表虽然在单条查询中确实比B+树快,但哈希表不支持范围查询和排序,B+树则支持,

而且使用哈希表就必须一次将数据全部加载到内存中,如果数据量大就非常消耗内存,而B+树则可以按照节点分段加载,可以减少内存消耗;

Ⅱ. 相比于二叉树:

B+树的分支更多,层数更低,IO次数更少,查询效率更高;

Ⅲ. 相比于B树:

B+树的非叶子结点只存储索引结构,不存储数据,而B树的非叶子结点和叶子节点都存储了数据,因此每个节点中存储的记录条数更多,树的层数更低,IO次数更少;

除此之外,B+树每次都需要查询到叶子节点才能获取数据,B树则有可能在非叶子结点就获取数据,因此B+树的查询次数更稳定;

还有就是B+树的叶子节点维护了一条链表,因此在范围查询时只需找到下限然后沿单链表遍历即可,而B树则只能通过中序遍历来实现;

 

B+树索引IO次数一般为多少?

一个页的大小为16KB,主键类型一般为int或binInt,大小为4-8B,指针大小一般也为4-8B,所以一个页能存储1k左右的键值,

这样三层的B+树就能存储越10亿条记录,考虑到实际使用中一般页不会存满,所以树的高度一般为2-4层,

又因为根节点是常驻内存的,所以一般IO次数为1-3次;

 

既然增加B+树的路数可以降低高度,那么能不能无限增加路数?

不能,因为无限增加路数会导致一个B+树节点中存储的键值过多,一次将节点全部读入内存的消耗也会过大;

 

键值为null的记录在B+树如何存放?

首先主键必定非空,因此主键索引中不可能有为Null的键值,而在二级索引中,null值一般被视为最小值,放在B+树叶子节点的最左侧;

 

 

③使用索引有哪些好处?缺点?何时使用索引?何时不适用?

最大的好处是可以大大提高检索效率,其次可以通过创建唯一索引来保证每一行数据的唯一性;

缺点在于对表中数据进行增删改时,也需要对索引进行维护,降低了增删改操作的效率,除此之外索引本身需要占用物理空间;

当字段有唯一限制或重复度较低时,对于那些经常使用where、order by、group by的字段,适合建立索引;

对于那些重复度较高的字段,或是经常更新的字段,则不适合建立索引;

 

 

④如何排查一条慢SQL?

发现慢SQL:可以通过开启MySQL的慢查询日志,设置好时间阈值,对慢查询命令进行捕获;

分析慢SQL:可以通过EXPLAIN指令来分析SQL语句的执行计划;

优化慢SQL:可以通过索引优化和优化SQL语句来实现;

 

EXPLAIN指令分析结果中的关键字段?

首先是id,表示执行顺序,id相同自上而下执行,不同id大的先执行;

然后是type,表示数据扫描类型,执行效率从低到高有:all全表扫描、index全索引扫描、range部分索引扫描、ref查询命中非唯一索引、eq_ref查询命中唯一/主键索引、const查询命中唯一索引且结果只有一条;

然后是key,表示本次查询使用到的索引;

然后是row,表示sql估计的本次扫描的记录行数;

最后是Extra额外信息,using index表示使用了覆盖索引,无需回表查询,using where表示需要回表查询,using filesort表示查询语句中需要排序,但无法利用索引完成排序,using temporary表示使用了临时表保存中间结果;

 

 

⑤索引优化的手段有哪些?(如何避免索引失效?索引使用时有哪些注意点?)

SQL语句优化的手段:

  将嵌套子查询改为连接查询(子查询产生的虚表没有索引)、

  尽量不使用select * 而是指定查询需要的字段,

索引优化的手段:

  避免索引失效(最左匹配原则、范围查询、对索引列进行函数计算、隐式转换)、

  尽量使用覆盖索引减少回表查询次数等;

 

Ⅰ. 使用联合索引时遵循最左匹配原则,查询的字段应当从索引的最左列开始,不能跳过中间的列,否则会导致索引失效;

Ⅱ. 范围查询中的>、<、like后缀匹配会导致其右侧的索引失效,>=、<=、between、like前缀匹配则不会(等于的那部分生效);

Ⅲ. 在索引列上进行计算或是函数操作会导致索引失效,查询条件中使用or时必须全部字段都建立了索引,否则也会索引失效;

Ⅳ. 当查询条件左右两侧类型不匹配时会发生隐式转换,可能导致索引失效;

Ⅴ. 尽量使用覆盖索引,避免或尽量减少回表查询的次数;

Ⅵ. 尽量用连接查询取代子查询,因为子查询产生的虚表中是没有建立索引的,只能执行全表扫描,效率较低;

 

 

⑥SQL语法中内连接、外连接区别是什么?

内连接JOIN/INNER JOIN:只保留两个表相匹配的数据;

左外连接LEFT JOIN:左表为驱动表,所有数据均保留,右表为匹配表,只保留匹配的数据;

右外连接RIGHT JOIN:与左外连接相反;

全外连接FULL OUTER JOIN:只保留两个表不匹配的数据;

 

补充:

UNION:返回两个查询的结果集的并集,去除重复记录;

UNIOM ALL:返回两个查询的结果集的并集,但不去除重复记录;

 

 

 

 

4. 事务

①事务的四大特性(ACID)?

Ⅰ. 原子性Atomicity:一个事务中的所有操作,要么全部完成,要么全部不完成;——通过undo log实现

Ⅱ. 一致性Consistency:事务操作前后,数据库中的数据应该保持一致性(例如转账事务中,操作前后,转账者和收款者的账户总和应该是不变的);——通过A、I、D实现

Ⅲ. 隔离性Isolation:一个事务在访问数据库时,不会被其他事务干扰,每个事务锁操作的数据空间应当是独立的;——通过MVCC和锁机制实现

Ⅳ. 持久性Durability:一个事务在提交之后,其对数据库进行的修改应当是永久的,即使数据库发生故障也不应当有影响;——通过redo log实现

 

 

②并发事务可能会导致哪些问题?

Ⅰ. 脏读:一个事务读到了另一个事务尚未提交的数据;

Ⅱ. 不可重复读:一个事务多次读取同一数据,得到的结果却不一样;

Ⅲ. 幻读:一个事务多次使用相同的查询条件,得到的记录条数却不一样;

Ⅳ. 丢弃修改:两个事务并发地对一个数据进行操作,结果产生了数据覆盖,

(例如两个事物同时对一个为0的变量执行递增操作,首先两个事务先后取到了初始值0,然后各自对0执行递增得到1并修改变量,导致两次递增结果只为1)

 

不可重复读和幻读的区别?

不可重复读强调内容修改,幻读强调记录增加或减少;

将两种分为两个概念主要是解决不可重复读和幻读的手段不同;

  • 例1(同样的条件, 你读取过的数据, 再次读取出来发现值不一样了 ):事务1中的A先生读取自己的工资为 1000的操作还没完成,事务2中的B先生就修改了A的工资为2000,导致A再读自己的工资时工资变为 2000;这就是不可重复读。

  • 例2(同样的条件, 第1次和第2次读出来的记录数不一样 ):假某工资单表中工资大于3000的有4人,事务1读取了所有工资大于3000的人,共查到4条记录,这时事务2 又插入了一条工资大于3000的记录,事务1再次读取时查到的记 录就变为了5条,这样就导致了幻读。

 

 

③事务的隔离级别有哪些?

Ⅰ. 读未提交Read Uncommitted:允许读取尚未提交的数据,可能会导致脏读、幻读或不可重复读;

Ⅱ. 读已提交Read Committed:只允许读取已提交的数据,可以解决脏读;

Ⅲ. 可重复读Repeated Read:保证一个事务对同一字段多次读取的结果是一样的,可以解决脏读、不可重复读,但只能部分解决幻读;——MySQL默认的隔离级别

Ⅳ. 串行化Serailizable:多个事务只能按序逐个执行,可以完全解决幻读问题;

 

 

④什么是MVCC?原理是什么?——核心原则:已提交的可见,未提交的不可见

MVCC多版本并发控制,就是通过事务的Read View和记录中的隐藏字段的比对,来控制并发事务访问同一个记录的行为;

具体来说,Read View中有四个重要字段:当前事务id、当前数据库中活跃且未提交的事务列表、列表中的最小事务id、下一个创建的事务的id,

每条记录中有两个隐藏字段:最新修改本条记录的事务id、指向上个版本的记录的指针,

当一个事务访问某条记录时,除了本事务更新的记录总是可见外,

  若记录中的事务id小于列表最小事务id,说明更新这条记录的事务已经提交,故可见;

  若记录中的事务id大于下一个创建的事务id,说明这是在本事务创建之后创建的事务提交的记录,故不可见;

  若记录中的事务id介于两者之间,则如果其位于活跃且未提交的列表中,说明尚未提交,故不可见,反之则可见;

当这条记录不可见时,事务就会继续判断上一版本的记录是否可见,直到找到对当前事务来说可见的版本或是一直找到最初版本;

 

通过在每次读取数据时就生成一个Read View,实现了读已提交;

通过只在事务创建时生成一个Read View并一直沿用到事务结束,实现了可重复读;

 

 

⑤可重复读是如何解决不可重复读问题的?又是如何部分解决幻读的?为什么不能完全解决幻读?

通过MVCC可以解决不可重复读问题;

对于幻读,有两种解决方案:

  对于快照读(普通select语句),MVCC就可以解决部分幻读问题,因为大部分情况下新插入/删除的记录对当前事务来说都是不可见的;

  对于当前读(select ... for update语句,还有增删改操作),因为这些语句执行前都需要查询最新版本的数据,因此不能用MVCC,而是采用了记录锁+间隙锁的方式,对指定范围内的记录上锁,从而部分解决了幻读问题;

 

之所以说只是部分解决了幻读问题,是因为在可重复读隔离级别下仍有可能会发生幻读:

比如事务先通过快照读查询记录,然后别的事务添加了一条满足条件的记录并提交,

之后若当前事务对该记录又执行了更新操作,或是改为执行当前读,就会导致两次查询到的记录条数不一样,发生幻读;

解决办法就是在事务开始后先执行当前读给可能发生幻读的记录范围上锁,避免其他并发事务进行修改,从而从根本上避免幻读;

 

 

 

 

5. 锁

①MySQL中有哪些锁?

Ⅰ. 全局锁:对整个数据库加锁;

Ⅱ. 表级锁:对整张表加锁,实现简单开销小,但锁粒度较大效率低,InnoDB和MyISAM都支持;

Ⅲ. 行级锁:对行记录加锁,行级锁是加在索引上的,锁粒度较小效率高,只有InnoDB支持;

 

行级锁主要有三种:

  记录锁:锁住一条记录,有共享锁和互斥锁之分(又称读锁和写锁),共享锁与共享锁互相兼容,互斥锁与所有锁互斥;——有对应记录才能加记录锁,select就加读锁,禁止写,删改就加写锁,读写都禁止

  间隙锁:只存在于可重复读隔离级别,用于锁住一个范围禁止插入数据,防止幻读的发生,间隙锁可以共存;——没有对应记录就给开区间加间隙锁,因为没有对应记录,所以不存在删改查,防止插入记录即可

  临键锁:是记录锁+间隙锁的组合,对记录本身和范围都加锁;

 

 

②在执行增删改查操作时分别会加何种锁?(默认是可重复读隔离级别)

——锁在事务中生成,事务提交后释放

Ⅰ. 普通select语句不加锁,MVCC就可以解决不可重复读和部分幻读问题;

Ⅱ. select ... for share/update、update、delete会加根据可防止幻读的最小范围(符合条件的最小范围)来加记录锁、间隙锁或是临键锁;

Ⅲ. insert操作不会加锁,但会被其他事务的间隙锁/临键锁阻塞;

 

注意:

因为行级锁是加在索引上的,索引如果执行update操作的where查询条件中的字段没加索引,就会导致全表扫描并给所有记录都上锁;

解决办法除了避免写出这类update语句外,可以将sqlsafe_update参数设置为1,防止无索引执行update语句;

 

 

③数据库层面是否会发生死锁?如何避免?

上面的操作会出现死锁:因为事务A、B先后给(20,30)加了间隙锁,又先后要对(20,30)范围执行insert操作,这就导致两个事务都阻塞并等待对方释放间隙锁,

满足了互斥、不可剥夺、持有等待、循环等待四个条件,发生死锁;

 

解决方法:

Ⅰ. 设置等待锁的超时时间:当一个事务的等待时间超过该值后,就对这个事务进行回滚,于是锁就释放了,另一个事务就可以继续执行了。在 InnoDB 中,参数 innodb_lock_wait_timeout 是用来设置超时时间的,默认值时 50 秒;

Ⅱ. 开启主动死锁检测:主动死锁检测在发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑,默认就开启;

 

 

 

 

6. 日志

①什么是undo log回滚日志?

在事务没有提交之前,MySQL会将每次进行增删改操作之前的数据都记录在undo log中,

当事务需要回滚时,就可以通过undo log来实现,undo log保证了事务的原子性;

 

 

②什么是redo log重做日志?

redo log是InnoDB存储引擎实现的物理日志,记录每次数据页的修改情况并写入内存缓冲区,当缓冲区被占满一半、或是每隔1s、或是当事务提交时,redo log会被持久化到磁盘上,

这样即使系统崩溃,也可以根据redo log将数据恢复到最新状态,redo log保证了事务的持久性;

——可以使用 Forcing InnoDB Recovery 来进行崩溃恢复

 

 

③什么是bin log?

bin log是Server层生成的日志,Server会记录本次事务中执行的所有增删改操作,在事务提交时写入bin log并持久化到磁盘,

主要用于主从复制和数据恢复;

 

bin log和redo log做数据恢复的区别?

bin log是Server层实现的日志,所有存储引擎均可用,是全量操作日志,可以进行全量的数据恢复,

redo log是InnoDB存储引擎实现的日志,有大小限制,会边写边擦除,主要用于处理紧急故障恢复;

 

bin log如何实现主从复制?

将主库的bin log复制到从库,解析为sql语句并执行即可;

 

 

④为什么要两阶段提交?过程是怎样的?

因为redo log会影响主库的数据,bin log会影响从库的数据,而这两种日志都需要刷盘,如果在刷盘时一个成功一个失败,就会导致主从库数据不一致;

两阶段实际上就是将事务的提交分成两阶段:

  首先第一阶段对redo log进行刷盘,成功则进入下一阶段;

  第二阶段会对bin log进行刷盘,如果失败则使用undo log对事务进行回滚,如果成功则完成事务的两阶段提交;

(核心:undo log的回滚可以让redo log中的数据状态也回滚,但无法让binlog中的数据状态回滚,所以redo log先刷盘)

 

 

 

7. MySQL性能优化的手段?数据库高并发怎么办?

①SQL优化,又分为索引优化和SQL语句优化;——详见索引一节

②使用缓存:在服务器和数据库之间加入Redis等缓存,将热点数据存储在缓存中,降低数据库查询压力;

③分库分表:当数据量过大造成SQL语句执行缓慢时,可以通过分表来减小单表数据量,当高并发情况下请求过多单个数据库难以及时处理时,可以通过分库分担并发的请求;

 

④读写分离:让主服务器负责写,从服务器负责读;

⑤使用分布式架构,分散计算压力;

 

 

 

 

 

 

 


 

六、Redis

1.如何保证数据库和redis缓存间的数据一致性?

最经典的方案就是预留缓存模式,具体来说就是:

  在读操作中,先读缓存,缓存未命中则查询数据库,并写入缓存,

  在写操作中,先更新数据库,然后删除缓存;

不过这种方案中的写操作中,如果第二步删除缓存失败,则仍有数据不一致的风险,

为了确保删除缓存成功,可以将删除缓存操作加入到消息队列中,操作成功则从消息队列中移除,如果删除失败则重试,重试达到次数就向业务层报错;

 

为什么写操作不是先删除缓存,然后再更新数据库?

因为在高并发环境下,如果A请求写操作在执行了删除缓存操作后,B请求读操作,缓存未命中从数据库在读取旧数据并写入缓存,

之后A请求完成关系数据库的操作,此时就会出现数据库和缓存不一致的情况;

 

先更新数据库再删除缓存在高并发的情况下不会出现上述问题吗?

有可能A请求读操作先读缓存未命中然后查询数据库得到旧数据,此时B请求写操作更新数据库并删除缓存,之后A请求将旧数据写回缓存,此时数据不一致,

不过这种情况可能性很小,因为A请求已经完成读数据库,其写入缓存的花费的时间很短,不大可能在此期间B请求完成了关系数据库和删除缓存的操作;

 

 

 

 

2. 如何通过Redis实现分布式锁?

参考文章:Redis怎么实现分布式锁?

如何用 Redis 实现分布式锁的?

可以通过Redis的SET命令结合NX、PX参数来实现,

具体来说,SET命令的参数NX作用是只有key不存在才执行SET操作,利用这一功能:

  如果KEY不存在,SET成功,就表示加锁成功;

  如果KEY存在,SET失败,就表示加锁失败;

再通过将键值对的value设为当前客户端的唯一标识,来区别不同客户端之间的锁

最后使用PX参数给锁设置一个过期时间,防止获得锁的客户端异常导致锁无法释放;

 

解锁的过程就是执行del命令删除键值对,为了保证删除锁的客户端是上锁的客户端,需要先通过键值对的value来判断当前客户端是不是上锁的客户端,

为了保证判断操作和删除操作的原子性,可以将其放入Lua脚本中来执行;

 

优点:实现简单,性能高效,能避免单点故障;

缺点:超时时间不好设置,太长会影响性能,太短可能操作还没执行完锁就过期了;

 

 

 

 

3. Redis的数据结构

①五种基本数据结构

Ⅰ. String:简单的key-value型,value可以是各种数据类型,可以用来缓存token、、或是用自增自减命令来计数;

Ⅱ. Hash:value则是一个哈希表,适用于需要存储包含多个属性的对象,且后续需要直接修改对象的某个属性的场景,例如存储用户信息、商品信息等;

Ⅲ. List:value是一个双向链表,支持双向遍历,可以用来存储各种列表,还可以通过lrange来实现高性能分页查询;

Ⅳ. Set:value是一个无序且不可重复的集合,适合用来判断元素是否存在于集合中,或是对集合取交集并集、从集合中随机获取元素,可以用在找共同关注、随机抽奖等场景;

Ⅴ. ZSet:相比Set多了一个权重参数score,可以按照权重对集合元素进行排序,可用于各种排行榜;

 

常用命令:

String:set、get、incr、decr

Hash:hset、hdel、hget

List:lpush/rpush、lpop/rpop、lrange、lset

Set:sadd、srem、srandmember、spop、sunion、sinter

ZSet:zadd、zrem、zrange/zrevrange

 

 

②各数据结构底层实现?

String的底层是SDS简单动态字符串,相比于原生的C语言中的字符串,SDS不仅可以存储文本,还能存储图片音频等二进制数据,,且SDS中有属性len记录长度,获取字符串长度的时间复杂度只有O(1);

Hash的底层是压缩列表和哈希表;

List底层是压缩列表和双向链表;

Set底层是整数集合和哈希表;

Zset底层是压缩列表和跳表;

(以上的压缩列表在Redis7.0都被替换成listpack了,元素小于512个时使用前一种数据结构,大于512个用后一种)

 

 

③Redis的ZSet底层是用什么实现的?为什么用跳表而不用红黑树?

ZSet底层通过跳表实现,跳表是一种单层多指针的双向链表,通过对节点添加多个指针来提高查询效率,

跳表相比红黑树实现简单,查询效率接近,平均时间复杂度都为O(logn),

且跳表的结构相比红黑树更简单,可以减少内存占用,

添加节点时也不会像红黑树那样需要平衡操作;

 

 

 

 

4. Redis持久化机制

①RDB持久化

RDB持久化就是通过创建快照的方式来获得内存中的全部数据在某个时间点的副本,并持久化到磁盘中

这个快照可以用来主从复制或是重启服务器时恢复数据,

RDB持久化时Redis默认的持久化方式,可以在配置中修改每隔多长时间且有多少key被修改时触发RDB持久化;

(手动触发RDB持久化:SAVE会阻塞主线程,BGSAVE在子线程中创建快照,不会阻塞主线程)

 

 

②AOF持久化

AOF持久化相比RDB实时性更好,因为每执行一条增删改命令都会写入AOF文件,并根据不同的刷盘策略每执行一条命令、每隔1s或是由操作系统决定何时刷盘;

默认情况下没有开启AOF,可以通过appendonly yes来开启;

 

AOF重写是什么?

当AOF文件过大时,redis会让子线程通过读取数据库中的键值对,来生成一个新的体积更小但数据库状态和原来的AOF文件一致的新AOF文件,用来替代原来的AOF文件,

在AOF重写期间执行的增量操作会先存放在内存缓冲区中,待重写完毕再写到新AOF文件的末尾;

 

 

③实际生产中用哪种持久化机制?

redis4.0开始支持RDB+AOF混合持久化,在AOF重写时直接将RDB的内容写到AOF文件开头,速度更快,但因为RDB文件存储的是压缩格式,可读性较差;

实际生产中,如果对数据安全性要求不是很高,用RDB就可以了,

如果对数据安全性要求较高,就用RDB+AOF混合持久化;

不推荐单独使用AOF,性能较差;

 

 

 

 

5. Redis线程模型

①为什么单线程的Redis这么快?

Ⅰ. Redis的大部分操作都是内存中的操作,且Redis中使用的数据结构非常高效,大部分操作的时间复杂度都为O(1);

Ⅱ. Redis采用了单线程,避免了多线程情况下频繁的上下文切换;

Ⅲ. Redis采用了非阻塞的IO多路复用机制;

 

具体来说,由于Redis大部分操作都是内存中的操作,且使用的数据结构高效,因此Redis的性能瓶颈不在CPU,而在内存或网络带宽,

正因如此,没有采用多线程的必要,单线程还可以避免频繁的上下文切换和死锁等问题,

除此之外,Redis采用非阻塞的IO多路复用来处理大量客户端的Socket请求,

IO多路复用就是通过让内核同时监听多个Socket,当有连接请求或数据请求到达时,就会交给Redis线程处理,实现单线程对多个IO流的处理;

 

 

 

 

6. Redis内存管理

①Redis对过期数据如何处理?

主要有两种方式,

一种是惰性删除,就是只有在取出key时才对其进行过期检查,这种方式对CPU友好,但会出现大量未删除的过期数据;

第二种是定期删除,就是每隔一段时间旧抽取一批key,对其进行过期检查,删除过期的数据,这种方式对内存友好,但CPU负担较大;

Redis两种方式混用;

 

惰性删除的原理:Redis在访问或是修改key之前,都会先对其进行过期检查,如过期则删除(可以选择同步或异步删除),并返回NULL,未过期则正常进行操作;

定期删除的原理:默认配置下,Redis每隔10s从数据库中随机抽取20个key进行过期检查,

            如过期则删除,如果本轮抽查的过期key比例超过25%也就是5个,则继续抽查,直至比例低于25%或是循环流程的时间达到上限25ms;

 

 

②Redis如果内存溢出怎么办?

当Redis运行内存达到设置的最大运行内存(默认情况下64位操作系统无限制,32位为3G),就会触发内存淘汰机制,主要有8种方式,可以分为三类,

一类是不做内存淘汰,这也是默认的内存淘汰策略;

一类是对设置了过期时间的数据进行淘汰:有random随机淘汰、ttl淘汰过期时间最长的、lru淘汰最近最久未使用的、lfu淘汰最不经常使用;

还有一类对全部数据进行淘汰:有random随机淘汰、lru淘汰最近最久未使用的、lfu淘汰最不经常使用;

 

 

 

 

7. 主从复制、哨兵和集群原理

①主从复制

主从复制就是通过保证主服务器上的数据和从服务器一致,主服务器执行写操作,从服务器执行读操作,实现读写分离,提高系统并发量;

具体来说,主从服务器首次同步时,主服务器生成RDB文件并传给从服务器,从服务器依据RDB文件进行数据的全量复制,

之后主从服务器之间会维护一个长连接,当主服务器收到了写命令时,也会将写命令通过连接传给从服务器,保证主从服务器的数据一致性,

如果从服务器断连一段时间又重新连接,可以根据偏移量将断连期间主服务器执行的写命令传给从服务器,实现增量复制;

 

主从连接的缺点:如果主服务器宕机,只能通过手动选择从服务器来取代主服务器,响应缓慢;

 

 

②哨兵

哨兵机制就是在主从复制的基础上,通过新增几个哨兵节点,专门用于监控主从服务器的运行状态,当主服务器宕机时,自动选出一个从服务器取代主服务器,保证Redis服务的可用性;

具体来说,哨兵节点会每隔一秒就给所有服务器发送PING命令,如果某个服务器超过一段时间未回复,则本哨兵认为此服务器已下线,称为主观下线,

如果认为该服务器主观下线的数量达到一定数量,就认为这个服务器客观下线,此时哨兵节点会选出其中一个哨兵作为leader,来进行接下来的故障转移:

  首先根据每个从节点的优先级、对主服务器数据的复制进度、运行id来选出新的主节点;

  然后令其它从节点都指向这个新的主节点,并通知客户主节点的更换;

  最后当发生故障的原主节点重新上线时,将其转换为从节点指向新的主节点;

 

哨兵的缺点:哨兵和主从复制都只能缓解主服务器的读压力,但在高并发场景下写压力也很大,需要缓解;

 

 

③集群

集群机制就是通过部署多台master,同时对外提供读写服务,让缓存数据库均匀地分布在这些master节点上,客户请求会根据路由规则发送到目标master节点上,缓解了高并发场景下的写压力,

同时每台master都配备了若干salve节点并内置了哨兵机制,在master发生故障时自动完成故障转移;

具体来说,Redis集群采用了哈希槽算法来对缓存数据库分配给各master节点,总共有2^14=16384个哈希槽,通过对key值计算CRC-16校验码,再对16384取模,将其分配到其中一个哈希槽,

再根据哈希槽和master节点的对应关系寻址到对应的master节点进行操作;——哈希槽算法的优点是当新增或删除节点时,只需要改变哈希槽和master节点的对应更新即可,key值和哈希槽的对应关系保持不变;

 

Redis集群在的各节点也会基于Gossip协议进行通信并维护一份集群内各节点的状态信息,比如发送MEET消息可以将新节点加入到集群中,PING/PONG消息可以用来检查各节点的状态;

 

Redis集群在扩容缩容期间也仍可以提供服务,Redis集群提供了重定向机制,对于在此期间发送的请求,

  如果发现key值对应的哈希槽正在迁移中,则返回ASK重定向错误,待哈希槽完成迁移继续发给原先的master节点;

  如果发现key值对应的哈希槽已经迁移到其它master节点了,则会返回MOVED重定向错误,告知哈希槽所在的新master节点,后续查询发送到新master节点;

 

为什么是16384个哈希槽?

首先节点通信时发送的心跳数据包中会携带当前节点与所有哈希槽的映射关系,采用16384个哈希槽需要占空间2KB,如果增加哈希槽数量会使得心跳包过大;

其次Redis集群中通常master不太可能超过1000个,16384个哈希槽已经完全够用了;

 

 

 

 

8. Redis生产问题

①大key

如果一个key对应的value较大,比如String类型超过10kb,其他类型元素数量超过5000个,就认为是大key;

大key会消耗过多的内存和带宽,影响整体性能,

可以利用一些开源工具对RDB文件进行分析找到大key,通过分批次删除元素并最终删除key值;

 

 

②热key

如果一个key在一段时间内的访问次数明显高于其它key,就认为它是热key,

热key会占用大量CPU资源和带宽,影响整体性能,

可以利用redis自带的--hotkeys参数或是借助开源软件来找到热key,

处理热key可以使用二级缓存,添加一层本地缓存,缓解Redis缓存的压力;

 

 

③缓存穿透、缓存击穿、缓存雪崩

Ⅰ. 缓存穿透:大量key值不存在于缓存和数据库中的请求,导致全部请求都落在了数据库上,导致数据库短时间内接收大量请求而崩溃;

解决办法:在将请求传给数据库之前,先通过布隆过滤器判断查询的key值是否存在于数据库中,不存在的请求直接丢弃;

      还可以通过对无效key进行缓存并设置较短的过期数据来解决;

 

Ⅱ. 缓存击穿:高并发情况下访问某个热key的大量请求没有在缓存种找到key值,全部落在了数据库上,导致数据库崩溃;

解决办法:针对热点数据提前进行缓存预热,并设置足够长的过期时间;

 

Ⅲ. 缓存雪崩:缓存服务器宕机,或是同一时间内大量缓存过期,导致大量请求全部落在了数据库上,导致数据库崩溃;

解决办法:使用Redis集群保证Redis服务的高可用性,限流防止过量请求访问;

 

布隆过滤器的原理?

布隆过滤器可以用O(1)的时间复杂度判断元素是否存在,其底层就是一个位数组和若干个哈希函数,当有元素加入时,就通过这几个哈希函数计算元素的哈希值,并将位数组的对应位置修改为1,

当需要判断元素是否存在时,同样通过这几个哈希函数计算元素的哈希值,得到对应位置位数组的值并做与运算,如果均为1则认为此元素存在,反之认为不存在,

布隆过滤器的特点是判断元素存在可能失误,因为不同哈希值可能映射到数组中的位置相同,使用多个哈希函数也是为了降低误判概率,但判断元素不存在一定不可能失误,因为相同哈希值映射到数组中的位置一定相同;

 

 

 

 

 

 


 

七、Spring

1. IOC(DI和IoC是同一个东西)

①什么是IoC?

IoC控制反转是一种思想,即将对象创建和管理的权力交给IoC容器,用户在需要使用对象时直接向容器索取即可,容器会完成对象的注入,

这样做的好处就是用户不需要关注如何使用各种实现类的各种构造器来构造自己需要的对象,直接使用创建好的对象根据接口调用方法即可;

 

 

②@Autowired和@Resource的区别

Ⅰ. @Autowired来自Spring框架,@Resource来自JDK;

Ⅱ. @Autowired支持属性、setter方法和构造器注入,@Resource只支持属性、setter方法;

Ⅲ. @Autowired默认采用byName的方式查找依赖,@Resource默认采用byType的方式查找依赖;

Ⅳ. 对于有多个实现类的接口的依赖注入,@Autowired可以通过@Qualifier注解的value属性来指定名称,@Resource可以通过其name属性来指定名称;

 

 

③说一说Spring IoC实现的原理

IoC/DI的实现可以主要分成两个阶段,第一阶段完成容器的创建和Bean的加载和注册,第二阶段完成Bean的初始化;

在第一阶段中,

  首先创建一个BeanFctory的实现类的实例对象作为容器,这个容器中包含一个以Bean id为key、以BeanDefinition为value的ConcurrentHashMap,叫beanDefinitionMap,用于存放当前容器中注册的全部Bean的信息,还有一个按注册顺序存储Bean id的ArrayList,叫beanDefinitionNames;

  然后读取并解析xml配置文件中各个Bean的信息,并以BeanDefinition的形式保存,并和Bean id 一起注册到容器中的集合和哈希表中;

在第二阶段中,主要完成对单例、非懒加载的Bean的初始化,具体来说,

  在容器中有一个以Bean id 为key、以Bean实例对象为value的ConcurrentHashMap,叫singletonObjects,这个哈希表专门用于存放单例对象,在进行Bean初始化时会先查询这个哈希表,

    若对象存在则直接返回,

    若不存在则通过反射获取运行时类的构造器,然后使用构造器创建对象并与其id一起存入singletonObjects中;

后面使用geyBean()方法获取实例对象时,也是先查询singletonObjects;

 

 

BeanFactory:顾名思义,其实就是一个创建和管理Bean的工厂接口,其中包括通过各种参数获取Bean的getBean()方法、判断Bean类型的各种isXxx()方法等:

BeanDefinition:BeanDefinition是一个定义了存储bean标签中各属性值的接口,比如这个 Bean 指向的是哪个类、是否是单例的、是否懒加载、这个 Bean 依赖了哪些 Bean 等等;

 

 

 

 

2. AOP

①什么是AOP?

AOP面向切面编程就是将那些与业务代码无关,但却被业务模块共同调用的逻辑(比如日志管理、权限控制等封装起来),

这样做的好处是可以减少系统的重复代码,降低模块间的耦合度;

Spring AOP是基于动态代理实现的,

  对于有接口的对象,可以使用JDK Proxy来代理;

  对于没有接口的对象,可以使用Cglib来代理;

  还可以使用AspectJ框架;

 

 

②Spring AOP 和 AspectJ AOP 有什么区别?

Spring AOP 属于运行时增强,而 AspectJ 是编译时增强;

Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation);

AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单;

如果我们的切面比较少,那么两者性能差异不大,当切面太多的话,最好选择 AspectJ ,它比 Spring AOP 快很多;

 

 

 

 

3. Spring MVC

①什么是Spring MVC?

MVC是模型model、视图view、控制器controller的简写,其核心思想是通过将数据、显示、业务逻辑分离来组织代码;

Spring MVC中通常将后端项目分为controller控制层,负责返回数据给前端页面、service层处理业务、dao层对数据库进行操作、entity层实体类;

 

 

②Spring MVC的原理?/Spring MVC有哪些核心组件,又是如何作用的?

Ⅰ. DispatcherServlet:Spring MVC的核心处理器,负责接收、分发请求和响应客户端;

Ⅱ. HandlerMapping:根据请求的url去匹配能处理的Handler,并将请求涉及到的拦截器Interceptor和Handler一起封装;

Ⅲ. HandlerAdapter:负责调用上面匹配到的Handler;

Ⅳ. Handler:就是我们常用的Controller,负责处理请求,并返回ModelAndView(数据和视图信息)给Ⅰ;

Ⅴ. ViewResolver:根据ModelAndView中的视图名查找实际的View,并传递给Ⅰ,进行渲染后响应客户端;

 

 

 

 

4. SpringBoot

①Spring和SpringBoot的区别?

Spring Boot是基于Spring框架的一个开发工具,它简化了Spring应用程序的配置和部署过程;

 

 

②Spring MVC和SpringBoot的区别?

Ⅰ. 配置方式:Spring MVC需要通过XML文件或Java配置类进行显式的配置,包括配置控制器、视图解析器、拦截器等;

而Spring Boot采用约定大于配置的原则,提供了自动配置功能,可以根据应用程序的依赖和配置文件中的设置,自动完成大部分配置工作,简化了开发者的配置过程;

Ⅱ. 依赖管理:Spring MVC需要开发者手动管理依赖的版本和冲突;

而Spring Boot使用了一个叫做"Starter"的概念,它提供了一组预定义的依赖,可以通过简单的引入Starter来管理依赖,减少了开发者的工作量;

 

 

③什么是SpringBoot的自动装配?原理是什么?

——参考文章:springboot自动装配

自动装配指的是在引入starter后,只需要通过少量注解和配置就可以使用第三方组件提供的功能了;

原理:

SpringBoot的启动类的注解@SpringBootApplication中包含开启自动装配的注解@EnableAutoConfiguration,这个注解主要通过一个ImportSelector类实现功能,这个类有两个核心方法

一个是process()方法:作用是读取所有starter的META-INF目录下的spring.factories文件中的所有自动配置类(除了@EnableAutoConfiguration注解中通过exclude参数排除掉的那些配置类),

还有一个是selectImports()方法:作用是根据每个配置类通过@Conditional注解设置的加载条件,有选择地加载那些满足条件的自动配置类的Bean到容器中;

 

 

④SpringBoot在开发RESTful Web服务时常用注解有哪些?

RESTful是一种开发风格,特点是url地址风格统一,使用斜杠分开,不使用问号键值对方式携带参数;

Ⅰ. Bean相关

注册Bean:@Component(衍生的@Controller、@Service、@Repository)、@Bean;

依赖注入/获取Bean:@Autowired、@Resource;

 

Ⅱ. 处理请求

@RequestMapping:处理各种请求;

@GetMapping:处理get请求,一般是从数据库获取数据;

@PostMapping:处理post请求,一般是向数据库添加数据;

@PutMapping:处理put请求,一般是更新数据库的数据;

@DeleteMapping:处理delete请求,一般是删除数据库中的数据;

——上述注解都可以加在类或者方法上

 

Ⅲ. 前后端传值

前端传后端:

  @PathVariable:获取请求路径中以大括号形式传递的参数;

  @RequestParam:获取请求路径中以“?xx=xx”问号键值对形式传递的参数;

  @RequestBody:获取JSON/XML格式的请求体并绑转换为java对象;

后端传前端:

  @ResponseBody:将它所标注的函数的返回值直接填入Http响应体中,基本是必加的注解,通常以@RestController的形式加在Controller类上;(@RestController = @ResponseBody + @Controller)

 

 

示例:

@PathVariable:

    @GetMapping("pre_chapter_id/{chapterId}")
    public RestResp<Long> getPreChapterId(@PathVariable Long chapterId){
        return bookService.getPreChapterId(chapterId);
    }
    @GetMapping("pre_chapter_id/{id}")
    //传入参数与url路径中占位符名称不同时,可以在@PathVariable注解中显示表明映射关系
    public RestResp<Long> getPreChapterId(@PathVariable("id") Long chapterId){
        return bookService.getPreChapterId(chapterId);
    }

请求url格式:/pre_chapter_id/1334318186313654272 

 

@RequestParam:

    @GetMapping("comment/newest_list")
    public RestResp<BookCommentRespDto> listNewestComments(@RequestParam(value = "type", required = false, defaultValue = 114514)Long bookId){
        return bookService.listNewestComments(bookId);
    }

请求 url 格式:/comment/newest_list?bookId=1334318182169681920

 

@RequestBody:

    @PostMapping("comment")
    public RestResp<Void> comment(@Valid @RequestBody UserCommentReqDto userCommentReqDto){
        userCommentReqDto.setUserId(UserHolder.getUserId());
        return userService.saveComment(userCommentReqDto);
    }

请求 url 格式:/comment

JSON格式请求体:

{
  "bookId": 1337958745514233856,
  "commentContent": "特别喜欢这本书,作者速速更新"
}

 

 

 

 

 


 

八、MyBatis

1. MyBatis传参的两种方式——${}和#{}有什么区别?

参考文章:#{}和${}的区别

首先,#{}本质上是占位符赋值,对字符串或日期类型进行赋值时可以不用手动加单引号,而${}本质上是字符串拼接,对字符串或日期类型进行赋值时需要手动加单引号;

其次,#{}可以防止sql注入,${}则不能;

 

什么是sql注入?

简而言之就是通过传参改变sql语句原本规则的操作,${}因为是通过字符串拼接实现的,因此可能会导致sql注入

 

 

 

 

2. Mapper接口/DAO接口的工作原理是什么?为什么没有实现类也能实现功能?

mapper接口的工作原理是jdk动态代理,MyBatis在运行时会为mapper接口生成代理对象,代理对象会拦截接口方法,

转而去执行mapper接口对应的xml文件中的sql语句,并返回执行结果;

 

 

 

 

3. 如何使用MyBatisPlus实现分页功能?原理是什么?

①如何实现

在MyBatisPlus的配置类中添加分页拦截器,

然后使用页面大小和当前页数作为参数创建一个Page对象,将这个对象同查询条件一起传入BaseMapper提供的slectPage()方法即可实现分页条件查询,查询结果也会以Page的形式返回;

 

 

②原理

参考文章:Mybatis-plus分页原理

手撕MybatisPlus分页原理

首先MyBatisPlus的分页拦截器会拦截所有sql语句的执行,对sql语句进行解析并判断传入的参数中是否有Page分页对象,

  如果没有则直接放行;

  如果有则根据Page参数中的当前页数与页面大小给sql语句拼接上limit分页语句;

 

拦截器的原理?

拦截器本质上是AOP的应用之一,底层基于java反射实现,主要用于在一个方法执行前后进行一系列操作,

例如常见的HandlerInterceptor接口,实现此接口可以根据url对请求进行拦截,并在请求执行前、执行后、请求处理完毕时进行一系列操作,可以用于权限验证、日志记录、性能监控等场景;

 

 

 

 

4. MyBatis-Plus相比MyBatis有什么区别?

①提供了BaseMapper接口,只需要继承此接口,就可以不用编写SQL语句,直接调用其中的方法来执行一些基本的CURD操作;

②提供了分页插件,只需要使用selectPage方法并以Page来传入分页相关参数,就可以实现分页查询;

③提供了@TableName、@TableId这样的注解,更方便实现实体类与数据库表的映射;

④mybatis-plus只是mybatis的增强工具,引入mybatis-plus不影响mybatis的正常使用,对于BaseMapper中不包含的复杂操作,仍可以通过手动编写Mapper接口的方法和xml文件中的sql语句来实现;

 

 

 

 

 


 

九、操作系统(目前只对进程调度、虚拟内存等重要概念有了抽象的理解,有时间通过实操深入理解Linux操作系统)

1. 内存管理

①堆和栈的区别?

Ⅰ. 栈是操作系统自动分配的一段连续内存区域,计算机在底层为栈分配了专门的寄存器,栈操作也有专门指令,速度非常快且不会产生内存碎片,堆是用户自己申请的非连续内存区域,堆的操作是由C/C++的函数库提供,速度较慢且可能会产生内存碎片;

Ⅱ. 栈向低地址方向增长,大小在编译时就确定了,堆向高地址方向增长,大小可以在运行时动态确定;

Ⅲ. 栈通常用来临时存储函数内部的数据,生命周期是其所属函数的执行时间,堆通常用来存储大小不确定、跨函数的数据,生命周期可以自行决定;

 

 

②内存碎片中的内部碎片和外部碎片有什么区别?

内部碎片:指分配给程序的内存没有被利用上的部分,通常出现在对内存采用固定分配的情况下,由于内存被分为若干个固定大小的分区,有些分区的内存无法被完全利用,就产生了内部碎片;

外部碎片:指有些较小的内存区域无法被分配给程序,通常出现在对内存采用动态分配的情况下,由于根据程序运行所需内存动态分配,所以不会出现内存碎片了,但可能会出现一些较小的内存区域无法被利用上,就产生了外部碎片;

 

 

③什么是虚拟内存?

虚拟内存就是通过将物理内存抽象为虚拟内存,每个进程都有各自的虚拟内存空间,

这样做一方面可以让各个进程内存空间独立,避免多个进程之间的地址冲突;

另一方面,由于虚拟内存的页无需全部在物理内存中,当访问到不在物理内存中的页时,通过页面置换将其从硬盘中加载到内存中即可,可以让程序获得更多的可用内存;

 

虚拟内存最大是多少?

虚拟存储器的实际容量=min(内存外存容量之和,CPU寻址范围);

 

 

④内存置换算法有哪些?

Ⅰ. 最佳置换算法OPT:优先替换那些在未来最长时间内不会被用到的页面,不过这是一种理想中的算法,实际无法预知未来;

Ⅱ. 先进先出算法FIFO:每次淘汰最早进入内存的页面,没有考虑到局部性原理,性能较差一般不会使用;

Ⅲ. 最近最久未使用算法LRU:每次淘汰那些最近最久未使用的页面,性能较高,但需要硬件支持开销较大;(这里可以联系到手写带TTL的LRU算法)

Ⅳ. 时钟算法CLOCK:为每个页面设置一个默认为0访问位,当页面被访问时将访问位置为1,并将全部页面组成一个循环队列,当需要进行页面置换时,

  第一轮扫描寻找访问位为0的页面进行置换,并将扫描到的页面置为访问位0,

  没找到则进行第二轮扫描,

  算法开销较小,但未考虑到页面是否被修改;

Ⅴ. 改进的时钟算法:在时钟算法的基础上,为每个页面添加一个默认为0的修改位,当被修改时置为1,当需要进行页面置换时,

  第一轮寻找访问位、修改位均为0的页面,

  没找到则第二轮寻找访问位为0,修改位为1的页面,同时将扫描到的页面置为访问位0,

  没找到则第三轮寻找访问位、修改位均为0的页面,

  没找到则第四轮寻找访问位为0,修改位为1的页面,

  算法开销较小,性能也不错;

 

什么是局部性原理?

局部性原理又分为时间局部性和空间局部性,

时间局部性是指如果一个数据被访问了,那么不久之后这个数据很有可能再次被访问,原因是因为程序中常常存在大量循环;

空间局部性时指如果一块内存区域被访问了,那么与其相邻的内存区域很有可能也会被访问,因为很多数据在内存中都是连续存放的;

例如TLB快表和高速缓冲技术都利用了时间局部性原理;

 

 

⑤分页和分段的区别?

首先二者都是操作系统用来管理虚拟地址与物理地址之间的关系的方式;

分页是将虚拟内存和物理内存都切分成一个个固定大小的空间,在Linux中页大小为4KB,虚拟内存中称为页,物理内存中称为页框,页与页框的映射关系存储在页表中,虚拟地址中包含页号和页内偏移量,查询页表即可知页框号,与页内偏移量拼接即可得物理地址,分页可能会产生内部碎片;

分段是将虚拟内存和物理内存按照程序实际需要分成一个个大小不一的内存空间,称为段,段的虚拟内存和物理内存的映射关系存储在段表中,与页表不同的是段表还存储了段长字段,在查询段的物理地址前还需要先判断是否越界,分段可能会产生外部碎片;

在操作系统中通常分页和分段是组合使用的,先将程序分为多个有逻辑意义的段,再将每个段分成若干个固定大小的页;

分页对用户不可见,是操作系统的行为,主要为了消除外部碎片,提高内存利用率,分段对用户可见,主要为了满足用户需要;

 

解决外部碎片的方式?

内存交换,即通过将内存先写入硬盘再写回内存的方式,将原本不连续的内存段拼接到连续的内存区域,填满外部碎片;

 

 

⑥大端存储和小端存储

大端存储就是将数据在内存中按从高地址到低地址逆序存放,

小端存储就是将数据在农场主按从低地址到高地址顺序存放,操作系统默认按照小端存储;

其应用例如MySQL的行记录中的NULL值列表和变长字段列表就是使用大端存储的,这样做是因为列表之后就是真实数据,这样一来考前的真实数据就更有可能和自己的列表信息在在同一个CPU Cache Line中,提高Cache命中率;

 

 

 

 

2. 进程管理

①进程、线程协程的区别与联系

进程是运行起来的可执行程序,是资源分配的单位,进程切换开销很大,包括虚拟地址空间和内核堆栈等的切换,

一个进程可以包含多个线程,线程是程序执行的基本单位,线程切换的开销很小,只需要切换线程私有数据和程序计数器等少量寄存器即可,

协程是用户态的轻量级线程,是线程内部调度的基本单位,特点是同一时间只有一个协程运行无法并行,且切换时无需进入内核态,因此切换速度非常快;

 

 

②进程的状态有哪些?每个阶段都做了什么

①创建态:此阶段进程被创建,系统为进程分配资源,创建进程控制块PCB(进程存在的唯一标志,存放在进程id、所属用户uid、进程当前状态等信息);

②就绪态:当进程完成创建后就进入就绪态,等待CPU资源;

③运行态:就绪态的进程分配到CPU资源时就会进入运行态,执行该进程对应的程序/指令;

④阻塞态:进程运行过程中可能需要等待某个事件的发生,例如等待某个资源的分配、或是其他进程的响应,此时进程就会进入阻塞态;

⑤终止态:进程执行完毕,被收回各种资源;

 

 

③进程调度算法有哪些?

——页面置换算法的目标是让被访问的页面尽可能在内存中,进程调度的算法是尽量根据优先级、等待时间、响应时间等综合考虑

Ⅰ. 先来先服务算法FCFS:调度时选择最先请求的进程,非抢占式,对长作业有利,对短作业不利;

Ⅱ. 短作业优先算法SJF:调度时选择预估执行时间最短的进程,有抢占式和非抢占式,抢占式就是当前运行的进程可能被其他进程抢走CPU资源,对短作业有利,对长作业不利;

Ⅲ. 高响应比优先:按照(等待时间+要求服务时间)/要求服务时间计算每个的响应体,调度时选择响应比最高的进程,非抢占式,综合考虑了等待时间和要求服务时间两个因素;

Ⅳ. 时间片轮转算法:按照进程请求的顺序,轮流让每个进程执行一个固定时长的时间片,时间片用完就换下一个进程,抢占式算法,优点是响应时间快,缺点是频繁切换开销较大;

Ⅴ. 优先级调度算法:调度时选择优先级最高的进程,抢占式算法,可以灵活调整各种进程的优先级,例如提高等待了很长时间的进程的优先级,降低占用CPU资源很长时间的进程的优先级; 

Ⅵ. 多级反馈队列算法:建立多个优先级、时间片不同的队列,优先级越高的队列时间片越短,进程到达时先进入优先级最高时间片最短的队列,如果未完成则进入优先级更低的队列,只有高优先级队列中的作业执行完,才会去执行低优先级的队列,多级反馈队列算法是时间片轮转算法和优先级调度算法的结合;

 

 

④信号量解决进程同步问题

信号量:操作系统提供的用于表示系统资源数量的变量,可以使用不可中断的原语P、V操作来操作信号量,实现进程同步;

哲学家进餐问题的解决方法:有两种思路,

  一种是根据资源数量计算不可能发生死锁的情况下允许同时进餐的哲学家的最大数量,例如有6只筷子,那么只允许5名哲学家同时进餐就可以保证不发生死锁;

  另一种是保证拿两只筷子的操作是互斥的,同一时间只允许一个哲学家进行拿筷子的操作;

 

 

 

 

3. 网络系统

参考文章:浅谈多路复用与Reactor模式

https://xiaolincoding.com/os/8_network_system/reactor.html#%E6%BC%94%E8%BF%9B

①IO多路复用

IO多路复用就是通过使用select/poll/epoll系统调用,一个线程可以监控多个连接,并从内核中获取其中发生事件的连接然后进行处理,从而实现一个线程管理多个连接;

具体来说,select/poll的原理是使用线性结构存放要监控的连接,然后将集合拷贝到内核中,内核通过遍历集合将那些有事件发生的连接做标记,再将集合拷贝回用户态,用户态再次遍历集合找出那些发生事件的连接,返回给线程;(两次拷贝两次遍历)

epoll的原理是在内核中维护一棵红黑树来存放要监控的连接,减少了数据拷贝的开支,除此之外还在内核中使用一个链表来存放发生事件的连接,不需要通过重复遍历来找出发生事件的连接;(不需要拷贝,一次遍历)

 

 

②Reactor模式

         

Reactor是一种同步非阻塞网络模式,IO多路复用负责监听连接和获取发生事件的连接,Reactor则是负责在收到事件后,根据事件类型分配给对应的模块进行处理;

Reactor有三种经典的方案:

  Ⅰ. 单Reactor单线程:主要分为Reactor、Acceptor、Handler三部分,Reactor负责接收和分发事件,Acceptor负责处理建立连接的事件,Handler负责处理其他事件例如读写操作等;

  ——这种方案的主要缺点在于不能充分利用多核CPU的性能,且如果某个事件的处理占用线程时间过长,会减慢对其他事件的响应;

  Ⅱ. 单Reacotr多线程:主线程管理Reactor、Acceptor、Handler,但区别在于此时Handler不再负责处理任务,而是将任务交给线程池中的某个子线程处理,Handler只负责转发数据;

  ——充分利用了多核CPU的性能,且即使出现某个耗时长的事件,也不会影响对其他事件的响应速度,此时由一个主线程管理的单个Reactor成为了性能瓶颈;

  Ⅲ. 多Reactor多线程:主线程只管理一个主Reactor和Acceptor,负责处理连接事件和将连接分配给一个子线程(不再负责分发事件,而是改为分配连接),每个子线程都有自己的副Reactor和Handler,会继续监听连接并当发生事件时分发给自己的Handler来处理事件;

 

 

阻塞IO和非阻塞IO?——阻塞和非阻塞IO强调准备数据阶段线程是否会被阻塞

 

                                   

阻塞IO:当线程发起read调用,如果内核没有准备好数据,线程会进入阻塞状态,直至内核准备好数据,再根据同步还是异步来决定由谁将数据拷贝到用户态;

非阻塞IO:当线程发起read调用,如果内核没有准备好数据,线程不会进入阻塞状态,而是会去执行其他操作,同时以轮询的方式向内核发起read调用,待内核准备好数据,再根据同步还是异步来决定由谁将数据拷贝到用户态;

 

 

同步和异步IO?——同步和异步强调拷贝数据的操作是需要线程自己执行,还是内核完成

同步IO:指将数据从内核态拷贝到用户态这一操作,需要线程来执行;

异步IO:指将数据从内核态拷贝到用户态这一操作会由内核自己执行,无需线程参与,线程只需要处理拷贝好的数据就行了;

(同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。异步:调用在发出之后,不用等待返回结果,该调用直接返回;)

 

 

 

 

 

 

 


 

十、计算机网络

1. 基础

①OSI七层模型

物理层:在物理介质上进行数据传输;

数据链路层:将网络层的数据包组装成帧,添加MAC地址等信息——包含ARP协议(根据IP地址获取MAC地址);

网络层:负责设备间的数据传输,实现寻址和路由选择——包含IP协议;

传输层:负责端口间的数据传输,实现流量控制、拥塞控制等功能——包含TCP、UDP等协议;

会话层:管理主机间的会话进程;

表示层:对数据的表示进行操作,例如压缩和加密等;

应用层:具体的网络应用——包含HTTP、DNS等协议;

 

TCP/IP模型将会话层、表示层、应用层合并为一个应用层,将物理层和链路层合并成一个网络接口层,同时网络层称为网际层;

 

 

②在浏览器地址栏输入一个URL后回车,背后会进行哪些步骤?

Ⅰ. 浏览器解析URL,根据Web服务器名称、文件名生成HTTP报文;

Ⅱ. 根据域名查询DNS服务器得到IP地址:主机会向本地域名服务器发出请求,本地域名服务器首先查看缓存,若缓存中没有,则查询其他更高级的域名服务器直至获取IP地址;——应用层

Ⅲ. 三次握手建立TCP连接,给HTTP报文加上包含端口号的TCP头部,生成TCP报文;——传输层

Ⅳ. 给TCP报文加上包含IP地址的IP头部,生成IP报文——网络层

Ⅴ. 给IP报文加上包含MAC地址的MAC头部,生成MAC报文——数据链路层

Ⅵ. 将MAC报文经过网卡、交换机、路由器传输到目标服务器;

Ⅶ. 服务器解析报文,将网页信息html代码封装在HTTP响应报文中,经过一系列包装发送给客户端;

Ⅷ. 客户端解析html代码并请求html代码中的资源(js、css、图片等),浏览器对页面进行渲染呈现给用户;

 

交换机与路由器的区别?

交换机是数据链路层的设备,可以连接同一个子网中不同的计算机,根据MAC地址进行转发;

路由器是网络层的设备,用于连接不同子网,根据IP地址进行转发;

 

 

 

 

2. HTTP

①HTTP

Ⅰ. 什么是HTTP?

HTTP 是超文本传输协议,也就是HyperText Transfer Protocol,可以传输包含文章、图片、视频等超文本数据;

 

Ⅱ. HTTP常见状态码

1XX:信息性状态码,表示请求正在处理;

2XX:成功状态码,表示请求正常处理完毕,例如200 OK一切正常;

3XX:重定向状态码,表示资源位置发生变动,需要客户端重新发送请求,例如304 NOT MODIFIED 资源未修改,客户端可继续使用缓存;

4XX:客户端错误状态码,表示请求报文有误,服务器无法处理,例如404 NOT FOUND 资源未找到;

5XX:服务器错误状态码,表示服务器在处理请求时内部发生错误,例如500 服务器错误;

 

Ⅲ. HTTP报文常见字段

Host:服务器域名;

Content-Length:回应数据长度;

Connection:连接类型,例如Keep-Alive长连接,保持TCP连接直至其中一方主动断开连接,Close短连接,每次请求都需要重新建立TCP连接;

 

Ⅳ. GET和POST的区别?

GET请求用于从服务器获取数据,是幂等的(多次操作结果相同),默认缓存;

POST请求用于新增或提交数据,是非幂等的(多次操作结果不同),默认不缓存;

 

POST比GET安全吗?

虽然GET请求的数据都在地址栏可见,POST请求的数据在地址栏不可见,存在request body中,

但在HTTP协议中POST和GET都是明文传输,通过抓包都可以获得,所以都不安全,要提高安全性应当使用HTTPS;

 

Ⅴ. HTTP的缺点

明文通信,不安全;

不验证通信方身份,可能遭遇伪装;

无法验证报文完整性,报文可能被篡改;

 

 

②HTTPS

Ⅰ. 什么是HTTPS?

HTTPS通过在HTTP协议和TCP协议层间加入SSL/TLS协议,解决了HTTP协议明文通信、无法完整通信方身份和报文完整的问题,具体来说:

通过混合加密的方式对报文进行加密,解决了明文通信的问题;

通过将服务器公钥放入到数字证书中,对通信方身份进行验证;

通过摘要算法校验报文的完整性;

HTTPS默认使用443端口,HTTP是80 端口;

 

Ⅱ. HTTPS建立连接的过程?如何保证数据传输的安全性的?——有点复杂,暂时不看

 

 

 

 

③HTTP1.1/2.0/3.0的区别?

HTTP1.1:

  基于HTTP,使用长连接的方式改善了HTTP1.0短连接造成的的开销,

  使用管道传输请求,下一个请求的发送不必等待上一个请求的响应,

  缺点是服务器按请求顺序响应,如果某个请求处理数据较长,可能会导致后续请求迟迟得不到响应,发生队头堵塞,

  且请求只能由客户端发送,服务器只能被动响应;

HTTP2.0:

  基于HTTPS,安全性有保障,

  多个stream复用一个TCP连接,实现了并发传输,解决了HTTP层面的队头阻塞问题,

  服务器可以主动向客户端推送消息,

  缺点是因为多个stream共用一个TCP连接,当某个请求发生丢包时,TCP协议要求重传,此时共用这个TCP连接的所有HTTP请求都必须等待重传——TCP层面的队头阻塞;

HTTP3.0:

  引入基于UDP的QUIC协议,可以实现可靠传输,且当某个请求发生丢包时,只会阻塞那一个stream,彻底解决了队头阻塞问题;

 

 

 

 

3. TCP/UDP

①TCP建立连接的三次握手

初始客户端处于CLOSE状态,服务端处于LISTEN状态,

第一次握手:客户端先发出SYN建立连接请求,携带一个随机的初始序列号,

第二次握手:服务端在收到客户端的SYN报文后,会给客户端发送一个SYN+ACK报文,携带等于客户端序列号+1的确认应答号、和自己的初始序列号;

第三次握手:客户端在收到服务端报文后,进入ESTABLISHED状态,并且会给服务端再发送一个ACK报文,携带等于服务端序列号+1的确认应答号,服务端收到后也会进入ESTABLISHED状态(这一次的报文是可以携带数据的,全两次都不可以);

 

为什么是三次握手?可以更少吗?

首先,为了阻止重复历史连接的建立,至少需要三次握手,如果只有两次,服务端在收到SYN报文后就进入ESTABLISHED状态建立连接,会导致那些因网络延迟而重发的多个ACK报文重复建立连接,浪费资源;

其次,为了同步客户端和服务端的序列号,需要两次一来一回的报文,中间的两个报文合并,至少需要三次握手;

 

 

②TCP断开连接的四次挥手

初始客户端和服务端都处于ESTABLISHED状态,

第一次挥手:客户端发送一个FIN报文,

第二次挥手:服务端收到报文后,发送一个ACK应答报文,服务端进入CLOSE_WAIT状态,

此时TCP进入半关闭状态,客户端到服务端的连接已经释放,但服务端到客户端的连接还未释放,服务端仍可以进行数据的发送和处理,

第三次挥手:服务端发送一个FIN报文,

第四次挥手:客户端收到报文后,发送一个ACK应答报文,进入TIME_WAIT状态,在经过2MSL时间后方可进入CLOSE状态,服务端在收到ACK报文之后就进入CLOSE状态;

 

为什么是四次挥手?三次行吗?

如果服务端在收到客户端的FIN报文后没有数据要传输,且开启了TCP延迟确认,就会合并服务端的ACK报文和FIN报文,变成三次挥手;

TCP延迟确认:当需要发送ACK响应报文时,会等待一段时间看有没有数据需要传输,如果有就会将数据携带在响应报文中一同传输;

 

为什么需要TIME_WAIT状态?为什么等待时间是2MSL?

首先是为了防止历史连接中延迟传输的报文被后续连接接收,因为TCP中序列号是循环的,先建立的连接中的报文的序列号可能会与后建立的连接中报文的序列号相同,可能会导致新连接错误接收旧连接中延迟的报文,因此需要在断开连接时,设置TIME_WAIT状态等待2MSL确保本次连接中所有报文均已送达或被丢弃;

——MSL(Maximum Segment Lifetime)是一个报文在网络中存在的最长时间,超过这个时间的报文将被丢弃(Linux中默认30s),

其次是为了确保客户端的最后一个ACK报文成功送达服务端,如果ACK报文发送失败,服务端会重发一个FIN报文,这样一来一回最多2MSL,此时客户端就会重置等待时间,并重发一个ACK报文;

 

服务器出现大量TIME_WAIT的原因有哪些?

首先,TIME_WAIT状态是主动关闭连接的一方才会出现的状态,因此服务器中出现大量TIME_WAIT状态说明服务器主动断开了大量TCP连接,

出现这一情况的原因有:

  服务端或客户端没有开启HTTP的Keep-Alive长连接;

  HTTP长连接超时;

  HTTP长连接的请求数量达到上限;

 

服务器出现大量CLOSE_WAIT状态的原因有哪些?

首先,CLOSE_WAIT是被动关闭方才会有的状态,被动关闭方在收到主动关闭方的FIN报文并发送应答报文后,在被动关闭方发送FIN报文前都会处于CLOSE_WAIT状态,

因此服务器中出现大量CLOSE_WAIT状态的原因可能是:

  服务器内部业务处理占用过长时间,迟迟没有处理完;

  服务器的业务逻辑有问题,没有调用close()方法关闭连接;

 

 

③TCP如何保证可靠传输?

Ⅰ. 重传机制

超时重传:TCP的发送方在规定时间内(这一事件是动态改变的,一般略大于报文往返时间RTT)没有收到应答方的ACK报文时,就会重新发送报文;

快速重传:TCP的发送方在连续三次收到来自接收方的相同序号的应答报文时,即使还没有超时也会重新发送报文;

SACK选择性重传:当接收方需要发送方重传报文时,会在ACK应答报文中携带当前已接收的报文段的信息,这样发送方就只需要重传丢失的报文;

 

Ⅱ. 流量控制——避免发送方数据填满接收方的缓存

TCP使用滑动窗口来控制发送方在未收到确认的情况下可发送的最大数据量,超过窗口大小就停止发送数据,直至窗口内的数据被确认,

发送方窗口的大小根据接收方返回的报文中的窗口字段来动态改变;

 

Ⅲ. 拥塞控制——避免发送方数据填满整个网络

慢启动算法:TCP刚建立连接时,拥塞窗口为1,每经过一个RTT就翻一番;

拥塞避免算法:当拥塞窗口大小达到慢启动门限ssthresh后,每经过一个RTT就令拥塞窗口+1;

当网络中出现拥塞时,需要进行重传,重传机制有两种,执行的拥塞发生算法也不同:

  一种是超时重传,此时令慢启动门限ssthresh为发生拥塞时拥塞窗口大小的一半,拥塞窗口变为1,重新从慢启动算法开始执行;

  一种是快速重传,此时仍令慢启动门限ssthresh为发生拥塞时拥塞窗口大小的一半,但拥塞窗口大小变为与慢启动门限相同,执行拥塞避免算法;

 

 

④TCP和UDP的区别?

TCP面向连接,建立连接和释放连接需要三次握手和四次挥手,UDP是无连接的;

TCP保证可靠传输(有序无错、不丢不重),UDP不保证可靠传输(无重传机制、无拥塞控制);

TCP报文首部20字节,UDP报文首部只有8字节;

TCP只支持一对一连接,UDP还支持一对多、多对一、多对多;

TCP是面向字节流的,UDP是面向报文的;

TCP适用于对可靠性比较高的场景,例如远程登录,UDP适用于对实时性要求较高对可靠性要求不那么高的场景,例如视频通话;

 

 

⑤TCP协议和UDP协议可以使用同一个端口吗?(了解即可)

在数据链路层中,通过 MAC 地址来寻找局域网中的主机,

在网际层中,通过 IP 地址来寻找网络中互连的主机或路由器,

在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序;

 

 

 

 

 


 

posted @ 2023-12-05 15:32  Avava_Ava  阅读(1707)  评论(0编辑  收藏  举报