前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >提前批拿到意向书,我的秋招结束了!

提前批拿到意向书,我的秋招结束了!

作者头像
小林coding
发布2024-07-05 15:07:38
110
发布2024-07-05 15:07:38
举报
文章被收录于专栏:小林coding小林coding

早就是优势,对于校招生来说,早准备的同学,有肉吃!

拿到了提前批 offer,不会马上说薪资的事情,大部分公司通常是延迟 10 月份才开奖,所以是大白菜,还是 sp,ssp offer,得等 10 月份了。

之前已经分享过深信服的提前批的面经,那么今天就来分享 tplink 提前批的后端开发面经,给大家感受一下提前批面试的氛围。

tplink 面试过程不是很长,通常一场面试 10 多个问题,相比互联网公司的面试数量,还是少一些,这次 tplink 的面经,还是我结合了 2 位同学的技术面试题汇总的。平常一些大厂面经,光是一个同学面经,就超过 30 个了。

考察的知识点,主要围绕Java+Redis+网络+算法:

  • Java:JVM、容器,ThreadLocal
  • Redis: 一致性问题、缓存穿透、集群、分布式锁、数据结构
  • 计网:TCP连接
  • 算法:堆排序

Java

Jvm内存区域

根据 JVM8 规范,JVM 运行时内存共分为虚拟机栈、堆、元空间、程序计数器、本地方法栈五个部分。还有一部分内存叫直接内存,属于操作系统的本地内存,也是可以直接操作的。

