【Java后端面试经历】我和阿里面试官的“又”一次“邂逅”(附问题详解)



大家好,我是 Guide哥,一个三观比主角还正的技术人。

承接上一篇深受好评的文章:《【Java 大厂真实面试经历】我和阿里面试官的一次“邂逅”(附问题详解)》 。时隔 n 个月,又一篇根据读者投稿的《5 面阿里,终获 offer》改编的 “Java 大厂真实面试经历” 文章来啦!希望这样形式的文章,你们能够喜欢,也希望你们可以从这篇文章中切实学到东西。


不同求职者的阿里面试经历因为面试官以及你的简历和能力的不同会有比较大的差异,但是在一些常见的问题上还是比较一致的。本篇文章的目的只是为了通过面试问答的形式,带着你去回顾和温习知识或者说是查漏补缺。

另外,考虑到尽量涵盖多一点的知识点,这篇文章并没有在一个问题上非常深入下去,阿里的面试大概率会在一个问题深入下去(也取决于你的能力和面试官)。

如果有任何不对或者需要完善的地方,请帮忙指出!Guide哥感激不尽!


本文内容概览:

操作系统

  1. 操作系统的内存管理机制了解吗?内存管理有哪几种方式?
  2. 分页机制和分段机制有哪些共同点和区别呢?
  3. 逻辑地址和物理地址
  4. 进程和线程的区别

多线程

  1. 为什么要使用多线程?使用多线程可能带来什么问题?
  2. 造成死锁的原因有哪些?如何避免线程线程死锁呢??
  3. Java 内存模型了解吗?volatile 有什么作用?sychronized 和 volatile 的区别?
  4. 用过 CountDownLatch 么?什么场景下用的?CompletableFuture 呢?

Netty

  1. 介绍一下自己对 Netty 的认识,为什么要用
  2. 通俗地说一下使用 Netty 可以做什么事情?
  3. 什么是 TCP 粘包/拆包,解决办法。Dubbo 在使用 Netty 作为网络通讯时候是如何避免粘包与半包问题?
  4. Netty 线程模型。
  5. 讲讲 Netty 的零拷贝?

废话不说话!二面和三面开始了。面试官拿着一个厚重的 Thinkpad 走过来啦!他那稀疏的头发,犹豫的眼神,一看就知道是技术方面专家级别的人物了。


操作系统

这部分的很多内容参考了《现代操作系统》第三版这本书。更多操作系统相关的面试题问题,见这篇文章:《我和面试官之间关于操作系统的一场对弈!写了很久,希望对你有帮助!》

内存管理机制主要是做什么?

👨‍💻 面试官: 操作系统的内存管理主要是做什么?

🙋 我: 操作系统的内存管理主要负责内存的分配与回收(malloc 函数:申请内存,free 函数:释放内存),另外地址转换也就是将逻辑地址转换成相应的物理地址等功能也是操作系统内存管理做的事情。

操作系统的内存管理机制了解吗?内存管理有哪几种方式?

👨‍💻 面试官: 操作系统的内存管理机制了解吗?内存管理有哪几种方式?

🙋 我: 这个在学习操作系统的时候有了解过。

简单分为连续分配管理方式非连续分配管理方式这两种。连续分配管理方式是指为一个用户程序分配一个连续的内存空间,常见的如 块式管理 。同样地,非连续分配管理方式允许一个程序使用的内存分布在离散或者说不相邻的内存中,常见的如页式管理段式管理

  1. 块式管理 :远古时代的计算机操系统的内存管理方式。将内存分为几个固定大小的块,每个块中只包含一个进程。如果程序运行需要内存的话,操作系统就分配给它一块,如果程序运行只需要很小的空间的话,分配的这块内存很大一部分几乎被浪费了。这些在每个块中未被利用的空间,我们称之为碎片。
  2. 页式管理 :把主存分为大小相等且固定的一页一页的形式,页较小,相对相比于块式管理的划分力度更大,提高了内存利用率,减少了碎片。页式管理通过页表对应逻辑地址和物理地址。
  3. 段式管理 :页式管理虽然提高了内存利用率,但是页式管理其中的页实际并无任何实际意义。段式管理把主存分为一段段的,每一段的空间又要比一页的空间小很多 。但是,最重要的是段是有实际意义的,每个段定义了一组逻辑信息,例如,有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。段式管理通过段表对应逻辑地址和物理地址。

