# 小鹏汽车 Java 面试

#

大家好,我是小宇宙。

现在新能源汽车的竞争已经开始进入下半场了,各家公司都开始进行组织架构调整,决战决赛圈,如果不能在决赛圈胜出,可能企业很难存活下来,市场就是这么残酷,赢家通吃,输家出局。

之前发过理想 (opens new window)、蔚来 (opens new window)、比亚迪 (opens new window)、极越 (opens new window)等车企公司的薪资情况和面试真题,这次来补上小鹏汽车的!

理想 (opens new window)

蔚来 (opens new window)

比亚迪 (opens new window)

极越 (opens new window)

小鹏汽车 2025 年计划招聘 6000 人,即使之前从小鹏离职过的,也可以重新加入小鹏。

之所以今年大力开始招聘,也是因为小鹏汽车穿过了暴风雨,在 2024 年度销量有突破性的增长,现在小鹏汽车月销 4w+台是常态,而且应该会很快上升到 5w+台,销量上去了,自然要扩大员工,在新的一年,将继续推出更多新型号的汽车。

这次来看看 25 届小鹏汽车校招的薪资,目前还没看到 Java 岗的薪资,所以列了一些其他岗位的薪资情况,也可以做个参考:

软件测试:18.5k x 15 = 27.7w,办公地点广州,同学背景硕士 985

嵌入式开发:20k * 15 = 30w,办公地点上海,同学背景硕士211

产品经理:18.5 x 15 = 27.7w,办公地点广州,同学背景硕士其他

影像开发:25k x 15 = 37.5w,办公地点广州,同学背景硕士 985

小鹏汽车的薪资对比互联网公司大厂的话,是会少一些,大概是互联网中厂的薪资水平,如果同学手上有互联网公司选择的话,小鹏汽车的竞争力就会弱一些了。新能源汽车里开的薪资能对标互联网大厂的,目前来看是理想汽车了。

肯定也有同学好奇,小鹏汽车的面试难度如何?

这次来看看小鹏汽车Java岗位的校招面经,主要考察计算机基、Java、JVM、网络、算法这些内容,难度的话,算中等,算法还是会考察,大部分新能源汽车都会考察,不管面互联网公司,还是新能源车企,算法大家都需要准备。

# 计算机基础

#

# ARM有了解嘛?

#

了解不过,大概知道是一个处理器,嵌入式领域用的多。

# 二进制怎么转16机制?

#

可以采用分组转换法。

分组转换法基于二进制和十六进制之间的位权关系。因为2^4=16,这意味着 4 位二进制数能够表示的状态数刚好与十六进制的一位所能表示的状态数相同。所以可以将二进制数按每 4 位一组进行划分,每一组都能唯一地对应一个十六进制数字。

下面我给出了二进制的数,我们将它转换为十六进制,例如:0101101,我们将这个数按4个一组来划分,变成 0010 1101(这里本来是010 1101前面不够4位我们就凑一个0),可以得到 0010 =2、 1101=D

所以转换成十六进制就是2D。

# byte类型的-1怎么表示?

#

byte类型是有符号的 8 位整数,取值范围是-128到127。-1在byte类型中的二进制表示是补码形式,正数的补码与原码相同,负数的补码是在反码的基础上加 1,这是因为计算机中采用补码来进行减法运算,可以将减法转换为加法,方便硬件实现,计算过程如下:

byte
-128
127
-1
byte

先写出1的原码:00000001。

1
00000001

然后得到-1的原码:10000001。

-1
10000001

接着求-1的反码:11111110。

-1
11111110

最后求-1的补码:11111111。

-1
11111111

所以,在 Java 的byte类型中,-1用二进制补码表示为11111111。当进行运算或存储时,计算机使用这个补码来处理-1相关的操作。例如,在进行加法运算时,-1 + 1的计算过程如下:

byte
-1
11111111
-1
-1 + 1
  • 1的补码是11111111,1的补码是00000001。
- 1
11111111
1
00000001