JVM的内存结构主要分为以下几个部分:

  • 元空间:元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
  • Java 虚拟机栈:每个线程有一个私有的栈,随着线程的创建而创建。栈里面存着的是一种叫“栈帧”的东西,每个方法会创建一个栈帧,栈帧中存放了局部变量表(基本数据类型和对象引用)、操作数栈、方法出口等信息。栈的大小可以固定也可以动态扩展。
  • 本地方法栈:与虚拟机栈类似,区别是虚拟机栈执行java方法,本地方法站执行native方法。在虚拟机规范中对本地方法栈中方法使用的语言、使用方法与数据结构没有强制规定,因此虚拟机可以自由实现它。
  • 程序计数器:程序计数器可以看成是当前线程所执行的字节码的行号指示器。在任何一个确定的时刻,一个处理器(对于多内核来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,我们称这类内存区域为“线程私有”内存。
  • 堆内存:堆内存是 JVM 所有线程共享的部分,在虚拟机启动的时候就已经创建。所有的对象和数组都在堆上进行分配。这部分空间可通过 GC 进行回收。当申请不到空间时会抛出 OutOfMemoryError。堆是JVM内存占用最大,管理最复杂的一个区域。其唯一的用途就是存放对象实例:所有的对象实例及数组都在对上进行分配。jdk1.8后,字符串常量池从永久代中剥离出来,存放在队中。
  • 直接内存:直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中农定义的内存区域。在JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用native 函数库直接分配堆外内存,然后通脱一个存储在Java堆中的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

本地方法栈的运行场景

本地方法栈与虚拟机栈在功能上有很多相似之处,但是它们之间还是有一些区别的。虚拟机栈主要用于执行 Java 方法,也就是字节码,而本地方法栈主要负责执行由非 Java 语言(如 C、C++)编写的 Native 方法。

每个线程都拥有一个和它生命周期相同的私有本地方法栈。虚拟机没有对本地方法栈使用的编程语言、实现方式和数据结构做严格限制,这意味着不同的虚拟机可以根据自己的需求实现本地方法栈。例如,Sun HotSpot 虚拟机就直接把本地方法栈和虚拟机栈合并为一个。

和虚拟机栈一样,本地方法栈也可能在栈深度超过虚拟机允许的范围时,抛出 StackOverflowError,在虚拟机栈动态扩展无法申请到足够内存时,抛出 OutOfMemoryError。

本地方法是指那些使用native关键字声明,但在Java外部实现的方法,通常使用C或C++编写。本地方法栈是每个线程私有的,它的功能与Java虚拟机栈类似,但是专门用于处理本地方法的调用。以下是本地方法栈的一些典型运行场景:

  • 系统级操作:Java语言本身不能直接访问操作系统资源,如文件系统、硬件设备等,这时就需要通过本地方法来调用操作系统的API。
  • 与硬件交互:对于需要与硬件直接交互的场景,如图形界面、音频处理或设备驱动,通常需要使用本地方法来调用相应的底层库。
  • 高级库的封装:许多高级的开发库,如图像处理、科学计算等,可能已经有成熟的C/C++版本,为了在Java中使用这些库,可以编写本地方法作为桥梁。
  • 互操作性:Java需要与其他非Java语言编写的程序进行交互时,本地方法栈可以支持这种互操作性,例如通过JNI(Java Native Interface)来调用。
  • 线程操作:Java中的线程管理在一定程度上依赖于本地方法,如Thread.sleep(long millis)和Thread.start()等方法实际上是在本地方法栈中实现的。

Native方法解释一下

在Java中,native方法是一种特殊类型的方法,它允许Java代码调用外部的本地代码,即用C、C++或其他语言编写的代码。native关键字是Java语言中的一种声明,用于标记一个方法的实现将在外部定义。在Java类中,native方法看起来与其他方法相似,只是其方法体由native关键字代替,没有实际的实现代码。例如:

代码语言:javascript
复制
public class NativeExample {
    public native void nativeMethod();
}

要实现native方法,你需要完成以下步骤:

  1. 生成JNI头文件:使用javah工具从你的Java类生成C/C++的头文件,这个头文件包含了所有native方法的原型。
  2. 编写本地代码:使用C/C++编写本地方法的实现,并确保方法签名与生成的头文件中的原型匹配。
  3. 编译本地代码:将C/C++代码编译成动态链接库(DLL,在Windows上),共享库(SO,在Linux上)
  4. 加载本地库:在Java程序中,使用System.loadLibrary()方法来加载你编译好的本地库,这样JVM就能找到并调用native方法的实现了。

怎么保证多线程安全

  • synchronized关键字:可以使用synchronized关键字来同步代码块或方法,确保同一时刻只有一个线程可以访问这些代码。对象锁是通过synchronized关键字锁定对象的监视器(monitor)来实现的。
代码语言:javascript
复制
public synchronized void someMethod() { /* ... */ }

public void anotherMethod() {
    synchronized (someObject) {
        /* ... */
    }
}
  • volatile关键字:volatile关键字用于变量,确保所有线程看到的是该变量的最新值,而不是可能存储在本地寄存器中的副本。
代码语言:javascript
复制
public volatile int sharedVariable;
  • Lock接口和ReentrantLock类:java.util.concurrent.locks.Lock接口提供了比synchronized更强大的锁定机制,ReentrantLock是一个实现该接口的例子,提供了更灵活的锁管理和更高的性能。
代码语言:javascript
复制
private final ReentrantLock lock = new ReentrantLock();

public void someMethod() {
    lock.lock();
    try {
        /* ... */
    } finally {
        lock.unlock();
    }
}
  • 原子类:Java并发库(java.util.concurrent.atomic)提供了原子类,如AtomicIntegerAtomicLong等,这些类提供了原子操作,可以用于更新基本类型的变量而无需额外的同步。
代码语言:javascript
复制
AtomicInteger counter = new AtomicInteger(0);

int newValue = counter.incrementAndGet();
  • 线程局部变量:ThreadLocal类可以为每个线程提供独立的变量副本,这样每个线程都拥有自己的变量,消除了竞争条件。
代码语言:javascript
复制
ThreadLocal<Integer> threadLocalVar = new ThreadLocal<>();

threadLocalVar.set(10);
int value = threadLocalVar.get();
  • 并发集合:使用java.util.concurrent包中的线程安全集合,如ConcurrentHashMapConcurrentLinkedQueue等,这些集合内部已经实现了线程安全的逻辑。
  • JUC工具类: 使用java.util.concurrent包中的一些工具类可以用于控制线程间的同步和协作。例如:SemaphoreCyclicBarrier等。

HashTable数据结构,底层实现原理

  • Hashtable的底层数据结构主要是数组加上链表,数组是主体,链表是解决hash冲突存在的。
  • HashTable是线程安全的,实现方式是Hashtable的所有公共方法均采用synchronized关键字,当一个线程访问同步方法,另一个线程也访问的时候,就会陷入阻塞或者轮询的状态。

Threadlocal作用,原理,具体里面存的key value是啥,会有什么问题,如何解决

ThreadLocal是Java中用于解决线程安全问题的一种机制,它允许创建线程局部变量,即每个线程都有自己独立的变量副本,从而避免了线程间的资源共享和同步问题。

从内存结构图,我们可以看到:

  • Thread类中,有个ThreadLocal.ThreadLocalMap 的成员变量。
  • ThreadLocalMap内部维护了Entry数组,每个Entry代表一个完整的对象,keyThreadLocal本身,value是ThreadLocal的泛型对象值。

ThreadLocal的作用

  • 线程隔离ThreadLocal为每个线程提供了独立的变量副本,这意味着线程之间不会相互影响,可以安全地在多线程环境中使用这些变量而不必担心数据竞争或同步问题。
  • 降低耦合度:在同一个线程内的多个函数或组件之间,使用ThreadLocal可以减少参数的传递,降低代码之间的耦合度,使代码更加清晰和模块化。
  • 性能优势:由于ThreadLocal避免了线程间的同步开销,所以在大量线程并发执行时,相比传统的锁机制,它可以提供更好的性能。

ThreadLocal的原理

ThreadLocal的实现依赖于Thread类中的一个ThreadLocalMap字段,这是一个存储ThreadLocal变量本身和对应值的映射。每个线程都有自己的ThreadLocalMap实例,用于存储该线程所持有的所有ThreadLocal变量的值。

当你创建一个ThreadLocal变量时,它实际上就是一个ThreadLocal对象的实例。每个ThreadLocal对象都可以存储任意类型的值,这个值对每个线程来说是独立的。

  • 当调用ThreadLocalget()方法时,ThreadLocal会检查当前线程的ThreadLocalMap中是否有与之关联的值。
    • 如果有,返回该值;
    • 如果没有,会调用initialValue()方法(如果重写了的话)来初始化该值,然后将其放入ThreadLocalMap中并返回。
  • 当调用set()方法时,ThreadLocal会将给定的值与当前线程关联起来,即在当前线程的ThreadLocalMap中存储一个键值对,键是ThreadLocal对象自身,值是传入的值。
  • 当调用remove()方法时,会从当前线程的ThreadLocalMap中移除与该ThreadLocal对象关联的条目。

可能存在的问题

当一个线程结束时,其ThreadLocalMap也会随之销毁,但是ThreadLocal对象本身不会立即被垃圾回收,直到没有其他引用指向它为止。

因此,在使用ThreadLocal时需要注意,如果不显式调用remove()方法,或者线程结束时未正确清理ThreadLocal变量,可能会导致内存泄漏,因为ThreadLocalMap会持续持有ThreadLocal变量的引用,即使这些变量不再被其他地方引用。

因此,实际应用中需要在使用完ThreadLocal变量后调用remove()方法释放资源。

Redis

Redis数据结构及各自应用

Redis 提供了丰富的数据类型,常见的有五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)