👨‍💻面试官 :回答的还不错!不过漏掉了一个很重要的 段页式管理机制 。段页式管理机制结合了段式管理和页式管理的优点。简单来说段页式管理机制就是把主存先分成若干段,每个段又分成若干页,也就是说 段页式管理机制 中段与段之间以及段的内部的都是离散的。

🙋 :谢谢面试官!刚刚把这个给忘记了~

分页机制和分段机制对比

👨‍💻面试官分页机制和分段机制有哪些共同点和区别呢?

🙋

  1. 共同点
    • 分页机制和分段机制都是为了提高内存利用率,较少内存碎片。
    • 页和段都是离散存储的,所以两者都是离散分配内存的方式。但是,每个页和段中的内存是连续的。
  2. 区别
    • 页的大小是固定的,由操作系统决定;而段的大小不固定,取决于我们当前运行的程序。
    • 分页仅仅是为了满足操作系统内存管理的需求,而段是逻辑信息的单位,在程序中可以体现为代码段,数据段,能够更好满足用户的需要。

逻辑地址和物理地址

👨‍💻面试官 :你刚刚还提到了逻辑地址和物理地址这两个概念,我不太清楚,你能为我解释一下不?

🙋 我: em...好的嘛!我们编程一般只有可能和逻辑地址打交道,比如在 C 语言中,指针里面存储的数值就可以理解成为内存里的一个地址,这个地址也就是我们说的逻辑地址,逻辑地址由操作系统决定。物理地址指的是真实物理内存中地址,更具体一点来说就是内存地址寄存器中的地址。物理地址是内存单元真正的地址。

进程和线程

👨‍💻面试官: 好的!我明白了!那你再说一下:进程和线程的区别

🙋 我: 好的!下图是 Java 内存区域,我们从 JVM 的角度来说一下线程和进程之间的关系吧!

如果你对 Java 内存区域 (运行时数据区) 这部分知识不太了解的话可以阅读一下这篇文章:【修订完善版】面试又被 JVM 内存区域虐了?推荐你看看这篇文章!

jvm运行时数据区域

从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器虚拟机栈本地方法栈

总结: 线程是进程划分成的更小的运行单位,一个进程在其执行的过程中可以产生多个线程。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。

进程的调度算法

👨‍💻面试官你知道操作系统中进程的调度算法有哪些吗?

🙋 :嗯嗯!这个我们大学的时候学过,是一个很重要的知识点!

为了确定首先执行哪个进程以及最后执行哪个进程以实现最大 CPU 利用率,计算机科学家已经定义了一些算法,它们是:

  • 先到先服务(FCFS)调度算法 : 从就绪队列中选择一个最先进入该队列的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。
  • 短作业优先(SJF)的调度算法 : 从就绪队列中选出一个估计运行时间最短的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。
  • 时间片轮转调度算法 : 时间片轮转调度是一种最古老,最简单,最公平且使用最广的算法,又称 RR(Round robin)调度。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。
  • 多级反馈队列调度算法 :前面介绍的几种进程调度的算法都有一定的局限性。如短进程优先的调度算法,仅照顾了短进程而忽略了长进程 。多级反馈队列调度算法既能使高优先级的作业得到响应又能使短作业(进程)迅速完成。,因而它是目前被公认的一种较好的进程调度算法,UNIX 操作系统采取的便是这种调度算法。
  • 优先级调度 :为每个流程分配优先级,首先执行具有最高优先级的进程,依此类推。具有相同优先级的进程以 FCFS 方式执行。可以根据内存要求,时间要求或任何其他资源要求来确定优先级。

多线程

为什么要使用多线程?

👨‍💻面试官为什么要使用多线程?使用多线程可能带来什么问题?

🙋 :使用多线程目的就是为了能提高程序的执行效率提高程序运行速度。如果多线程使用不当,不仅不会提高程序的执行速度,可能会遇到很多问题,比如:线程不安全、内存泄漏、死锁等等。

多线程死锁

👨‍💻面试官那你说说造成线程死锁的原因有哪些吧?可以用代码给我演示一下不?

🙋 :我艹!有点难度啊!还好我看了 《JavaGuide 面试突击版》,不然不是要 gg 了么!