相加得到:11111111 + 00000001 = 100000000(9 位,超出byte范围)。

11111111 + 00000001 = 100000000
byte

由于byte类型是 8 位,会发生截断,得到00000000,也就是0,这符合数学运算结果。

byte
00000000
0

# Java

#

# 两个方法都被synchronized修饰,其中一个调用另一个可以成功嘛?synchronized修饰方法锁的那一部分?

#

如果两个方法都被synchronized修饰,一个方法内部调用另一个方法是可以成功的。这是因为synchronized方法默认是对当前对象(this)加锁。当一个线程进入了一个synchronized方法,它已经获得了该对象的锁,在这个方法内部调用另一个synchronized方法时,由于是同一个对象的锁,所以线程可以继续执行被调用的synchronized方法,不会出现锁竞争导致无法调用的情况。

synchronized
synchronized
this
synchronized
synchronized
synchronized

例如下面的代码,method1调用method2时,因为它们都是同一个对象example的synchronized方法,所以可以正常执行。

method1
method2
example
synchronized
public class SynchronizedExample {
    public synchronized void method1() {
        System.out.println("Method 1 started");
        method2();
        System.out.println("Method 1 ended");
    }
    public synchronized void method2() {
        System.out.println("Method 2 is running");
    }
    public static void main(String[] args) {
        SynchronizedExample example = new SynchronizedExample();
        example.method1();
    }
}
public class SynchronizedExample {
    public synchronized void method1() {
        System.out.println("Method 1 started");
        method2();
        System.out.println("Method 1 ended");
    }
    public synchronized void method2() {
        System.out.println("Method 2 is running");
    }
    public static void main(String[] args) {
        SynchronizedExample example = new SynchronizedExample();
        example.method1();
    }
}

synchronized 修饰方法锁的对象:

对于非静态方法:当synchronized修饰一个非静态方法时,锁的是当前对象(this)。这意味着同一时刻,对于同一个对象实例,只有一个线程能够执行这个对象的synchronized非静态方法。不同的对象实例之间的synchronized非静态方法可以被不同的线程同时执行,因为它们的锁对象(this)是不同的。

synchronized
this
synchronized
synchronized
this

对于静态方法:当synchronized修饰一个静态方法时,锁的是这个类的Class对象。因为静态方法是属于类的,而不是属于某个具体的对象实例。所以同一时刻,对于一个类的所有实例,只有一个线程能够执行这个类的synchronized静态方法。例如,下面的例子,staticMethod1和staticMethod2都是静态的synchronized方法,它们共享同一个类的Class对象作为锁。所以当thread1和thread2同时启动时,其中一个方法会先获得类的Class对象锁,另一个方法需要等待锁释放后才能执行。

synchronized
Class
synchronized
staticMethod1
staticMethod2
synchronized
Class
thread1
thread2
Class
public class SynchronizedStaticExample {
    public static synchronized void staticMethod1() {
        System.out.println("Static Method 1 started");
    }
    public static synchronized void staticMethod2() {
        System.out.println("Static Method 2 started");
    }
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            SynchronizedStaticExample.staticMethod1();
        });
        Thread thread2 = new Thread(() -> {
            SynchronizedStaticExample.staticMethod2();
        });
        thread1.start();
        thread2.start();
    }
}
public class SynchronizedStaticExample {
    public static synchronized void staticMethod1() {
        System.out.println("Static Method 1 started");
    }
    public static synchronized void staticMethod2() {
        System.out.println("Static Method 2 started");
    }
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            SynchronizedStaticExample.staticMethod1();
        });
        Thread thread2 = new Thread(() -> {
            SynchronizedStaticExample.staticMethod2();
        });
        thread1.start();
        thread2.start();
    }
}

# 静态内部类和匿名内部类有什么区别吗?

#

静态内部类是定义在另一个类内部的类,并且使用static关键字修饰。它就像是类的一个静态成员,不依赖于外部类的实例,就像下面的例子中,StaticInnerClass可以直接访问OuterClass的outerStaticVar静态变量。