随着 Redis 版本的更新,后面又支持了四种数据类型:BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)。Redis 五种数据类型的应用场景:

  • String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。
  • List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
  • Hash 类型:缓存对象、购物车等。
  • Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
  • Zset 类型:排序场景,比如排行榜、电话和姓名排序等。

Redis 后续版本又支持四种数据类型,它们的应用场景如下:

  • BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
  • HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;
  • GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车;
  • Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。

Redis和数据库一致性的问题 怎么实现

对于读数据,我会选择旁路缓存策略,如果 cache 不命中,会从 db 加载数据到 cache。对于写数据,我会选择更新 db 后,再删除缓 存。

针对删除缓存异常的情况,我还会对 key 设置过期时间兜底,只要过期时间一到,过期的 key 就会被删除了。

除此之外,还有两种方式应对删除缓存失败的情况。

消息队列方案

我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。

  • 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
  • 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。

举个例子,来说明重试机制的过程。

订阅 MySQL binlog,再操作缓存

先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。

于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。

Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。

下图是 Canal 的工作原理:

所以,如果要想保证「先更新数据库,再删缓存」策略第二个操作能执行成功,我们可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作缓存。

分布式锁是如何设计的

分布式锁是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用

如下图所示:

Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁,而且 Redis 的读写性能高,可以应对高并发的锁操作场景。Redis 的 SET 命令有个 NX 参数可以实现「key不存在才插入」,所以可以用它来实现分布式锁:

  • 如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
  • 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。

基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。

  • 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁;
  • 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间;
  • 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端;