我的内心有些波动,表情开始正经起来了

线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。

线程死锁示意图

下面通过一个例子来说明线程死锁,代码模拟了上图的死锁的情况 (代码来源于《并发编程之美》):

public class DeadLockDemo {
    private static Object resource1 = new Object();//资源 1
    private static Object resource2 = new Object();//资源 2

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "线程 1").start();

        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + "get resource1");
                }
            }
        }, "线程 2").start();
    }
}

Output

Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1

线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。上面的例子符合产生死锁的四个必要条件。

学过操作系统的朋友应该都知道产生死锁必须具备以下四个条件:

  1. 互斥条件 :该资源任意一个时刻只由一个线程占用。
  2. 请求与保持条件 :一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件 :线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  4. 循环等待条件 :若干进程之间形成一种头尾相接的循环等待资源关系。

👨‍💻面试官 :那么问题来啦!如何避免线程线程死锁呢? 如何让你上面写的代码变为不会产生死锁?

🙋

我上面说了产生死锁的四个必要条件,为了避免死锁,我们只要破坏产生死锁的四个条件中的其中一个就可以了。现在我们来挨个分析一下:

  1. 破坏互斥条件 :这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
  2. 破坏请求与保持条件 :一次性申请所有的资源。
  3. 破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  4. 破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

我们对线程 2 的代码修改成下面这样就不会产生死锁了。

        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "线程 2").start();

Output

Thread[线程 1,5,main]get resource1
Thread[线程 1,5,main]waiting get resource2
Thread[线程 1,5,main]get resource2
Thread[线程 2,5,main]get resource1
Thread[线程 2,5,main]waiting get resource2
Thread[线程 2,5,main]get resource2

Process finished with exit code 0

我们分析一下上面的代码为什么避免了死锁的发生?

线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。

从实现一个线程安全的单例模式看 synchronized 和 volatile 的使用

👨‍💻面试官单例模式了解吗?你用双重检验+锁的方式实现一个吧!

🙋 :好的好的!

双重校验锁实现对象单例(静态方法+synchronized 关键字)

public class Singleton {

    private static Singleton uniqueInstance;

    private Singleton() {
    }

    public static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                //对象为空才去创建(懒加载)
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();//非原子操作。注意!!!
                }
            }
        }
        return uniqueInstance;
    }
}

👨‍💻面试官 :可以简单解释一下上面的代码吗?