static
StaticInnerClass
OuterClass
outerStaticVar
class OuterClass {
    private static int outerStaticVar = 10;
    static class StaticInnerClass {
        public void printOuterStaticVar() {
            System.out.println(outerStaticVar);
        }
    }
}
class OuterClass {
    private static int outerStaticVar = 10;
    static class StaticInnerClass {
        public void printOuterStaticVar() {
            System.out.println(outerStaticVar);
        }
    }
}

静态内部类不能直接访问外部类的非静态成员,因为非静态成员是依赖于外部类的实例存在的。如果要访问外部类的非静态成员,需要通过外部类的实例来访问。

静态内部类的生命周期与外部类的静态成员相似。它在外部类加载时不会自动加载,只有在第一次被使用(例如,通过new关键字创建实例或者访问静态成员)时才会加载。加载后,只要类加载器没有卸载这个类,它就一直存在于内存中。

new

实例化静态内部类时,不需要外部类的实例。可以直接通过外部类名.静态内部类名的方式来创建实例,例如OuterClass.StaticInnerClass innerObj = new OuterClass.StaticInnerClass();。

外部类名.静态内部类名
OuterClass.StaticInnerClass innerObj = new OuterClass.StaticInnerClass();

当一个类只与另一个类有比较紧密的关联,并且主要是为了辅助外部类完成某些功能,同时又不依赖于外部类的实例时,适合使用静态内部类。例如,一个工具类中的一些工具方法可以组织成静态内部类,这些方法可能会共享一些外部类的静态资源。静态内部类还可以用于实现单例模式。通过将单例对象的实例化放在静态内部类中,可以保证在第一次访问单例对象时才进行实例化,并且保证了线程安全。

匿名内部类是一种没有名字的内部类。它是在创建对象的同时定义类的一种方式,通常用于只需要使用一次的类,并且是作为某个接口或者抽象类的实现(或者某个类的子类)出现。例如,在下面实现接口的例子中,匿名内部类是在main方法内部定义的,它的行为可能会受到main方法中的其他变量或者外部类的状态的影响。

main
main
interface MyInterface {
    void myMethod();
}
class Main {
    public static void main(String[] args) {
        MyInterface anonymousClass = new MyInterface() {
            @Override
            public void myMethod() {
                System.out.println("This is an anonymous class implementing MyInterface");
            }
        };
        anonymousClass.myMethod();
    }
}
interface MyInterface {
    void myMethod();
}
class Main {
    public static void main(String[] args) {
        MyInterface anonymousClass = new MyInterface() {
            @Override
            public void myMethod() {
                System.out.println("This is an anonymous class implementing MyInterface");
            }
        };
        anonymousClass.myMethod();
    }
}

匿名内部类可以访问外部类的成员变量和方法,包括静态和非静态的。如果访问外部类的局部变量,这些局部变量必须是final(在 Java 8 之后,实际上是隐式final)的,这是为了保证在匿名内部类的生命周期内,这些变量的值不会被改变。

final
final

匿名内部类的生命周期取决于它的使用场景。如果它是在一个方法内部定义的,那么当方法执行结束后,只要没有其他引用指向这个匿名内部类的对象,它就会被垃圾回收。如果它是作为一个类的成员变量定义的,那么它的生命周期会和这个类的对象生命周期相关。匿名内部类在定义的同时就会被实例化,并且只能创建一个实例。因为它没有类名,所以不能像普通类一样通过new关键字在其他地方再次创建实例。

new

当只需要临时实现一个接口或者继承一个抽象类来提供特定的功能,并且这个实现类只使用一次时,匿名内部类是一个很好的选择。它避免了为一个简单的功能定义一个完整的类,从而简化了代码结构。

# 匿名内部内可以使用外部类的引用吗?静态的呢?

#

# HashMap和HashTable区别?

#