满足这三个条件的分布式命令如下:

代码语言:javascript
复制
SET lock_key unique_value NX PX 10000
  • lock_key 就是 key 键;
  • unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作;
  • NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
  • PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。

而解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。

可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。

代码语言:javascript
复制
// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。

解决缓存穿透的方法

当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。

那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。

缓存穿透解决方案:

  • 非法请求的限制:当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
  • 缓存空值或者默认值:当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。
  • 布隆过滤器:我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。

布隆过滤器原理介绍一下

布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。

布隆过滤器会通过 3 个操作完成标记:

  • 第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值;
  • 第二步,将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。
  • 第三步,将每个哈希值在位图数组的对应位置的值设置为 1;

举个例子,假设有一个位图数组长度为 8,哈希函数 3 个的布隆过滤器。

在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中

布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。

所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据

Redis集群的模式了解吗 优缺点了解吗

当 Redis 缓存数据量大到一台服务器无法缓存时,就需要使用 Redis 切片集群(Redis Cluster )方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。

Redis Cluster 方案采用哈希槽(Hash Slot),来处理数据和节点之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中,具体执行过程分为两大步:

  • 根据键值对的 key,按照 CRC16 算法计算一个 16 bit 的值。
  • 再用 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。

接下来的问题就是,这些哈希槽怎么被映射到具体的 Redis 节点上的呢?有两种方案:

  • 平均分配: 在使用 cluster create 命令创建 Redis 集群时,Redis 会自动把所有哈希槽平均分布到集群节点上。比如集群中有 9 个节点,则每个节点上槽的个数为 16384/9 个。
  • 手动分配: 可以使用 cluster meet 命令手动建立节点间的连接,组成集群,再使用 cluster addslots 命令,指定每个节点上的哈希槽个数。

为了方便你的理解,我通过一张图来解释数据、哈希槽,以及节点三者的映射分布关系。

上图中的切片集群一共有 2 个节点,假设有 4 个哈希槽(Slot 0~Slot 3)时,我们就可以通过命令手动分配哈希槽,比如节点 1 保存哈希槽 0 和 1,节点 2 保存哈希槽 2 和 3。

代码语言:javascript
复制
redis-cli -h 192.168.1.10 –p 6379 cluster addslots 0,1
redis-cli -h 192.168.1.11 –p 6379 cluster addslots 2,3

然后在集群运行的过程中,key1 和 key2 计算完 CRC16 值后,对哈希槽总个数 4 进行取模,再根据各自的模数结果,就可以被映射到哈希槽 1(对应节点1) 和 哈希槽 2(对应节点2)。

需要注意的是,在手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作。

Redis集群模式优点/缺点

优点:

  • 高可用性:Redis集群最主要的优点是提供了高可用性,节点之间采用主从复制机制,可以保证数据的持久性和容错能力,哪怕其中一个节点挂掉,整个集群还可以继续工作。
  • 高性能:Redis集群采用分片技术,将数据分散到多个节点,从而提高读写性能。当业务访问量大到单机Redis无法满足时,可以通过添加节点来增加集群的吞吐量。
  • 扩展性好:Redis集群的扩展性非常好,可以根据实际需求动态增加或减少节点,从而实现可扩展性。集群模式中的某些节点还可以作为代理节点,自动转发请求,增加数据模式的灵活度和可定制性。

缺点:

  • 部署和维护较复杂:Redis集群的部署和维护需要考虑到分片规则、节点的布置、主从配置以及故障处理等多个方面,需要较强的技术支持,增加了节点异常处理的复杂性和成本。
  • 集群同步问题:当某些节点失败或者网络出故障,集群中数据同步的问题也会出现。数据同步的复杂度和工作量随着节点的增加而增加,同步时间也较长,导致一定的读写延迟。
  • 数据分片限制:Redis集群的数据分片也限制了一些功能的实现,如在一个key上修改多次,可能会因为该key所在的节点位置变化而失败。此外,由于将数据分散存储到各个节点,某些操作不能跨节点实现,不同节点之间的一些操作需要额外注意。

计网

TCP三次握手

