主题
其他常见大厂面试题
JVM:
1.jre、jdk、jvm 的关系:
jdk 是最小的开发环境,由 jre++java 工具组成。
jre 是 java 运行的最小环境,由 jvm+ 核心类库组成。
jvm 是虚拟机,是 java 字节码运行的容器,如果只有 jvm 是无法运行 java 的,因为缺少了核心类库。
2.java 的运行时数据区有哪些:
java 运行时数据区分为 5 块:
线程共享的模块:方法区和堆空间
非线程共享的模块:程序计数器,虚拟机栈,本地方法栈。
方法区:存储已经被虚拟机加载的类信息,静态变量,常量,即时编译器编译后的代码等数据。主要可以分为两类:类信息(class 文件信息,class 内部的数据结构),运行时常量池(在 1.7 版本部分被迁移至堆空间了,其中有字符串常量池,静态变量。原因:永久代的空间评估很难预测,太少容易出现 OOM,太大浪费空间,所以把占用空间最多的字符串常量池,静态变量迁移到了堆空间,可以有效的避免永久代出现 OOM 的情况。在 1.8 的时候方法区就全部迁移到了元数据,也就是 no-heap,使用本地内存来存储,这样子就不需要分配空间,而是只会限制于本地内存的大小了,可以设置参数,如果没有设置则是动态的扩容)
堆空间:大部分对象存放的区域,包括数组
程序计数器:当前现成所执行的字节码的行号指示器:程序计数器中只存储当前线程执行程序的行号,一个类指针的数据结构。字节码解析器就是通过改变这个值,来确定下一个执行的字节码指令
虚拟机栈:java 方法执行的内存模型,java 执行的方法都是一个栈帧,记录了方法的局部变量表、操作数栈、动态连接、方法出口等,递归调用的存储情况(当一个方法需要调用另外一个方法时,会把当前的方法执行的情况保存起来,压入栈,弹出形参和方法返回地址,并重新创建一个新的方法的栈帧),其中局部变量表、操作数栈都是在编译期就能确定的。
本地方法栈:本地方法的调用栈
3.什么是运行时常量池:
运行常量池是方法区存放字面量和符合引用的地方,区别于 class 的信息常量池。一般是编译期就可以产生,但是并没有限制只能编译期产生,也可以在运行时产生,最多使用的就是 String 的 intern 方法。
4.jvm 是如何给对象分配内存:
java 给对象分配内存的方式有两种,一种是指针碰撞,这种方式主要是在于使用的内存空间是连续的,只要偏移指针就可以得到分配空间;另外一种是空间列表:这种适合不连续的内存空间,需要额外维护一张内存空闲列表,来确定那部分内存是空闲的。一般来说带有 compact 操作的垃圾回收算法会使用方式一,常见的就是 serial,带有复制算法。而 cms 这种基于清除算法的,都是方式 2。在分配过程中可能会出现并发问题,一个线程分配了 A 部分内存,另一个线程也是分配了这部分,那么就会被覆盖。jvm 采用两种方式来避免,一个是分配的时候使用 CAS 的方式来保证操作的原子性;还有一种方式是给每个线程分配一个内存空间(TLAB),这样子就直接在自己的内存空间分配,如果 TLAB 不够,则在使用同步方式,重新分配
5.对象是如何创建的:
对象创建分为如下几步:
检查对象的类信息是否存在,如果不存在,则加载类信息
分配内存
设置对象的基本信息:比如类的元数据指向,哈希码,分代年龄等,这些信息会存放在对象头中
执行构造函数
其中 a,b,c 三步是对于 jvm 而言的,创建完成后,jvm 就认为对象创建完成了,但是对于我们程序而言,才只是开始,d 就是执行属于我们程序的逻辑。
6.对象的构成:
一个对象分为 3 个区域:对象头、实例数据、对齐填充
对象头:主要是包括两部分,1.存储自身的运行时数据比如 hash 码,分代年龄,锁标记等(但是不是绝对哦,锁状态如果是偏向锁,轻量级锁,是没有 hash 码的。。。是不固定的)2.指向类的元数据指针。还有可能存在第三部分,那就是数组类型,会多一块记录数组的长度(因为数组的长度是 jvm 判断不出来的,jvm 只有元数据信息)
实例数据:会根据虚拟机分配策略来定,分配策略中,会把相同大小的类型放在一起,并按照定义顺序排列(父类的变量也会在哦)
对齐填充:这个意义不是很大,主要在虚拟机规范中对象必须是 8 字节的整数,所以当对象不满足这个情况时,就会用占位符填充
7.对象的访问定位:
java 的对象访问定位分为两种方式:句柄和直接指针。
句柄方式主要是存储稳定的句柄地址,jvm 会创建一个句柄池,一个 reference 会对应对象的句柄池地址(句柄池分为两个指针:对象实例指针,对象类型指针)
直接指针主要是快速,可以直接指向对象的地址(对象会分为两块,一个对象类型指针,和实例数据)
8.垃圾回收算法:
jvm 常用的垃圾回收算法有:复制,标记-清除,标记-整理,分代回收。
复制算法:没有碎片化,但是需要把内存分为 2 个或以上部分,空余出一个区域,可以进行转移
标记-清除:会先把需要回收的对象进行标记,然后清除。这个算法主要的问题是会产生内存碎片。
标记-整理:先进行标记,再把存活的对象放到一边,清除边界外的数据
9.如果判断一个对象是否存活:
一般判断对象是否存活有两种算法,一种是引用计数,另外一种是可达性分析。在 java 中主要是第二种
10.java 是根据什么来执行可达性分析的:
根据 GC ROOTS。GC ROOTS 可以的对象有:虚拟机栈中的引用对象,方法区的类变量的引用,方法区中的常量引用,本地方法栈中的对象引用。
11.什么是安全点?
安全点是用于辅助 jvm 进行 GC ROOTS 的枚举,以及 GC 过程中,作为线程停顿的特定位置。正常来说 GC 会需要 STW 的时候,都是需要线程进入一个暂停的状态,啥时候可以暂停?就是安全点了。一般来说让线程暂停可以分两种方式:抢占式中断和主动中断,抢占中断是由 jvm 控制,在一瞬间,暂停全部线程,如果发现线程不是安全点,则恢复线程(这玩意概率有点低啊);主动中断:则是需要线程自己进行,jvm 在 gc 时候,设计一个标记,然后由线程到底安全点的时候去查询是否处于 gc 过程,如果是,则暂停。
12.那什么是安全区呢:
安全区指的是一段代码中并不会出现引用关系的变化,所以成为安全区,这个时候也是可以执行逻辑的,线程不会中断,当进入安全区的时候,线程会设置标示,说明自己是安全的,当要出临界点的时候,回去查询 GC 是否完成,如果还没有,则暂停线程。
13.GC ROOTS 是怎么进行枚举的:
一般来说,有两种方式可以进行枚举:一种是遍历,另外一种是维护一个映射表。遍历的方式称为保守式,jvm 没有记录一个类型是什么,所以它只能遍历需要检查应用的地方,判断这个指针是不是指向 GC 堆的指针(检查上下午,或者对齐填充),这种方式有个问题:无法判断疑似指针,就会被放过,而且也是因为疑似指针,它就不能被移动(除非使用句柄池的方式)。外部维护一个映射表方式比较简单,因为对象本身就是带有类型信息的(反射),所以只要有足够的信息,GC 就能判断出这个对象那部分是引用(这个会在编译的时候确定,类加载的时候),jvm 使用了 OOPMap 的方式来支持这个,只要递归遍历这些 oopMap 就可以枚举出来。
14.什么是 OOPMAP:
OopMap 记录了栈上本地变量到堆上对象的引用关系,在编译后,只有变量在帧上的位置,OOPMap 就是映射这个位置和对象的关系。JNI 在编译的时候会在特定的位置安置 oopMap,这样子线程执行到这个位置的时候,就可以更新这个关系(为什么?因为如果在每个指令后去更新,就太损耗性能了,所以在一些特定的点上做这个操作),这个点就是安全点。
15.安全点一般在什么位置:
一般会出现在循环的末尾,方法返回前,可能出现异常的地方。
16.内存是如何分代的:
java 分为新生代和老年代,一般是 1:2 的存在。当对象新建的时候一般是在新生代中,除非是大对象,会直接达到老年代,老年代一般是一些比较稳定的对象,比如大对象,以及经历了多次 gc 的对象(默认 15 次),还有一种情况,当新生代中相同年龄的对象个数大于一半的时候,会把大于等于这个年龄的这部分也升级为老年代
17.什么时候会出现 MGC 和 FGC:
MGC 又称为 YGC,主要是发生在新生代,当 Eden 区满的时候会被触发。FGC 是全堆范围的 GC,当堆可用空间只有 20% 的时候,则会被触发。
18.YGC 只会出现在新生代嘛:
不一定的,因为新生代的对象有可能会持有老年代对象的引用,所以会把这部分一起给算进去,但是不会扫描整个老年代,而是由 RememberedSet 来记录不同年龄代的对象引用,直接把这部分弄出来就好了。
19.垃圾收集器有哪些:
新生代:serial 收集器 (单线程),parNew 收集器(多线程,唯一可以和 CMS 连用),Parallel Scavenge 收集器(吞吐量为目的);
老年代:serial old 收集器,parallel old 收集器 CMS 收集器
不分区的 G1 收集器,以及最新的 ZGC 收集器
20.CMS 收集器的工作原理:
CMS 收集器可以分为 4 步或者 6 步:
初次标记(STW)
并发标记
重新标记(STW)
并发清除
其中,在 b,c 直接还会出现预清理和可中断预清理两个步骤,因为对于 b 步骤而言,已经标记了部分不可达的对象,而对象如果一旦被标记不可达,就再也不会变成可达了,所以这部分如果在内存不足的情况,可以在进行一次清理,而预清理主要是把上一阶段被标记的对象,重新有变更的进行标记。可中断预清理主要是处理 From 和 To 区的对象,标记可达的老年代对象。
CMS 收集器清理算法主要是标记-清除,因为是并发清除的。。。所以不能动指针。
21.G1 收集器的工作原理:
G1 收集器的步骤分为:
初次标记(STW)
并发标记
最终标记(STW)
筛选回收(STW)
G1 收集器和 CMS 不一样,G1 收集器的时候已经不是物理隔离分代了,而是把内存分成一个个块。它能实现预测停顿,会跟踪每一个块里面的垃圾堆积和价值大小(回收空间大小和所需的时间),由于把内存分块了,所以只要回收这个块里面,不会进行全部的堆扫描。如果出现跨块的引用,G1 收集器则基于 rememberSet 来进行管理。在对象进行 reference 类型进行写操作的时候,就会产生一个 write barrier,暂时中断写入,去检查是否有跨块情况,如果有就记录 rememberSet 中。
22.知道 G1 里面的 STAB 嘛:
STAB 是 GC 开始时活着的对象的一个快照。它是通过 Root Tracing 得到的,作用是维持并发 GC 的正确性。在并发标记的过程中,如果一个灰对象没有遍历完,删除了一个白对象到灰对象之间的直接引用或者间接引用,就会被漏标。如果是给黑对象一个白对象的引用,则也是会出现的。new 出来的对象是白对象,会通过使用 TAMS 来避免这个情况,Region 中有两个 top-at-mark-start(TAMS)指针,分别为 prevTAMS 和 nextTAMS。在 TAMS 以上的对象是新分配的,这是一种隐式的标记。但是第一种情况则是由 STAB 来破坏的,会由一个 write barrier 来记录指针的变更。
23.加载类的方式有哪些:
加载类的方式分为两种:隐式加载和显式加载。隐式加载一般是 new 指令,而显式加载是反射,如 Class.forName 还有 classLoader.load。
24.类加载的过程:
类加载的过程分为加载->链接>初始化。而连接有分 3 个步骤:验证->准备->解析
加载:由类的全限定名来获取定义类的二进制流,把二进制流代表的静态存储结构转化为方法区的运行时的数据结构,生成一个 Class 对象,作为方法区的入口
验证:会校验字节流中包含的信息符合当前虚拟机的要求,有文件格式的校验:比如魔术,版本号等,元数据的校验:对数据类型的校验,比如继承呀,抽象等是否符合要求,字节码验证:主要是对类的方法体进行验证,符号引用的验证:类对自身以外(常量池中的各种符号引用)的信息进行匹配性校验,比如:符号引用的类全限名是否能找到类的实例等
准备:这阶段主要是把类的静态变量进行初始化,把对象变为默认值,分配空间。当然如果是 final 修饰的,则会直接赋值。
解析:把符号引用替换为直接引用.
初始化:会执行<clinit>指令,按照静态变量和静态块的顺序进行执行,这个方法是线程安全的,会加锁。这就是为什么静态块只能访问顺序在其前面的变量,后面的变量能赋值,但是不能访问的原因,因为后面的变量只有分配类空间和原始值,访问是错误的。这边还有个很特别的现象,那就是如果在 static 块中给后面的变量赋值,是会被后面变量覆盖的(除非后面变量没有赋值,只是声明)。
25.什么是双亲委派模型:
在类加载中,分为 3 大类加载器:bootstrap 加载器,extension 加载器,Application 加载器,还有我们自定义的类加载器, 除了顶层的 bootstrap 加载器都要有一个父加载器,且每次加载都要优先由父加载器去加载,如果父加载器装载不了,则判断是否自身能装载。
26.双亲委派模型有啥作用:
保证 java 基础类在不同的环境还是同一个 Class 对象,避免出现了自定义类覆盖基础类的情况,导致出现安全问题。还可以避免类的重复加载。
27.如何打破双亲委派模型:
可以使用线程上下文,线程上下文默认会继承父类加载器,一般情况下是应用类加载器。
28.tomcat 是如何打破双亲委派模型:
tomcat 有着特殊性,它需要容纳多个应用,需要做到应用级别的隔离,而且需要减少重复性加载,所以划分为:/common 容器和应用共享的类信息,/server 容器本身的类信息,/share 应用通用的类信息,/WEB-INF/lib 应用级别的类信息。整体可以分为:boostrapClassLoader->ExtensionClassLoader->ApplicationClassLoader->CommonClassLoader->CatalinaClassLoader(容器本身的加载器)/ShareClassLoader(共享的)->WebAppClassLoader。虽然第一眼是满足双亲委派模型的,但是不是的,因为双亲委派模型是要先提交给父类装载,而 tomcat 是优先判断是否是自己负责的文件位置,进行加载的。
并发编程:
1.什么是线程,进程?
进程是分配资源的最小单位,一个进程相当于是一个应用程序,一个进程至少有一个线程
线程是 cpu 执行的最小单位。可以提高进程的处理顺序,并发执行
2.线程的实现方式?
线程的实现方式一般有三种:内核线程,用户线程创建以及(用户线程和轻量级进程混合创建)。
内核线程:直接由操作系统内核支持的线程,由内核来完成线程的切换
用户线程:非内核线程,都是用户线程。
3.线程的调度方式有哪些:
线程的调度方式分为两类:协同式和抢占式。
协同式是有线程本身来控制的,等待自己运行完成则切换,
抢占式是由操作系统来进行调度,系统会给每个线程分配时间片,一旦时间片执行完,就需要让位。
4.线程的状态有哪些:
线程的状态有 6 个:新建,运行,阻塞,等待,超时等待,结束。
新建:线程刚刚创建的时候,但是并没有准备执行
运行:就绪和运行中两个状态
阻塞:等待锁的释放
等待:等待其他线程的通知,不会被分配 cpu 空间,可能无期限的等待下去 wait(),join() 方法
超时等待:不会被分配 cpu 空间,但是不会无期限等待下去,等待一定时间,就会被重新唤醒。sleep(),有时间限制的 wait 或者 join 方法
结束:运行结束
5.JMM(java 内存模型):
在 java 中线程->工作内存->save/load 等同步指令->主内存。相对于操作系统的内存模型很相似,cpu->缓存 1,缓存 2->内存一致性协议->主内存。
6.为什么线程状态中没有中断状态:
线程中断是线程间的一个协程方式,并不是线程的一种状态。并不是真的中断了线程,而是设置了一个中断标示。有线程自己去处理,一般会有等待,或者异常的方式(那线程就真的结束了。。。)
可以用线程的 interrupte() 设置中断标示,isInterrrupted() 判断是否中断(不会重制),interrupted() 方法会检测是否中断状态,但是如果是中断,则会清除这个标记。
7.什么是死锁,活锁:
死锁是两个或两个及以上线程因为资源抢占而出现的永久性等待的情况。
活锁是线程因为某些条件没有满足,而一直重试,一直失败的情况。
8.产生死锁的原因有哪些,怎么避免死锁:
死锁产生的条件:
互斥
资源占用不释放
需要请求其他资源
循环等待
如果要避免死锁,则需要打破上面条件中的一个,一般来说比较好解决的是 b 和 d,可以在请求资源的时候设置一个超时时间,如果一直获取不到,则释放自己占用的资源,重新尝试。或者就是在一开始就获取全部资源,比如 reentrantlock 就可以用 tryLock 的方式来获取资源,只要有一个失败,则释放全部资源。
9.什么是守护线程:
守护线程是一种服务线程,jvm 在关闭的时候不会等待守护线程的关闭,只要其他的用户线程关闭后,就会关闭,而守护线程也会被失效(因为没有可以服务的线程了),最常见的就是垃圾回收线程。守护线程的设置需要在调用 start 方法前设置,否则无效
10.volatile 关键词的作用是什么:
volatile 关键词主要作用是保证变量线程间可见,以及避免指令重排序的情况。
11.volatile 如何保证线程间可见和避免指令重排:
volatile 可见性是有指令原子性保证的,在 jmm 中定义了 8 类原子性指令,比如 write,store,read,load。而 volatile 就要求 write-store,load-read 成为一个原子性操作,这样子可以确保在读取的时候都是从主内存读入,写入的时候会同步到主内存中(准确来说也是内存屏障),
指令重排则是由内存屏障来保证的,由两个内存屏障,一个是编译器屏障:阻止编译器重排,保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行。第二个是 cpu 屏障:sfence 保证写入,lfence 保证读取,lock 类似于锁的方式。java 多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个 lock 指令,就是增加一个完全的内存屏障指令。
12.锁的种类有哪些:
常用的锁有:悲观锁/乐观锁,自旋锁,重入锁/不可重入锁,公平锁/非公平锁等
13.你使用过哪些锁:
最多使用的是悲观锁,synchronized 和 reentrantlock,乐观锁,自旋锁使用的原子类工具。以及 ReentrantReanWritelock。
14.说一下 synchronized 和 reentrantlock 的区别:
两种都是互斥锁,一般来说 synchronized 能使用的地方 reentrantlock 都能使用,synchronized 是关键词,是有 jvm 来实现功能,reentrantlock 是 api 包,是有开发人员自己的逻辑,故此可以实现公平锁和非公平锁等其他额外的功能,synchronized 加锁,解锁是隐式的,添加关键词就好来,reentrantlock 是显式的,需要手动的调用 lock 方法或者 unlock 方法来加锁/释放锁。
15.synchronized 的实现原理:
synchronized 有三种使用用法,实现方式都不相同:修饰代码块,会在指令上面增加 monitorEnter 和 monitorExit 来限制锁。如果修饰的是方法,则是加锁在当前实例上。如果修饰的是静态方法,获取的是对应的类实例。但是三种方式主要是基于 minitor 对象(基于操作系统的 muntexLock 来实现的)来实现的,而 java 对象天生是 minitor(这个也就是为什么加锁是加在对象上面的了。。。),我们知道对象实例分为 3 个部分:对象头,实例数据,填充数据,在对象头中会存放一个指向 ObjectMinitor 的指针(偏向锁是没有的,轻量级锁也是没有的。。。),ObjectMinitor 是有一个 owner 和一个计数器,以及其他的队列的,owner 表示是否占用线程,指向了获取锁的线程,计数器是代表了重入的次数,队列中有个队列是用来存储等待锁的线程队列(特么和 AQS 差不多啊)
16.reentrantlock 的实现原理:
reentrantlock 的核心实现方式就是 AQS,在 AQS 中会有一个 state 的值,对于 reentrantlock 来说就是可以重入的次数,利用 cas 操作来改变 state 的值,如果 state 不为 0 的时候,cas 操作其实是失败的,那就是获取锁失败了,则会进去 queue 队列里面去等待锁的释放,如果获取锁成功,则会记录当前的线程。而锁对应的实现其实是 Sync 类,分为 NofairSync 和 FairSync,公平锁和非公平锁的区别,就在于在 cas 前会判断队列是否是空的,当前线程是否是头节点的线程。
17.什么是公平锁和非公平锁:
公平锁意味着公平,所以线程获取锁的顺序是按照进队列的顺序的。而非公平锁不是,在唤醒头节点的时候,如果新来了一个线程,则会给新的线程,这样子可以减少一定的上下文切换。实现是 cas 前会判断队列是否是空的,当前线程是否是头节点的线程。
18.什么是悲观锁和乐观锁:
悲观锁认为,在线程执行中,其他的线程都会改变它的执行结果,所以需要把其他线程屏蔽(也就是互斥),不给他们执行。
乐观锁则是在并发量小的情况下,其他线程不会改变自己的结果,只要在执行完成后,去验证一下值是否被改变了,如果被改变了则失败。
19.cas 是原子性操作嘛:
是的,cas 的原子性是有操作系统保证的。
20.cas 有什么问题:
cas 会出现一个 ABA 的问题,如果一个线程改变了值,变成了 B,而另外一个线程改回了 A,则初始线程去判断值是否被改变的时候,会发现值是没有变的,则会认为没有修改值,但是准确来说运行结果已经被变更了。那么如何避免 ABA 问题,那就是需要增加一个版本号了,不光判断值,也要判断版本号是否一致, 常用的比如 AtomicStampedReference。
21.读写锁了解嘛,知道读写锁的实现方式嘛:
常用的读写锁 ReentrantReanWritelock,这个其实和 reentrantLock 相似,也是基于 AQS 的,但是这个是基于共享资源的,不是互斥,关键在于 state 的处理,读写锁把高 16 为记为读状态,低 16 位记为写状态,就分开了,读读情况其实就是读锁重入,读写/写读/写写都是互斥的,只要判断低 16 位就好了。
22.乐观锁的应用场景有哪些:
常用的乐观锁使用是在原子类中,原子类使用自旋锁的方式,循环调用 cas,直到 cas 成功,所以也就是为什么是原子类的原因了。自旋锁的会有一个问题,也就是当并发量非常大的时候,cas 只能允许一个线程执行成功,就会造成很多线程循环等待,循环执行失败,可能会导致活锁的情况。为了解决这种方式,我们可以采用分段的思想,比如 LongAddr 和 ConcurrentHashMap
23.LongAddr 是如果解决高并发的问题:
LongAddr 对于值是由两部分组成,baseValue 和 Cell 数组,它把一个值分成多个部分,每次线程竞争的时候,就可以竞争多个对象(也就是 Cell),并把操作修改在上面,这样子把原本的单一线程执行成功,变成了可以多个线程执行成功。而且当线程获取 cell 失败,不是阻塞,而是尝试下一个。最终的 vaue 就是 baseValue+Cell 数组的和。
24.LongAddr 的执行逻辑是什么样子:
在判断 Cell 数组是否为 null,如果不为 null,则选择一个 Cell 进行 cas 操作,如果为 null,则对 base 进行 cas 操作,如果 cas 失败,说明并发量高,则需要扩容 cell。扩容 cell 的时候会设置 busy 字段,也是用 cas 操作的。
25.LongAddr 是如何选择对应的 Cell 的:
会调用 getProbe 方法来获取当前线程的一个 hash 值,并且和 cell 的长度进行&操作,计算出对应的下标。
26.锁膨胀的过程:
在 java1.6 前锁的性能是很差的,对锁进行了优化,引进了偏向锁,轻量级锁,自旋锁,自适应自旋锁等。其中对于一个线程的锁是有偏向锁-》轻量级锁-》重量级锁,进行膨胀的,而且不可回退。
线程加锁默认会是偏向锁,偏向锁会存储锁状态,和线程 id,当开始的时候会先判断锁的状态,是否为可偏向锁,如果是,则判断当前线程 id 和偏向锁对应的 id 是否一致,如果一致,那就获取锁成功,如果不一致则尝试使用 cas 替换线程 id。那么什么时候会被膨胀呢,当 cas 操作失败的时候,说明竞争激烈,则需要升级锁,还有一种情况,因为对象头里面存储的是锁状态,线程 id,没有了对象的 hash 值,那如果要用这个值的时候,也会膨胀到轻量级锁。那么是如何膨胀到轻量级锁的呢:偏向锁是需要通过线程竞争来释放的(hash 那个也是触发条件),当 cas 失败当时候,会等待,直到线程进入安全点,然后会暂停线程,校验占有了偏向锁的线程是否还活着,如果不活动了,则变为无锁状态,然后重新 cas,如果线程是活跃的,那么就把对象头的信息指向栈的一个引用(不是 monitor,是 lock reocd,是对象的 markword 的一个引用(无锁状态的)),升级为轻量级锁,重新进行 cas(这次是自旋锁了)
轻量级锁适用于一些竞争少的情况。所以当有锁竞争的时候,jvm 会先生成栈上面的一个 lock reocd,然后把对象头信息存放进去,用 cas 去替换,如果替换成功,则获取锁成功,如果替换失败,则自旋,如果自旋失败,则膨胀。
27.什么时候适合线程池:
1.任务的执行时间短
2.任务的数量比较多
28.Executors 有哪些线程池,分别的应用场景有哪些:
固定线程数量的线程池 fixed 线程池,单例线程池,可缓存线程池,定时线程池。
fixed 线程池比较常用,是固定线程数量的线程池,
单例线程池是只有一个线程的线程池。
可缓存线程池是如果有空余的线程,则会提交给空余的否则就是新建线程
定时线程池,一般是用于延时任务的。
29.ThreadPoolExecutor 的参数有哪些:
核心线程数,最大线程数,空闲时间,空闲时间类型,线程工厂,队列,拒绝策略。
30.给线程池提交一个任务的流程是什么样子的:
先会判断核心线程数是否满了,如果没有满则新建一个线程,如果满了则丢给任务队列,如果任务队列也满了,则判断线程数是否超出了最大线程数,如果没有则新建线程,如果也满了,则执行拒绝策略。
31.线程池的拒绝策略有哪些:
直接拒绝,抛出异常,移除最先进入队列的任务,在 execute 方法的调用线程中运行被拒绝的任
32.如果线程池中的一个线程运行时出现了异常,会发生什么:
如果提交任务的时候使用了 submit,则返回的 feature 里会存有异常信息,但是如果数 execute 则会打印出异常栈。但是不会给其他线程造成影响。之后线程池会删除该线程,会新增加一个 worker。
33.线程池的状态有哪些:
线程池有 5 种状态:running,showdown,stop,Tidying,TERMINATED。
running:线程池处于运行状态,可以接受任务,执行任务,创建线程默认就是这个状态了
showdown:调用 showdown()函数,不会接受新任务,但是会慢慢处理完堆积的任务。
stop:调用 showdownnow()函数,不会接受新任务,不处理已有的任务,会中断现有的任务。
Tidying:当线程池状态为 showdown 或者 stop,任务数量为 0,就会变为 tidying。这个时候会调用钩子函数 terminated()。
TERMINATED:terminated()执行完成。
在线程池中,用了一个原子类来记录线程池的信息,用了 int 的高 3 位表示状态,后面的 29 位表示线程池中线程的个数。
34.我们如果在并发量很高的情况下使用随机数生成,会有什么问题:
随机数一般是一个伪随机数,它生产新的随机数是需要一个种子的,比如 Random,一般我们会根据老的种子,重新生成一个新的种子,在根据种子生成一个新的随机数,但是呢,在高并发的情况下,随机数是有坑的,因为它没有加锁,所以当并发执行到生成新的种子的时候,有可能两个线程都生成了同一个新的种子(先读取了老种子,然后被切换了),为了避免这个问题,Random 使用了自旋锁,那么就是自旋锁有的性能问题,如果在高并发的情况下,只能一个执行成功,容易出现活锁的情况。我们可以使用 ThreadLocalRandom,看名字就知道和 ThreadLocal 有很大的关系了,ThreadLocalRandom 会在线程内部保存了一个 ThreadLocalRandwomSeed,相当于每一个线程都有一个种子的副本,每个线程都根据自己的副本生成随机数,那就没有并发的问题啦。
35.CountDownLatch 和 CyclicBarrier 的区别是什么:
CountDownLatch 是等待其他线程执行到某一个点的时候,在继续执行逻辑(子线程不会被阻塞,会继续执行),只能被使用一次。最常见的就是 join 形式,主线程等待子线程执行完任务,在用主线程去获取结果的方式(当然不一定),内部是用计数器相减实现的(没错,又特么是 AQS),AQS 的 state 承担了计数器的作用,初始化的时候,使用 CAS 赋值,主线程调用 await()则被加入共享线程等待队列里面,子线程调用 countDown 的时候,使用自旋的方式,减 1,知道为 0,就触发唤醒。
CyclicBarrier 回环屏障,主要是等待一组线程到底同一个状态的时候,放闸。CyclicBarrier 还可以传递一个 Runnable 对象,可以到放闸的时候,执行这个任务。CyclicBarrier 是可循环的,当调用 await 的时候如果 count 变成 0 了则会重置状态,如何重置呢,CyclicBarrier 新增了一个字段 parties,用来保存初始值,当 count 变为 0 的时候,就重新赋值。还有一个不同点,CyclicBarrier 不是基于 AQS 的,而是基于 RentrantLock 实现的。存放的等待队列是用了条件变量的方式。
36.什么是信号量 Semaphore:
信号量是一种固定资源的限制的一种并发工具包,基于 AQS 实现的,在构造的时候会设置一个值,代表着资源数量。信号量主要是应用于是用于多个共享资源的互斥使用,和用于并发线程数的控制(druid 的数据库连接数,就是用这个实现的),信号量也分公平和非公平的情况,基本方式和 reentrantLock 差不多,在请求资源调用 task 时,会用自旋的方式减 1,如果成功,则获取成功了,如果失败,导致资源数变为了 0,就会加入队列里面去等待。调用 release 的时候会加一,补充资源,并唤醒等待队列。
37.并发包中的 CopyOnWrite'ArrayList 有用过嘛:
简单用过,这个适合于读多写少的情况,是读写分离的一种概念,当有写的需求时,会基于 reentrantLock,锁主对象,然后 copy 一个数组,把原本的数组的值复制进去,并修改,然后替换原本的数组,在被修改的时候是不会影响读的,也就是并发修改的时候,读取的还是原来的快照。因为指针并没有切换。
38.ThreadLocal 有使用过嘛,解释一下它的原理:
ThreadLocal 是保证变量线程内可见。它其实存储的不是在 ThreadLocal 里面,而是存储在了线程的 threadLocals 里,是线程专属的。线程内部的 threadLocals 是一个 ThreadLocalMap 格式,可以理解为是一个简单的 map,它把 thread 当成 key 来存放 value。
39.如果我们要获取父线程的 ThreadLocal 值呢:
ThreadLocal 是不具备继承性的,所以是无法获取到的,但是我们可以用 InteritableThreadLocal 来实现这个功能。InteritableThreadLocal 继承来 ThreadLocal,重写了 createdMap 方法,已经对应的 get 和 set 方法,不是在利用了 threadLocals,而是 interitableThreadLocals 变量。这个变量会在线程初始化的时候(调用 init 方法),会判断父线程的 interitableThreadLocals 变量是否为空,如果不为空,则把放入子线程中,但是其实这玩意没啥鸟用,当父线程创建完子线程后,如果改变父线程内容是同步不到子线程的。。。同样,如果在子线程创建完后,再去赋值,也是没啥鸟用的
集合类:
1.java 的集合框架有哪几种:
两种:collection 和 map,其中 collection 分为 set 和 List。
2.List 你使用过哪些:
ArrayList 和 linkedList 使用的最多,也最具代表性。
3.你知道 vector 和 ArrayList 和 linkedList 的区别嘛:
ArrayList 实现是一个数组,可变数组,默认初始化长度为 10,也可以我们设置容量,但是没有设置的时候是默认的空数组,只有在第一步 add 的时候会进行扩容至 10(重新创建了数组),后续扩容按照 3/2 的大小进行扩容,是线程不安全的,适用多读取,少插入的情况
linkedList 是基于双向链表的实现,使用了尾插法的方式,内部维护了链表的长度,以及头节点和尾节点,所以获取长度不需要遍历。适合一些插入/删除频繁的情况。
Vector 是线程安全的,实现方式和 ArrayList 相似,也是基于数组,但是方法上面都有 synchronized 关键词修饰。其扩容方式是原来的两倍。
4.hashMap 和 hashTable 和 ConcurrentHashMap 的区别:
hashMap 是 map 类型的一种最常用的数据结构,其底部实现是数组 + 链表(在 1.8 版本后变为了数组 + 链表/红黑树的方式),其 key 是可以为 null 的,默认 hash 值为 0。扩容以 2 的幂等次(为什么。。。因为只有是 2 的幂等次的时候(n-1)&x==x%n,当然不一定只有一个原因)。是线程不安全的
hashTable 的实现形式和 hashMap 差不多,它是线程安全的,是继承了 Dictionary,也是 key-value 的模式,但是其 key 不能为 null。
ConcurrentHashMap 是 JUC 并发包的一种,在 hashMap 的基础上做了修改,因为 hashmap 其实是线程不安全的,那在并发情况下使用 hashTable 嘛,但是 hashTable 是全程加锁的,性能不好,所以采用分段的思想,把原本的一个数组分成默认 16 段,就可以最多容纳 16 个线程并发操作,16 个段叫做 Segment,是基于 ReetrantLock 来实现的
5.说说你了解的 hashmap 吧:
hashMap 是 Map 的结构,内部用了数组 + 链表的方式,在 1.8 后,当链表长度达到 8 的时候,会变成红黑树,这样子就可以把查询的复杂度变成 O(nlogn)了,默认负载因子是 0.75,为什么是 0.75 呢?我们知道当负载因子太小,就很容易触发扩容,如果负载因子太大就容易出现碰撞。所以这个是空间和时间的一个均衡点,在 1.8 的 hashmap 介绍中,就有描述了,貌似是 0.75 的负载因子中,能让随机 hash 更加满足 0.5 的泊松分布。除此之外,1.7 的时候是头插法,1.8 后就变成了尾插法,主要是为了解决 rehash 出现的死循环问题,而且 1.7 的时候是先扩容后插入,1.8 则是先插入后扩容 (为什么?正常来说,如果先插入,就有可能节点变为树化,那么是不是多做一次树转化,比 1.7 要多损耗,个人猜测,因为读写问题,因为 hashmap 并不是线程安全的,如果说是先扩容,后写入,那么在扩容期间,是访问不到新放入的值的,是不是不太合适,所以会先放入值,这样子在扩容期间,那个值是在的)。1.7 版本的时候用了 9 次扰动,5 次异或,4 次位移,减少 hash 冲突,但是 1.8 就只用了两次,觉得就足够了一次异或,一次位移。
6.concurrentHashMap 呢:
concurrentHashMap 是线程安全的 map 结构,它的核心思想是分段锁。在 1.7 版本的时候,内部维护了 segment 数组,默认是 16 个,segment 中有一个 table 数组(相当于一个 segmeng 存放着一个 hashmap。。。),segment 继承了 reentrantlock,使用了互斥锁,map 的 size 其实就是 segment 数组的 count 和。而在 1.8 的时候做了一个大改版,废除了 segment,采用了 cas 加 synchronize 方式来进行分段锁(还有自旋锁的保证),而且节点对象改用了 Node 不是之前的 HashEntity。Node 可以支持链表和红黑树的转化,比如 TreeBin 就是继承了 Node,这样子可以直接用 instanceof 来区分。1.8 的 put 就很复杂来,会先计算出 hash 值,然后根据 hash 值选出 Node 数组的下标(默认数组是空的,所以一开始 put 的时候会初始化,指定负载因子是 0.75,不可变),判断是否为空,如果为空,则用 cas 的操作来赋值首节点,如果失败,则因为自旋,会进入非空节点的逻辑,这个时候会用 synchronize 加锁头节点(保证整条链路锁定)这个时候还会进行二次判断,是否是同一个首节点,在分首节点到底是链表还是树结构,进行遍历判断。
7.concurrentHashMap 的扩容方式:
1.7 版本的 concurrentHashMap 是基于了 segment 的,segment 内部维护了 HashEntity 数组,所以扩容是在这个基础上的,类比 hashmap 的扩容,
1.8 版本的 concurrentHashMap 扩容方式比较复杂,利用了 ForwardingNode,先会根据机器内核数来分配每个线程能分到的 busket 数,(最小是 16),这样子可以做到多线程协助迁移,提升速度。然后根据自己分配的 busket 数来进行节点转移,如果为空,就放置 ForwardingNode,代表已经迁移完成,如果是非空节点(判断是不是 ForwardingNode,是就结束了),加锁,链路循环,进行迁移。
8.hashMap 的 put 方法的过程:
判断 key 是否是 null,如果是 null 对应的 hash 值就是 0,获得 hash 值过后则进行扰动,(1.7 是 9 次,5 次异或,4 次位移,1.8 是 2 次),获取到的新 hash 值找出所在的 index,(n-1)&hash,根据下标找到对应的 Node/entity,然后遍历链表/红黑树,如果遇到 hash 值相同且 equals 相同,则覆盖值,如果不是则新增。如果节点数大于 8 了,则进行树化(1.8)。完成后,判断当前的长度是否大于阀值,是就扩容(1.7 是先扩容在 put)。
9.为什么修改 hashcode 方法要修改 equals:
都是 map 惹的祸,我们知道在 map 中判断是否是同一个对象的时候,会先判断 hash 值,在判断 equals 的,如果我们只是重写了 hashcode,没有顺便修改 equals,比如 Intger,hashcode 就是 value 值,如果我们不改写 equals,而是用了 Object 的 equals,那么就是判断两者指针是否一致了,那就会出现 valueOf 和 new 出来的对象会对于 map 而言是两个对象,那就是个问题了
10.TreeMap 了解嘛:
TreeMap 是 Map 中的一种很特殊的 map,我们知道 Map 基本是无序的,但是 TreeMap 是会自动进行排序的,也就是一个有序 Map(使用了红黑树来实现),如果设置了 Comparator 比较器,则会根据比较器来对比两者的大小,如果没有则 key 需要是 Comparable 的子类(代码中没有事先 check,会直接抛出转化异常,有点坑啊)。
11.LinkedHashMap 了解嘛:
LinkedHashMap 是 HashMap 的一种特殊分支,是某种有序的 hashMap,和 TreeMap 是不一样的概念,是用了 HashMap+ 链表的方式来构造的,有两者有序模式:访问有序,插入顺序,插入顺序是一直存在的,因为是调用了 hashMap 的 put 方法,并没有重载,但是重载了 newNode 方法,在这个方法中,会把节点插入链表中,访问有序默认是关闭的,如果打开,则在每次 get 的时候都会把链表的节点移除掉,放到链表的最后面。这样子就是一个 LRU 的一种实现方式。
网络:
1.5 层网络模型和 7 层模型:
7 层网络模型:物理层-》数据链路层-》网络层-》传输层-》会化层-》表示层-》应用层:
物理层:比特流的转化,电信号转化为数字信号
数据链路层:负责物理层上面的互连,节点间的传输
网络层:将数据传输到目的地,根据链路寻找目标
传输层:可靠的传输作用
会话层:负责链接和断开通信链接
表示层:把应用层的信息转化为网络传输用的格式(也用于转化)
应用层:为应用程序提供服务并规定应用程序中通信的相关细节
5 层网络模型:整合了传输层-》会化层-》表示层为传输层。
2.TCP/IP 协议:
TCP/IP 其实是两个协议的结合体,是我们最常用的协议,其网络模型分为 4 层应用层-》传输层-》网络层-》网络传输层。其中的 ip 协议是网络层协议。
3.一次 http 请求是怎么样的:
http 请求发生时,浏览器会先判断本地 host,如果有本地 host 则直接定位到 ip,如果没有,则查询路由表,如果路由表都没有,则请求 dns 服务器,获取 ip。获取到 ip 后则可以进行 tcp 链接,连接成功后就可以发送 http 请求了,请求到服务器,等待服务器返回数据,浏览器根据返回结果渲染页面
4.dns 解析到过程(补充上一题):
会先查询本地的 host 信息(我们可以定点打到某台机器就可以用这种方式),如果本地没有,则访问路由表(本地根服务器),本地根服务器会根据自己的缓存查询,如果有则返回,如果没有则请求发到根域名解析,根域名解析会返回一级域名解析服务器地址,请求一级域名服务器比如.com/.cn,一级域名会返回对应的 ip/二级域名服务器,进行二级域名服务器请求,二级域名一般就会返回真正的 ip 信息了。一般解析地址的顺序:.->.com->taobao.com->www.taobao.com 的顺序
5.TCP 和 UDP 的区别是啥:
UDP 在传送数据之前不需要先建立连接,远地主机在收到 UDP 报文后,不需要给出任何确认。虽然 UDP 不提供可靠交付,但在某些情况下 UDP 确是一种最有效的工作方式,比如: QQ
TCP 提供面向连接的服务。在传送数据之前必须先建立连接,数据传送结束后要释放连接。 TCP 不提供广播或多播服务。由于 TCP 要提供可靠的,面向连接的运输服务(TCP 的可靠体现在 TCP 在传递数据之前,会有三次握手来建立连接),这种造成了很多资源的消耗
6.TCP 是怎么保证可靠性的:
应用数据被分割成 TCP 认为最适合发送的数据块 (会出现沾包半包的现象)。
TCP 给发送的每一个包进行编号,接收方对数据包进行排序,把有序数据传送给应用层。
校验和: TCP 将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。
TCP 的接收端会丢弃重复的数据。
流量控制: TCP 连接的每一方都有固定大小的缓冲空间,TCP 的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP 使用的流量控制协议是可变大小的滑动窗口协议。
拥塞控制: 当网络拥塞时,减少数据的发送。
超时重传: 当 TCP 发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到 一个确认,将重发这个报文段
7.TCP 的三次握手:
TCP 的三次握手主要是为了保证接收方和发送方的收发功能正常,因为 tcp 是双向通道。在发送方准备发起 tcp 连接时,会发送一个 SYN 包(SYN=1,加一个随机序列号 seq),接收方收到后,会回一个 ACK 包 +SYN 包(ack=1,ack 是发送 seq+1,SYN=1,同时生成一个随机序列号 seq。发送方接受后,知道对方已经具备了发送和接受能力,但是对方还不知道我有这个能力,所以需要发送一个 ACK 包(ACK=1,ack=seq+1)。
8.TCP 的四次挥手:
相对于连接,结束连接的时候需要的次数更加多一次,客户端请求断开会发送 FIN 包(FIN=1,随机的序列数 seq),服务端接受到了 FIN 包,返回一个 ACK 包(ACK=1,ack=seq+1),这个时候服务端就会把自己没有完成的任务完成,任务完成后发送一个 FIN+ACK(FIN=1,ACK=1,ack=之前的 seq+1,seq=随机序列数),告诉客户端我准备好了断开,客户端接受到了后会返回一个 ACK 包(ACK=1,ack=seq+1)。为什么要这样呢?一个确保另一方准备好了断开,为嘛服务端在发送 FIN 的时候还要在发送一个 ACK 包,这个是因为第一次发送 ACK 包的时候并没有收到回复,可能会丢失了,所以要在发送一次确认,等到接收到对方的 ack 包后,就知道都准备好了,可以主动断开了。
Netty:
1.BIO、NIO、AIO 的区别:
BIO:阻塞同步 IO,本身是同步阻塞模式。 线程发起 IO 请求后,一直阻塞 IO,直到缓冲区数据就绪后,再进入下一步操作。
NIO:同步非阻塞 IO。线程发起 io 请求后,立即返回(非阻塞 io)。同步指的是必须等待 IO 缓冲区内的数据就绪,而非阻塞指的是,用户线程不原地等待 IO 缓冲区,可以先做一些其他操作,但是要定时轮询检查 IO 缓冲区数据是否就绪,常见的就是 Reactor 模型
AIO:异步非阻塞 IO,线程发起 IO 请求后,立即返回,也不需要等待 IO 缓冲区的数据,而是会有内核通知,可以进行 io 操作。是真正的非阻塞 io,对应了 uninx 网络模型中的最后一个异步模型.
2.5 类 uninx 网络模型:
1.同步阻塞 io:用户线程发起 io 请求,会阻塞用户线程,内核会先查缓存区是否存在数据,如果没有则读取数据到缓存区。然后把内核数据复制到用户线程空间。用户线程开始处理数据
2.半非阻塞同步 IO:用户线程发起 io 请求,内核检测缓存区数据后,如果没有会返回 EWOULDBLOCK 错误,由用户线程自己去轮询检查是否有数据了,等到内核缓冲区有数据的时候,同步等待内核数据复制到用户区。再由用户线程处理
3.IO 复用模型:使用 linux 的 select/poll 模型,这样子可以阻塞在 select 操作上面,等待内核通知数据完成,内核数据准备完成后,阻塞等待内核数据复制到用户线程区域。因为 select/poll 模型有很严重的性能问题(高连接的情况)
4.信号驱动的 IO 模型:用户线程会先建立 SIGIO 信号处理程序,线程继续执行,等待信号的产生,内核复制数据到缓存区后,生成 SIGIO 信号,用户线程监听后,触发调用,阻塞用户线程从内核区复制到用户空间。
5.异步 IO:用户线程通知内核处理数据,内核把数据复制到缓存区,且放入用户空间后,通知线程处理数据,线程接受到信号后,直接处理数据
4/5 其实看似很像,都是基于信号到,但是 5 比 4 更加优秀,不会阻塞等待用户线程复制数据。而且内核通知的时间点也是不一样的,4 是通知是数据准备好了,5 是通知可以进行 io 操作了。
3.IO 多路复用技术:
IO 多路复用技术基于了 uninx 的第三种模型,用于服务器需要同时处理多个处于监听状态或者多个连接状态的嵌套词,还有多种网络协议的套接字。可以降低系统的开销,比原本的多线程多进程比。线程可以阻塞在 select 上,可以让系统在单线程的情况同时处理多个客户端请求
4.为什么不用 select/poll,而是要用 select/epoll:
poll 有很多的限制,比如对一个进程打开对句柄数是有上限对,默认是 1024 个,这个数量不够的,但是 epoll 就没有上限的,是操作系统可以处理的最大文件句柄数。而且随着句柄数过多,select 的遍历会成为瓶颈。而且使用了 mmap 来加速内核和用户空间传递。
5.NIO 的核心组件:
Buffer: 缓冲区,我们知道在 javaIo 是,字节流是一般不用缓存区的,只有字符流是使用缓冲区的,而字节流是直接放入 stream 里面的,这次使用流缓冲区,读取是从缓冲区读取,写入也是缓冲区。
Channel:双向管道,Stream 是流,是单向的而 channel 是双工的,既可以读,也可以写,更加符合 unix 的底层操作系统。
Selector: 多路复用器 selector,会筛选出就绪状态的 channel,可以做到单线程处理多个客户端的情况
6.NIO 的通信过程:
服务端:
创建 NIOServer,打开 ServerSocketChannel,绑定监听的端口
创建 Selector,启动线程
注册 ServerSocketChannel 到 Selector
selector 轮询就绪 Key
ioHandler 处理 accept 消息,设置新客户端的连接
注册 Selector 新建立的 channel 为 OP_READ
channel 读取请求信息到缓冲区
ioHandler 处理信息,并写入 SocketChannel
客户端:
创建 NioClient,打开 SockerChannel,设置参数,
异步连接服务端,
创建 Selector,注册读事件
7.Netty 的好处:
api 简单,
功能强大,支持多种解码功能
定制能力强,可以用 pipe 进行扩展
稳定,成熟
8.netty 是怎么解决拆包和沾包的:
netty 处理沾包/拆包的方式有 4 种:
消息定长,固定报文大小,不够的利用填充
在报文结束的尾部添加换行符,用换行符进行分割
在报文结束的尾部添加特殊符号,用特殊符号来解析
将消息分为两部分,消息体和消息头,消息头记录长度
9.TCP 沾包拆包出现的原因:
a. 应用程序 write 写入的字节大小大于套接口的发送缓冲区大小
b. 进行 MSS 大小的 TCP 分段
c. 以太网帧的 payload 大于 MTU 进行 IP 分片
10.Netty服务端流程:
a. 创建 ServerBootstrap
b. 设置绑定 Reactor 线程池(服务端可以有两个 EventLoopGroup 组)
c. 设置 channel 类型,服务端一般是 NioServerSocketChannel
d. 创建 channelPipline
e. 绑定监听端口并启动
f. Selector 轮询
11.EventLoopGroup 作用:
EventLoopGroup 是一个线程组,EventLoop 是工作线程的包装,比如 selector 的轮询就是由其中的一个线程负担,还会处理网络 io 事件。除此之外,用户自定义的 task 和定时任务也是有 EventLoop 负责.
12.为什么 NioServerSocketChannel 创建的时候设置为 0,后续才设置为 OP_ACCEPT:
设置为 0 表示只注册,不监听任何网络操作,因为注册方法是多态的,既可以是读写,也可以是注册,所以初始化的时候不能写死。第二,可以根据执行链中预处理完后,在进行监听事件,可以用 SelectorKey 来修改
13.Netty客户端流程:
a. 创建 BootStrap
b. 选择 io 线程组
c. 指定 NIOSocketChannel
d. 创建 Pipeline
e. 连接指定端口和地址
f. 注册到多路复用器
g. 处理连接结果
h. 发送连接成功到事件
14.netty的 byteBuf:
byteBuf 和 jdk 不一样,jdk 是用 position 和 limit 来表示读取区域,而 netty 是用了 readerIndex 和 writeIndex,写入使用 writeIndex,读取使用 readerIndex。0~readerIndex 表示已读区域,是可以回收的。netty 的 buf 可以分为两类,一部分是堆内存,一部分是直接内存,我们一般在处理 io 的时候使用直接内存。而后端业务消息处理使用堆内存。
15.byteBuf 的扩容:
默认扩容的方式是倍数扩容,但是如果大于一定大小后变为步进(4MB)。
16.netty的内存池:
netty 在开始的时候会申请一大块内存,这个内存由 chunk 组成。而每个 chunk 由 page 组成,使用二叉树的方式管理内存。