HashMap线程不安全,效率高一点,可以存储null的key和value,null的key只能有一个,null的value可以有多个。默认初始容量为16,每次扩充变为原来2倍。创建时如果给定了初始容量,则扩充为2的幂次方大小。底层数据结构为数组+链表,插入元素后如果链表长度大于阈值(默认为8),先判断数组长度是否小于64,如果小于,则扩充数组,反之将链表转化为红黑树,以减少搜索时间。

HashTable线程安全,效率低一点,其内部方法基本都经过synchronized修饰,不可以有null的key和value。默认初始容量为11,每次扩容变为原来的2n+1。创建时给定了初始容量,会直接用给定的大小。底层数据结构为数组+链表。它基本被淘汰了,要保证线程安全可以用ConcurrentHashMap。

ConcurrentHashMap是Java中的一个线程安全的哈希表实现,它可以在多线程环境下并发地进行读写操作,而不需要像传统的HashTable那样在读写时加锁。ConcurrentHashMap的实现原理主要基于分段锁和CAS操作。它将整个哈希表分成了多Segment(段),每个Segment都类似于一个小的HashMap,它拥有自己的数组和一个独立的锁。在ConcurrentHashMap中,读操作不需要锁,可以直接对Segment进行读取,而写操作则只需要锁定对应的Segment,而不是整个哈希表,这样可以大大提高并发性能。

# 讲一下ConcurrentHashMap?

#

JDK 1.7 ConcurrentHashMap

在 JDK 1.7 中它使用的是数组加链表的形式实现的,而数组又分为:大数组 Segment 和小数组 HashEntry。 Segment 是一种可重入锁(ReentrantLock),在 ConcurrentHashMap 里扮演锁的角色;HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。

img

JDK 1.7 ConcurrentHashMap 分段锁技术将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。

JDK 1.8 ConcurrentHashMap

在 JDK 1.7 中,ConcurrentHashMap 虽然是线程安全的,但因为它的底层实现是数组 + 链表的形式,所以在数据比较多的情况下访问是很慢的,因为要遍历整个链表,而 JDK 1.8 则使用了数组 + 链表/红黑树的方式优化了 ConcurrentHashMap 的实现,具体实现结构如下:

img

JDK 1.8 ConcurrentHashMap JDK 1.8 ConcurrentHashMap 主要通过 volatile + CAS 或者 synchronized 来实现的线程安全的。添加元素时首先会判断容器是否为空:

如果为空则使用 volatile 加 CAS 来初始化

如果为空则使用 volatile 加 CAS 来初始化

如果容器不为空,则根据存储的元素计算该位置是否为空。

如果容器不为空,则根据存储的元素计算该位置是否为空。

如果根据存储的元素计算结果为空,则利用 CAS 设置该节点;

如果根据存储的元素计算结果为空,则利用 CAS 设置该节点;

如果根据存储的元素计算结果不为空,则使用 synchronized ,然后,遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。

如果根据存储的元素计算结果不为空,则使用 synchronized ,然后,遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。

如果把上面的执行用一句话归纳的话,就相当于是ConcurrentHashMap通过对头结点加锁来保证线程安全的,锁的粒度相比 Segment 来说更小了,发生冲突和加锁的频率降低了,并发操作的性能就提高了。

而且 JDK 1.8 使用的是红黑树优化了之前的固定链表,那么当数据量比较大的时候,查询性能也得到了很大的提升,从之前的 O(n) 优化到了 O(logn) 的时间复杂度。

# JVM

#

# 类加载过程?

#

类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括以下 7 个阶段:

img

加载:通过类的全限定名(包名 + 类名),获取到该类的.class文件的二进制字节流,将二进制字节流所代表的静态存储结构,转化为方法区运行时的数据结构,在内存中生成一个代表该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

加载:通过类的全限定名(包名 + 类名),获取到该类的.class文件的二进制字节流,将二进制字节流所代表的静态存储结构,转化为方法区运行时的数据结构,在内存中生成一个代表该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