🙋 :在上面的代码中,我们首先判断 uniqueInstance是否为空,如果不为空直接返回。如果同时有多个线程都发现``uniqueInstance==null为空的话,就会去创建这个对象,但是创建部分的代码块使用了synchronized关键字加锁,这样就保证了某一时刻只能有一个线程可以执行创建对象这部分代码块,也就保证了当前系统只存在一个Singleton`对象。

👨‍💻面试官 :但是,你上面写的代码在多线程下会出现问题的。你再检查一下你上面写的代码。

🙋 :思考 🤔 许久....我还是没有发现问题呢!

👨‍💻面试官 :我来给你说一下吧!uniqueInstance = new Singleton() 不是原子操作,这段代码可以简单分为下面三步执行:

  1. 为 uniqueInstance 分配内存空间;
  2. 初始化 uniqueInstance;
  3. 将 uniqueInstance 指向分配的内存地址

由于但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 a 执行了 1 和 3,此时 线程 b 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化,所以就会导致空指针异常。

👨‍💻面试官 :那你说说有没有解决办法?有没有想到多线程中哪个常用的关键字?

🙋 :哦哦!我记起来了!使用 volatile 修饰变量就可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。 我们只需要将上面的代码稍作修改,就可以在多线程环境下使用了!代码修改如下:

 private volatile static Singleton uniqueInstance;

从 CPU 缓存模型聊到 JMM(Java 内存模型)

👨‍💻面试官 :既然聊到了 volatile 关键字。那你说说自己对于 Java 内存模型(JMM) 的了解吧!还有,volatile 除了防止 JVM 的指令重排,还有什么其他作用吗?

CPU 缓存模型

🙋 :面试官我给你讲,说到这个问题呢!我们先要从 CPU 缓存模型 说起!

为什么要弄一个 CPU 高速缓存呢?

类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。

我们甚至可以把 内存可以看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。

总结:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。

为了更好地理解,我画了一个简单的 CPU Cache 示意图如下(实际上,现代的 CPU Cache 通常分为三层,分别叫 L1,L2,L3 Cache):

CPU Cache

CPU Cache 的工作方式:

先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !比如我执行一个 i++操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 1++运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。

CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议或者其他手段来解决。

JMM(Java 内存模型)

在 JDK1.2 之前,Java 的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致

JMM(Java内存模型)

要解决这个问题,就需要把变量声明为volatile,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

所以,volatile 关键字 除了防止 JVM 的指令重排 ,还有一个重要的作用就是保证变量的可见性。

volatile关键字的可见性

synchronized 关键字介绍

👨‍💻面试官synchronized 关键字了解吗?

🙋 :synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized 关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

另外,在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

synchronized vs volatile

👨‍💻面试官 :那你说说 synchronized 关键字和 volatile 关键字的区别吧!

🙋 synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以volatile 性能肯定比 synchronized 关键字要好。但是volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

用过 CountDownLatch 么?什么场景下用的?

👨‍💻面试官用过 CountDownLatch 么?什么场景下用的?

🙋 CountDownLatch 的作用就是 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。之前在项目中,有一个使用多线程读取多个文件处理的场景,我用到了 CountDownLatch 。具体场景是下面这样的:

我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。

为此我们定义了一个线程池和 count 为 6 的CountDownLatch对象 。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch对象的 await()方法,直到所有文件读取完之后,才会接着执行后面的逻辑。

伪代码是下面这样的:

public class CountDownLatchExample1 {
  // 处理文件的数量
  private static final int threadCount = 6;

  public static void main(String[] args) throws InterruptedException {
    // 创建一个具有固定线程数量的线程池对象(推荐使用构造方法创建)
    ExecutorService threadPool = Executors.newFixedThreadPool(10);
    final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
    for (int i = 0; i < threadCount; i++) {
      final int threadnum = i;
      threadPool.execute(() -> {
        try {
          //处理文件的业务操作
          ......
        } catch (InterruptedException e) {
          e.printStackTrace();
        } finally {
          //表示一个文件已经被完成
          countDownLatch.countDown();
        }

      });
    }
    countDownLatch.await();
    threadPool.shutdown();
    System.out.println("finish");
  }

}

👨‍💻面试官 :有没有可以改进的地方呢?

🙋 :可以提示一下具体的改进方向不?

👨‍💻面试官 :Java 8 的新增加的一个多线程处理的类。

🙋 :是 CompletableFuture 吧!这个确实可以通过这个类来改进。Java8 的 CompletableFuture 提供了很多对多线程友好的方法,使用它可以很方便地为我们编写多线程程序,什么异步、串行、并行或者等待所有线程执行完任务什么的都非常方便。

CompletableFuture<Void> task1 =
  CompletableFuture.supplyAsync(()->{
    //自定义业务操作
  });
......
CompletableFuture<Void> task6 =
  CompletableFuture.supplyAsync(()->{
    //自定义业务操作
  });
......
 CompletableFuture<Void> headerFuture=CompletableFuture.allOf(task1,.....,task6);

  try {
    headerFuture.join();
  } catch (Exception ex) {
    ......
  }
System.out.println("all done. ");

👨‍💻面试官 :嗯嗯!大概意思说清楚了,不过代码还可以接续优化,当任务过多的时候,把每一个 task 都列出来不太现实,可以考虑通过循环来添加任务。

//文件夹位置
List<String> filePaths = Arrays.asList(...)
// 异步处理所有文件
List<CompletableFuture<String>> fileFutures = filePaths.stream()
        .map(filePath -> doSomeThing(filePath))
        .collect(Collectors.toList());
// 将他们合并起来
CompletableFuture<Void> allFutures = CompletableFuture.allOf(
        fileFutures.toArray(new CompletableFuture[fileFutures.size()])
);

Netty

Netty 介绍

👨‍💻面试官介绍一下自己对 Netty 的认识。

🙋 :简单用 3 点概括一下 Netty 吧!

  1. Netty 是一个基于 NIO 的 client-server(客户端服务器)框架,使用它可以快速简单地开发网络应用程序。
  2. 它极大地简化并简化了 TCP 和 UDP 套接字服务器等网络编程,并且性能以及安全性等很多方面甚至都要更好。
  3. 支持多种协议如 FTP,SMTP,HTTP 以及各种二进制和基于文本的传统协议。

用官方的总结就是:Netty 成功地找到了一种在不妥协可维护性和性能的情况下实现易于开发,性能,稳定性和灵活性的方法。

为什么要用 Netty?

👨‍💻面试官为什么要用?

🙋 :因为 Netty 具有下面这些优点,并且相比于 JDK 自带的 NIO 相关 API 更加易用。

  • 统一的 API,支持多种传输类型,阻塞和非阻塞的。
  • 简单而强大的线程模型。
  • 自带编解码器解决 TCP 粘包/拆包问题。
  • 自带各种协议栈。
  • 真正的无连接数据包套接字支持。
  • 比直接使用 Java 核心 API 有更高的吞吐量、更低的延迟、更低的资源消耗和更少的内存复制。
  • 安全性不错,有完整的 SSL/TLS 以及 StartTLS 支持。
  • 社区活跃
  • 成熟稳定,经历了大型项目的使用和考验,而且很多开源项目都使用到了 Netty 比如我们经常接触的 Dubbo、RocketMQ 等等。
  • ......

Netty 应用场景

👨‍💻面试官通俗地说一下使用 Netty 可以做什么事情?

🙋 :凭借自己的了解,简单说一下吧!理论上 NIO 可以做的事情 ,使用 Netty 都可以做并且更好。Netty 主要用来做网络通信 :

  1. 作为 RPC 框架的网络通信工具 :我们在分布式系统中,不同服务节点之间经常需要相互调用,这个时候就需要 RPC 框架了。不同服务指点的通信是如何做的呢?可以使用 Netty 来做。比如我调用另外一个节点的方法的话,至少是要让对方知道我调用的是哪个类中的哪个方法以及相关参数吧!
  2. 实现一个自己的 HTTP 服务器 :通过 Netty 我们可以自己实现一个简单的 HTTP 服务器,这个大家应该不陌生。说到 HTTP 服务器的话,作为 Java 后端开发,我们一般使用 Tomcat 比较多。一个最基本的 HTTP 服务器可要以处理常见的 HTTP Method 的请求,比如 POST 请求、GET 请求等等。
  3. 实现一个即时通讯系统 :使用 Netty 我们可以实现一个可以聊天类似微信的即时通讯系统,这方面的开源项目还蛮多的,可以自行去 Github 找一找。
  4. 实现消息推送系统 :市面上有很多消息推送系统都是基于 Netty 来做的。
  5. ......

TCP 粘包/拆包以及解决办法

👨‍💻面试官什么是 TCP 粘包/拆包,解决办法?

🙋 :TCP 粘包/拆包 就是你基于 TCP 发送数据的时候,出现了多个字符串“粘”在了一起或者一个字符串被“拆”开的问题。比如你多次发送:“你好,你真帅啊!哥哥!”,但是客户端接收到的可能是下面这样的:

解决办法:

  1. Netty 自带的解码器
  2. 自定义序列化编解码器

这篇文章中不详细分析 TCP 粘包/拆包问题,后面会在我的 《Netty 实战+手写一个简单的 RPC 框架》中介绍到。

Netty 的零拷贝

👨‍💻面试官讲讲 Netty 的零拷贝?

🙋

维基百科是这样介绍零拷贝的:

零复制(英语:Zero-copy;也译零拷贝)技术是指计算机执行操作时,CPU 不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省 CPU 周期和内存带宽。

在 OS 层面上的 Zero-copy 通常指避免在 用户态(User-space)内核态(Kernel-space) 之间来回拷贝数据。而在 Netty 层面 ,零拷贝主要体现在对于数据操作的优化。

Netty 中的零拷贝体现在以下几个方面:

  1. Netty 通过 DirectByteBuffer 可以使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。
  2. 使用 Netty 提供的 CompositeByteBuf 类, 可以将多个ByteBuf 合并为一个逻辑上的 ByteBuf, 避免了各个 ByteBuf 之间的拷贝。
  3. ByteBuf 支持 slice 操作, 因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf, 避免了内存的拷贝。
  4. 通过 FileRegion 包装的FileChannel.tranferTo 实现文件传输, 可以直接将文件缓冲区的数据发送到目标 Channel, 避免了传统通过循环 write 方式导致的内存拷贝问题.

Netty 线程模型

👨‍💻面试官Netty 线程模型了解么?

🙋 :大部分网络框架都是基于 Reactor 模式设计开发的。

Reactor 模式基于事件驱动,采用多路复用将事件分发给相应的 Handler 处理,非常适合处理海量 IO 的场景。

Netty 主要靠 NioEventLoopGroup 线程池来实现具体的线程模型的 。

我们实现服务端的时候,一般会初始化两个线程组:

  1. bossGroup :接收连接。
  2. workerGroup :负责具体的处理,交由对应的 Handler 处理。

下面我们来详细看一下 Netty 中的线程模型吧!

1.单线程模型

一个线程需要执行处理所有的 accept、read、decode、process、encode、send 事件。对于高负载、高并发,并且对性能要求比较高的场景不适用。

对应到 Netty 代码是下面这样的

使用 NioEventLoopGroup 类的无参构造函数设置线程数量的默认值就是 CPU 核心数 *2

  //1.eventGroup既用于处理客户端连接,又负责具体的处理。
  EventLoopGroup eventGroup = new NioEventLoopGroup(1);
  //2.创建服务端启动引导/辅助类:ServerBootstrap
  ServerBootstrap b = new ServerBootstrap();
            boobtstrap.group(eventGroup, eventGroup)
            //......

2.多线程模型

一个 Acceptor 线程只负责监听客户端的连接,一个 NIO 线程池负责具体处理:accept、read、decode、process、encode、send。满足绝大部分应用场景,并发连接量不大的时候没啥问题,但是遇到并发连接大的时候就可能会出现问题,成为性能瓶颈。

对应到 Netty 代码是下面这样的:

// 1.bossGroup 用于接收连接,workerGroup 用于具体的处理
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
  //2.创建服务端启动引导/辅助类:ServerBootstrap
  ServerBootstrap b = new ServerBootstrap();
  //3.给引导类配置两大线程组,确定了线程模型
  b.group(bossGroup, workerGroup)
    //......

3.主从多线程模型

从一个 主线程 NIO 线程池中选择一个线程作为 Acceptor 线程,绑定监听端口,接收客户端连接的连接,其他线程负责后续的接入认证等工作。连接建立完成后,Sub NIO 线程池负责具体处理 I/O 读写。如果多线程模型无法满足你的需求的时候,可以考虑使用主从多线程模型 。

// 1.bossGroup 用于接收连接,workerGroup 用于具体的处理
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
  //2.创建服务端启动引导/辅助类:ServerBootstrap
  ServerBootstrap b = new ServerBootstrap();
  //3.给引导类配置两大线程组,确定了线程模型
  b.group(bossGroup, workerGroup)
    //......

哪些开源项目用到了 Netty?

👨‍💻面试官哪些开源项目用到了 Netty?

我们平常经常接触的 Dubbo、RocketMQ、Elasticsearch、gRPC 等等都用到了 Netty。

可以说大量的开源项目都用到了 Netty,所以掌握 Netty 有助于你更好的使用这些开源项目并且让你有能力对其进行二次开发。

实际上还有很多很多优秀的项目用到了 Netty,Netty 官方也做了统计,统计结果在这里:https://netty.io/wiki/related-projects.html 。

Reference

  • 《计算机操作系统—汤小丹》第四版
  • netty 学习系列二:NIO Reactor 模型 & Netty 线程模型:https://www.jianshu.com/p/38b56531565d
  • 《Netty 实战》
  • 对于 Netty ByteBuf 的零拷贝(Zero Copy) 的理解:https://www.cnblogs.com/xys1228/p/6088805.html



我的 75k Star 开源项目 JavaGuide 总结而成的PDF版本的《JavaGuide面试突击版》,公众号后台回复“面试突击”即可获取最新版本!安排!





 
阿里程序员常用的 15 款开发者工具
 
听说你要接私活?Guide连夜整理了5个开源免费的Java项目快速开发脚手架。
 
优质文章分类整理|Guide哥直呼:看了这些技术文章我之后飘了~




好文让朋友知道你“在看”