TCP 是面向连接的协议,所以使用 TCP 前必须先建立连接,而建立连接是通过三次握手来进行的。三次握手的过程如下图:

  • 一开始,客户端和服务端都处于 CLOSE 状态。先是服务端主动监听某个端口,处于 LISTEN 状态
  • 客户端会随机初始化序号(client_isn),将此序号置于 TCP 首部的「序号」字段中,同时把 SYN 标志位置为 1,表示 SYN 报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于 SYN-SENT 状态。
  • 服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号(server_isn),将此序号填入 TCP 首部的「序号」字段中,其次把 TCP 首部的「确认应答号」字段填入 client_isn + 1, 接着把 SYN 和 ACK 标志位置为 1。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于 SYN-RCVD 状态。
  • 客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 ACK 标志位置为 1 ,其次「确认应答号」字段填入 server_isn + 1 ,最后把报文发送给服务端,这次报文可以携带客户到服务端的数据,之后客户端处于 ESTABLISHED 状态。
  • 服务端收到客户端的应答报文后,也进入 ESTABLISHED 状态。

从上面的过程可以发现第三次握手是可以携带数据的,前两次握手是不可以携带数据的,这也是面试常问的题。

一旦完成三次握手,双方都处于 ESTABLISHED 状态,此时连接就已建立完成,客户端和服务端就可以相互发送数据了。

SYN的概念

在这里,我们首先给出答案,SYN是TCP头部中的一个控制位字段,该位为 1 时,表示希望建立连接,并在其「序列号」的字段进行序列号初始值的设定。

接下来让我们看看 TCP 头的格式,标注颜色的表示与本文关联比较大的字段,其他字段不做详细阐述。

序列号:在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。用来解决网络包乱序问题。

确认应答号:指下一次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。用来解决丢包的问题。

控制位:

  • ACK:该位为 1 时,「确认应答」的字段变为有效,TCP 规定除了最初建立连接时的 SYN 包之外该位必须设置为 1 。
  • RST:该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接。
  • SYN:该位为 1 时,表示希望建立连接,并在其「序列号」的字段进行序列号初始值的设定。
  • FIN:该位为 1 时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换 FIN 位为 1 的 TCP 段。

算法

堆排序算法原理,稳定吗

如果每个节点大于等于子树中的每个节点,我们称之为大顶堆,小于等于子树中的每个节点,我们则称之为小顶堆。

堆的要求:

  • 必须是完全二叉树
  • 堆中的每一个节点,都必须大于等于(或小于等于)其子树中每个节点的值。

堆通常是使用一维数组进行保存,节省空间,不需要存左右子节点的指针,通过下标就可定位左右节点和父节点。在起始位置为0的数组中:

  • 父节点 i 的左子节点在(2i+1)的位置
  • 父节点 i 的右子节点在(2i+2)的位置
  • 子节点 i 的父节点在(i-1)/2向下取整的位置

我们可以把堆排序的过程大致分为两大步骤,分别是建堆和排序。

  • 建堆:建堆操作就是将一个无序的数组转化为最大堆的操作,首先将数组原地建一个堆。“原地”的含义就是不借助另一个数组,就在原数组上操作。我们的实现思路是从后往前处理数据,并且每个数据都是从上向下调整。
  • 排序:建堆结束后,数组中的数据已经按照大顶堆的特性进行组织了,数组中的第一个元素就是堆顶,也就是最大的元素。我们把它和最后一个元素交换,那最大的元素就放到了下标为n的位置,时末尾元素就是最大值,将剩余元素重新堆化成一个大顶堆。继续重复这些步骤,直至数组有序排列

假设我们有一个数组 [4, 10, 3, 5, 1],堆排序的过程如下:

  1. 构建最大堆:[10, 5, 3, 4, 1]
  2. 交换堆顶元素与最后一个元素:[1, 5, 3, 4, 10]
  3. 调整剩余元素为堆:[5, 4, 3, 1]
  4. 再次交换堆顶元素与最后一个元素:[1, 4, 3, 5]
  5. 调整剩余元素为堆:[4, 3, 1]
  6. 继续上述过程直到排序完成:[1, 3, 4, 5, 10]

算法实现:

代码语言:javascript
复制
public class HeapSort {
    // 堆排序方法
    public static void heapSort(int[] arr) {
        int n = arr.length;

        // 构建堆(重新排列数组)
        for (int i = n / 2 - 1; i >= 0; i--) {
            heapify(arr, n, i);
        }

        // 依次从堆中提取元素
        for (int i = n - 1; i > 0; i--) {
            // 将当前根节点移动到末尾
            int temp = arr[0];
            arr[0] = arr[i];
            arr[i] = temp;

            // 在堆中调整
            heapify(arr, i, 0);
        }
    }