连接:验证、准备、解析 3 个阶段统称为连接。 验证:确保class文件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的class类的正确性,不会危害到虚拟机的安全。验证阶段大致会完成以下四个阶段的检验动作:文件格式校验、元数据验证、字节码验证、符号引用验证 准备:为类中的静态字段分配内存,并设置默认的初始值,比如int类型初始值是0。被final修饰的static字段不会设置,因为final在编译的时候就分配了 解析:解析阶段是虚拟机将常量池的「符号引用」直接替换为「直接引用」的过程。符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候可以无歧义地定位到目标即可。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用, 那引用的目标必定已经存在在内存中了。

连接:验证、准备、解析 3 个阶段统称为连接。

验证:确保class文件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的class类的正确性,不会危害到虚拟机的安全。验证阶段大致会完成以下四个阶段的检验动作:文件格式校验、元数据验证、字节码验证、符号引用验证

验证:确保class文件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的class类的正确性,不会危害到虚拟机的安全。验证阶段大致会完成以下四个阶段的检验动作:文件格式校验、元数据验证、字节码验证、符号引用验证

准备:为类中的静态字段分配内存,并设置默认的初始值,比如int类型初始值是0。被final修饰的static字段不会设置,因为final在编译的时候就分配了

准备:为类中的静态字段分配内存,并设置默认的初始值,比如int类型初始值是0。被final修饰的static字段不会设置,因为final在编译的时候就分配了

解析:解析阶段是虚拟机将常量池的「符号引用」直接替换为「直接引用」的过程。符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候可以无歧义地定位到目标即可。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用, 那引用的目标必定已经存在在内存中了。

解析:解析阶段是虚拟机将常量池的「符号引用」直接替换为「直接引用」的过程。符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候可以无歧义地定位到目标即可。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用, 那引用的目标必定已经存在在内存中了。

初始化:初始化是整个类加载过程的最后一个阶段,初始化阶段简单来说就是执行类的构造器方法,要注意的是这里的构造器方法()并不是开发者写的,而是编译器自动生成的。

初始化:初始化是整个类加载过程的最后一个阶段,初始化阶段简单来说就是执行类的构造器方法,要注意的是这里的构造器方法()并不是开发者写的,而是编译器自动生成的。

使用:使用类或者创建对象

使用:使用类或者创建对象

卸载:如果有下面的情况,类就会被卸载:1. 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。2. 加载该类的ClassLoader已经被回收。 3. 类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。

卸载:如果有下面的情况,类就会被卸载:1. 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。2. 加载该类的ClassLoader已经被回收。 3. 类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。

# 双亲委派机制为什么叫双亲?有什么好处?

#

双亲委派模型,简单说就是当类加载器(Class-Loader)试图加载某个类型的时候,除非父加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载 Java 类型。

“双亲” 并不是指有两个父母一样的角色。实际上,这里的 “双亲” 是一种形象的比喻,它是指除了最顶层的启动类加载器(Bootstrap ClassLoader)外,每个类加载器都有一个父类加载器。当一个类加载器需要加载类时,它会先委托给它的父类加载器去尝试加载,这个过程就好像孩子(子加载器)先请求父母(父加载器)帮忙做事一样,所以称为 “双亲委派”。

双亲委派机制好处主要是:

防止核心 API 被篡改:Java 的核心类库(如java.lang包中的类)是由启动类加载器(Bootstrap ClassLoader)加载的。因为双亲委派机制的存在,自定义的类加载器在加载类时,首先会将加载请求委托给父加载器。这就保证了像java.lang.Object这样的核心类不会被自定义的同名类随意替换。例如,如果没有双亲委派机制,恶意代码可能会定义一个自己的java.lang.Object类,并且通过自定义的类加载器加载,从而破坏 Java 程序的基本运行规则。

java.lang
java.lang.Object
java.lang.Object