    // 通过索引i对数组arr的前n个元素进行堆调整
    private static void heapify(int[] arr, int n, int i) {
        int largest = i; // 初始化最大值索引
        int left = 2 * i + 1; // 左孩子节点
        int right = 2 * i + 2; // 右孩子节点

        // 如果左孩子大于根节点
        if (left < n && arr[left] > arr[largest]) {
            largest = left;
        }

        // 如果右孩子大于当前最大值
        if (right < n && arr[right] > arr[largest]) {
            largest = right;
        }

        // 如果最大值不是根节点
        if (largest != i) {
            int swap = arr[i];
            arr[i] = arr[largest];
            arr[largest] = swap;

            // 递归调整受影响的子树
            heapify(arr, n, largest);
        }
    }

    public static void main(String[] args) {
        int[] arr = {4, 10, 3, 5, 1};
        heapSort(arr);
        
        System.out.println("排序后的数组:");
        for (int i : arr) {
            System.out.print(i + " ");
        }
    }
}

现在我们来分析一下堆排序的时间复杂度、空间复杂度以及稳定性。

  • 整个堆排序的过程中,只需要个别的临时存储空间,所以堆排序是原地排序算法
  • 堆排序包括建堆和排序两个操作,建堆的时间复杂度是O(n),排序过程时间复杂度是O(nlogN)。所以,**堆排序的整个时间复杂度是O(nlogN)**。
  • 因为在排序的过程中,存在将堆的最后一个节点跟堆顶互换的操作,所以有可能会改变值相同数据的原始相对顺序,所以堆排序不是稳定的排序算法。例如,假设我们有两个相同的元素A和B,且A在B前面。在构建和调整堆的过程中,B可能被移动到A的前面,从而破坏了它们原来的相对顺序。

什么是排序稳定性?

排序算法的稳定性是指在排序过程中,当有多个具有相同关键字的元素时,这些元素在排序后的序列中保持它们原有的相对顺序。

换句话说,如果两个元素有相同的键值,那么在排序前,如果第一个元素在第二个元素之前,排序后第一个元素也应该在第二个元素之前。

具体来说,对于一个序列中的两个元素A和B,如果A和B的键值相同,且在排序前A在B之前,那么在排序后A仍然应该在B之前,算法才能被称为是稳定的。例如,考虑一个包含姓名和年龄的列表:

代码语言:javascript
复制
[("Alice", 25), ("Bob", 25), ("Charlie", 20)]

如果排序算法是稳定的,那么在按年龄排序后,"Alice"和"Bob"的相对顺序不会改变:

代码语言:javascript
复制
[("Charlie", 20), ("Alice", 25), ("Bob", 25)]

但如果排序算法不稳定,"Alice"和"Bob"的相对顺序可能会在排序后改变:

代码语言:javascript
复制
[("Charlie", 20), ("Bob", 25), ("Alice", 25)]

在这种情况下,排序算法就被认为是不稳定的。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2024-07-03,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 小林coding 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Java
    • Jvm内存区域
      • 本地方法栈的运行场景
        • Native方法解释一下
          • 怎么保证多线程安全
            • HashTable数据结构,底层实现原理
              • Threadlocal作用,原理,具体里面存的key value是啥,会有什么问题,如何解决
              • Redis
                • Redis数据结构及各自应用
                  • Redis和数据库一致性的问题 怎么实现
                    • 分布式锁是如何设计的
                      • 解决缓存穿透的方法
                        • 布隆过滤器原理介绍一下
                          • Redis集群的模式了解吗 优缺点了解吗
                          • 计网
                            • TCP三次握手
                              • SYN的概念
                              • 算法
                                • 堆排序算法原理,稳定吗
                                相关产品与服务
                                云数据库 Redis
                                腾讯云数据库 Redis(TencentDB for Redis)是腾讯云打造的兼容 Redis 协议的缓存和存储服务。丰富的数据结构能帮助您完成不同类型的业务场景开发。支持主从热备,提供自动容灾切换、数据备份、故障迁移、实例监控、在线扩容、数据回档等全套的数据库服务。
                                领券
                                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档