避免类的重复加载:由于类加载请求是由下向上委托,然后再从上向下尝试加载。如果父加载器已经成功加载了某个类,子加载器就不会再重复加载该类,从而避免了因多次加载同一类而可能导致的类型不一致等问题。例如,系统中有多个不同的类加载器都可能需要加载java.util.ArrayList类,通过双亲委派机制,只有启动类加载器会加载这个类,其他类加载器会直接使用已经加载好的类。

java.util.ArrayList

保证类的一致性:在 Java 的运行环境中,对于同样全限定名的类,应该只有一份字节码被加载并使用。双亲委派机制确保了在整个类加载体系中,类的加载是有层次和顺序的。例如,在一个复杂的 Java 应用系统中,可能存在多个模块都依赖于同一个第三方库中的类。通过双亲委派机制,这些模块所使用的该类是由同一个类加载器加载的,保证了在整个系统中该类的一致性,使得不同模块之间可以正确地交互和共享对象。

# class文件和字节码文件的区别?

#

概念上的区别:

Class 文件:在 Java 中,.class文件是 Java 编译器(javac)将.java源文件编译后生成的文件格式。它是一种二进制文件,存储了 Java 程序的字节码指令、常量池、访问标志、类名、方法名、字段名等各种信息。可以把.class文件看作是字节码的一种物理存储形式,是字节码的载体。

.class
javac
.java
.class

字节码(Byte - code):字节码是一种中间形式的机器语言,它是 Java 程序经过编译后产生的指令集。字节码是一种高度抽象的、与具体机器硬件无关的指令代码,它可以在任何安装了 Java 虚拟机(JVM)的平台上执行。字节码指令是 JVM 能够理解和执行的基本单位,这些指令类似于汇编语言指令,但更加抽象和高级。

Class 文件用途

存储和分发:.class文件是 Java 程序的一种可存储和可分发的形式。当开发一个 Java 项目时,编译器会生成一系列的.class文件,这些文件可以被打包成.jar文件或者部署到服务器等环境中,供其他程序使用或者在运行时被加载。

.class
.class
.jar

跨平台基础:.class文件的存在是 Java 实现 “一次编写,到处运行” 特性的基础之一。因为不同的操作系统有不同的机器指令集,Java 编译器将.java源文件编译成与平台无关的.class文件,然后由各个平台上的 JVM 对.class文件进行解释执行或者编译成机器码执行。

.class
.java
.class
.class

字节码用途

JVM 执行的指令集:字节码是 JVM 执行 Java 程序的实际指令。当 JVM 加载.class文件时,它会解析.class文件中的字节码指令,并按照字节码指令的顺序执行操作。例如,当调用一个 Java 方法时,JVM 会读取方法表中的字节码指令,逐条执行这些指令来完成方法的功能。

.class
.class

动态加载和执行:字节码的动态特性使得 Java 可以实现一些高级的功能,如动态代理、字节码增强等。通过在运行时动态生成字节码或者修改已有的字节码,可以实现诸如 AOP等编程技术,为 Java 程序提供了更大的灵活性。

# 弱引用和软引用的区别?

#

软引用是一种相对较强的引用类型。它所引用的对象在内存足够的情况下,不会被垃圾回收器回收;只有在内存不足时,才会被回收。这使得软引用适合用来缓存一些可能会被频繁使用,但又不是必须一直存在的数据,例如缓存图片等资源。

弱引用是一种比较弱的引用类型。被弱引用关联的对象,只要垃圾回收器运行,无论当前内存是否充足,都会被回收。它主要用于解决一些对象的生命周期管理问题,例如在哈希表中,如果键是弱引用,当对象没有其他强引用时,就可以自动被回收,避免内存泄漏。

# 网络

#

# 计网分层结构说一下?

#

OSI七层模型

OSI七层模型

为了使得多种设备能通过网络相互通信,和为了解决各种不同设备在网络互联中的兼容性问题,国际标准化组织制定了开放式系统互联通信参考模型(Open System Interconnection Reference Model),也就是 OSI 网络模型,该模型主要有 7 层,分别是应用层、表示层、会话层、传输层、网络层、数据链路层以及物理层。

img

每一层负责的职能都不同,如下:

应用层,负责给应用程序提供统一的接口;

表示层,负责把数据转换成兼容另一个系统能识别的格式;

会话层,负责建立、管理和终止表示层实体之间的通信会话;

传输层,负责端到端的数据传输;

网络层,负责数据的路由、转发、分片;

数据链路层,负责数据的封帧和差错检测,以及 MAC 寻址;

物理层,负责在物理网络中传输数据帧;

由于 OSI 模型实在太复杂,提出的也只是概念理论上的分层,并没有提供具体的实现方案。

事实上,我们比较常见,也比较实用的是四层模型,即 TCP/IP 网络模型,Linux 系统正是按照这套网络模型来实现网络协议栈的。

TCP/IP模型

TCP/IP模型

TCP/IP协议被组织成四个概念层,其中有三层对应于ISO参考模型中的相应层。ICP/IP协议族并不包含物理层和数据链路层,因此它不能独立完成整个计算机网络系统的功能,必须与许多其他的协议协同工作。TCP/IP 网络通常是由上到下分成 4 层,分别是应用层,传输层,网络层和网络接口层。

img

应用层 支持 HTTP、SMTP 等最终用户进程

传输层 处理主机到主机的通信(TCP、UDP)

网络层 寻址和路由数据包(IP 协议)

链路层 通过网络的物理电线、电缆或无线信道移动比特

# TCP为什么要三次握手?

#

三次握手的原因:

三次握手才可以阻止重复历史连接的初始化(主要原因)

三次握手才可以同步双方的初始序列号

三次握手才可以避免资源浪费

原因一:避免历史连接

原因一:避免历史连接

我们来看看 RFC 793 指出的 TCP 连接使用三次握手的首要原因:

The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion.

简单来说,三次握手的首要原因是为了防止旧的重复连接初始化造成混乱。

我们考虑一个场景,客户端先发送了 SYN(seq = 90)报文,然后客户端宕机了,而且这个 SYN 报文还被网络阻塞了,服务并没有收到,接着客户端重启后,又重新向服务端建立连接,发送了 SYN(seq = 100)报文(注意!不是重传 SYN,重传的 SYN 的序列号是一样的)。

看看三次握手是如何阻止历史连接的:

img

客户端连续发送多次 SYN(都是同一个四元组)建立连接的报文,在网络拥堵情况下:

一个「旧 SYN 报文」比「最新的 SYN」 报文早到达了服务端,那么此时服务端就会回一个 SYN + ACK 报文给客户端,此报文中的确认号是 91(90+1)。

客户端收到后,发现自己期望收到的确认号应该是 100 + 1,而不是 90 + 1,于是就会回 RST 报文。

服务端收到 RST 报文后,就会释放连接。

后续最新的 SYN 抵达了服务端后,客户端与服务端就可以正常的完成三次握手了。

上述中的「旧 SYN 报文」称为历史连接,TCP 使用三次握手建立连接的最主要原因就是防止「历史连接」初始化了连接。

如果是两次握手连接,就无法阻止历史连接,那为什么 TCP 两次握手为什么无法阻止历史连接呢?

我先直接说结论,主要是因为在两次握手的情况下,服务端没有中间状态给客户端来阻止历史连接,导致服务端可能建立一个历史连接,造成资源浪费。

你想想,在两次握手的情况下,服务端在收到 SYN 报文后,就进入 ESTABLISHED 状态,意味着这时可以给对方发送数据,但是客户端此时还没有进入 ESTABLISHED 状态,假设这次是历史连接,客户端判断到此次连接为历史连接,那么就会回 RST 报文来断开连接,而服务端在第一次握手的时候就进入 ESTABLISHED 状态,所以它可以发送数据的,但是它并不知道这个是历史连接,它只有在收到 RST 报文后,才会断开连接。

img

可以看到,如果采用两次握手建立 TCP 连接的场景下,服务端在向客户端发送数据前,并没有阻止掉历史连接,导致服务端建立了一个历史连接,又白白发送了数据,妥妥地浪费了服务端的资源。

因此,要解决这种现象,最好就是在服务端发送数据前,也就是建立连接之前,要阻止掉历史连接,这样就不会造成资源浪费,而要实现这个功能,就需要三次握手。

所以,TCP 使用三次握手建立连接的最主要原因是防止「历史连接」初始化了连接。

原因二:同步双方初始序列号

原因二:同步双方初始序列号

TCP 协议的通信双方, 都必须维护一个「序列号」, 序列号是可靠传输的一个关键因素,它的作用:

接收方可以去除重复的数据;

接收方可以根据数据包的序列号按序接收;

可以标识发送出去的数据包中, 哪些是已经被对方收到的(通过 ACK 报文中的序列号知道);

可见,序列号在 TCP 连接中占据着非常重要的作用,所以当客户端发送携带「初始序列号」的 SYN 报文的时候,需要服务端回一个 ACK 应答报文,表示客户端的 SYN 报文已被服务端成功接收,那当服务端发送「初始序列号」给客户端的时候,依然也要得到客户端的应答回应,这样一来一回,才能确保双方的初始序列号能被可靠的同步。

img

四次握手其实也能够可靠的同步双方的初始化序号,但由于第二步和第三步可以优化成一步,所以就成了「三次握手」。

而两次握手只保证了一方的初始序列号能被对方成功接收,没办法保证双方的初始序列号都能被确认接收。

原因三:避免资源浪费

原因三:避免资源浪费

如果只有「两次握手」,当客户端发生的 SYN 报文在网络中阻塞,客户端没有接收到 ACK 报文,就会重新发送 SYN ,由于没有第三次握手,服务端不清楚客户端是否收到了自己回复的 ACK 报文,所以服务端每收到一个 SYN 就只能先主动建立一个连接,这会造成什么情况呢?

如果客户端发送的 SYN 报文在网络中阻塞了,重复发送多次 SYN 报文,那么服务端在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。

即两次握手会造成消息滞留情况下,服务端重复接受无用的连接请求 SYN 报文,而造成重复分配资源

# 算法

#

# 反转链表

#

通过迭代遍历链表,在遍历过程中改变链表节点指针的指向,将当前节点的next指针指向前一个节点,从而实现链表的反转。需要使用三个指针来辅助操作,分别指向当前节点、前一个节点和后一个节点。

next
class ListNode {
    int val;
    ListNode next;
    ListNode(int val) {
        this.val = val;
    }
}

public class ReverseLinkedList {
    public static ListNode reverseList(ListNode head) {
        ListNode prev = null;
        ListNode curr = head;
        while (curr!= null) {
            ListNode nextTemp = curr.next;
            curr.next = prev;
            prev = curr;
            curr = nextTemp;
        }
        return prev;
    }
}
class ListNode {
    int val;
    ListNode next;
    ListNode(int val) {
        this.val = val;
    }
}

public class ReverseLinkedList {
    public static ListNode reverseList(ListNode head) {
        ListNode prev = null;
        ListNode curr = head;
        while (curr!= null) {
            ListNode nextTemp = curr.next;
            curr.next = prev;
            prev = curr;
            curr = nextTemp;
        }
        return prev;
    }
}

在上述代码中:

prev 初始化为 null,代表反转后链表的末尾(也就是原链表的头节点反转后的前一个节点)。

prev
null

curr 初始化为原链表的头节点 head,然后在循环中,先保存当前节点的下一个节点到 nextTemp,接着将当前节点的 next 指针指向前一个节点 prev,再更新 prev 和 curr 的值,继续下一轮循环,直到遍历完整个链表,最后返回 prev,它就是反转后链表的头节点。

curr
head
nextTemp
next
prev
prev
curr
prev

对了,最新的互联网大厂后端面经都会在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。

img