3 万字 Java 高频面试指南带你通关跳槽季,复习 1 个月拿下大厂 offer
![3万字Java高频面试指南带你通关跳槽季,复习1个月拿下大厂offer](https://static001.geekbang.org/infoq/49/49f3c7cf1a2c356fba726294f30bb9af.jpeg)
今日分享开始啦,请大家多多指教~
开春了,现在面试的人很多,对于面试有很多疑问。本篇文章整理了一些面试题目资料,将一些面试经历以及网络收集的高频问题总结发布,希望帮助各位快速达到面试状态。话不多说,正文开始啦~
1.项目相关
介绍一下你简历上写的项目?自己主要做了什么?
你觉得项目里给你最大的挑战是什么?遇到了什么问题?如何解决的?从中学到了什么?
项目里面会不断出现各种问题,比如数据量过大造成的内存溢出问题,如何让程序运行效率更高,如何证明我们的算法比别人的算法效率高,如何找到新的观点来支撑我们现有的理论,如何向导师和师兄进行沟通完成接下来的工作。
项目的架构图能画一下不?
觉得项目有哪些地方可以改进完善?(比如:可以加一个 redis 缓存把热点数据缓存起来)
有没有遇到过内存泄漏的场景?
2.基础问题
2.1 进程和线程的区别?
a)进程是资源分配的最小单位,线程是任务执行的最小单位。
b)进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段,这种操作非常昂贵。而线程是共享进程中的数据的,使用相同的地址空间,因此 CPU 切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多。
c)线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据,而进程之间的通信需要以通信的方式(IPC)进行。不过如何处理好同步与互斥是编写多线程程序的难点。
d)但是多进程程序更健壮,多线程程序只要有一个线程死掉,整个进程也死掉了,而一个进程死掉并不会对另外一个进程造成影响,因为进程有自己独立的地址空间。
2.2 进程的调度算法有哪些?(主要)
a)先来先去服务
b)时间片轮转法
c)短作业优先
d)多级反馈队列调度算法
e)优先级调度
2.3 常用 IO 模型?
关注消息通信机制:
a)同步:调用一个功能,在功能结果没有返回之前,一直等待结果返回。
b)异步:调用一个功能,调用立刻返回,但调用者不能立刻得到结果。调用者可以继续后续的操作,其结果一般通过状态,回调函数来通知调用者。
等待调用结果时的状态:
c)阻塞:调用一个函数,当调用结果返回之前,当前线程会被挂起,只有得到结果之后才会返回。
d)非阻塞:调用一个函数,不能立刻得到结果之前,调用不能阻塞当前线程。一个输入操作通常包括两个阶段:
等待数据准备好
从内核向进程复制数据
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待数据到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。
e)阻塞 IO 模型:应用进程被阻塞,直到数据从内核缓冲区复制到应用进程缓冲区中才返回。
f)非阻塞 IO 模型:进程发起 IO 系统调用后,内核返回一个错误码而不会被阻塞;应用进程可以继续执行,但是需要不断的执行系统调用来获知 I/O 是否完成。如果内核缓冲区有数据,内核就会把数据返回进程。
g)IO 复用模型:使用 select 或者 poll 等待数据,可以等待多个套接字中的任何一个变为可读。这一过程会被阻塞,当某一个套接字可读时返回,之后把数据从内核复制到进程中。(在多路复用 IO 模型中,会有一个线程不断去轮询多个 socket 的状态,只有当 socket 真正有读写事件时,才真正调用实际的 IO 读写操作。因为在多路复用 IO 模型中,只需要使用一个线程就可以管理多个 socket,并且只有在真正有 socket 读写事件进行时,才会使用 IO 资源,所以它大大减少了资源占用。)
h)信号驱动 IO 模型:当进程发起一个 IO 操作,会向内核注册一个信号处理函数,然后进程返回不阻塞;当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用 IO 读取数据。
i)异步 IO 模型:当进程发起一个 IO 操作,进程返回不阻塞,但也不能返回结果;内核把整个 IO 处理完后,会通知进程结果。如果 IO 操作成功则进程直接获取到数据。
2.4 select、poll 和 epoll 的区别?epoll 的底层使用的数据结构。
select,poll 和 epoll 允许应用程序监视一组文件描述符,等待一个或者多个描述符成为就绪状态,从而完成 I/O 操作。
select 和 poll 的功能基本相同,不过在一些实现细节上有所不同。
select 的描述符类型使用数组实现,FD_SETSIZE 大小默认为 1024,因此默认只能监听少于 1024 个描述符。如果要监听更多描述符的话,需要修改 FD_SETSIZE 之后重新编译;而 poll 没有描述符数量的限制,poll 中的描述符是 pollfd 类型的数组;
poll 提供了更多的事件类型,并且对描述符的重复利用上比 select 高。
如果一个线程对某个描述符调用了 select 或者 poll,另一个线程关闭了该描述符,会导致调用结果不确定。
select 和 poll 速度都比较慢,每次调用都需要将全部描述符从应用进程缓冲区复制到内核缓冲区。
当某个进程调用 epoll_create() 方法时,内核会创建一个 eventpoll 对象。
创建 epoll 对象后,可以用 epoll_ctl() 向内核注册新的描述符或者是改变某个文件描述符的状态。已注册的描述符在内核中会被维护在一棵红黑树上,通过回调函数内核会将 I/O 准备好的描述符加入到一个链表中管理,进程调用 epoll_wait() 便可以得到事件完成的描述符。
就绪列表:epoll 使用双向链表来实现就绪队列,是一种能够快速插入和删除的数据结构。索引结构:epoll 使用红黑树去监听并维护所有文件描述符。
epoll 的描述符事件有两种触发模式:LT(水平触发)和 ET(边沿触发)。
当 epoll_wait() 检测到描述符事件到达时,将此事件通知进程,进程可以不立即处理该事件,下次调用 epoll_wait()会再次通知进程。
和 LT 模式不同的是,通知之后进程必须立即处理事件,下次再调用 epoll_wait() 时不会再得到事件到达的通知。
边沿触发仅触发一次,水平触发会一直触发。
2.5 进程的通信方式有哪些?线程呢?
2.5.1 进程间的通信方式
a) 管道/匿名管道(Pipes):用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。
b) 有名管道(Names Pipes): 匿名管道由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道。有名管道严格遵循先进先出(first in first out)。有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。
c)消息队列(Message Queuing):消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识。管道和消息队列的通信数据都是先进先出的原则。与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显示地删除一个消息队列时,该消息队列才会被真正的删除。消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比 FIFO 更有优势。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺。
d) 信号(Signal):信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生;(对于异常情况下的工作模式,就需要用「信号」的方式来通知进程,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。比如,Ctrl+C 产生 SIGINT 信号,表示终止该进程,Ctrl+Z 产生 SIGSTP,表示停止该进程,但还未结束)
e) 信号量(Semaphores):信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。(信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。)
f) 共享内存(Shared memory):使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。(共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中)
h) 套接字(Sockets): 此方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。
int socket(int domain, int type, int protocal)
2.5.2 线程间的通信方式:
a) 互斥量(Mutex):采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。比如 Java 中的 synchronized 关键词和各种 Lock 都是这种机制。
b) 信号量(Semphares):它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。
c) 事件(Event):Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作。
2.6 fork 函数的作用?
在 Linux 中 fork 函数是非常重要的函数,它的作用是从已经存在的进程中创建一个子进程,而原进程称为父进程。
调用 fork(),当控制转移到内核中的 fork 代码后,内核开始做:
分配新的内存块和内核数据结构给子进程。
将父进程部分数据结构内容拷贝至子进程。
将子进程添加到系统进程列表。
fork 返回开始调度器,调度。
特点:
1)调用一次,返回两次并发执行
2)相同但是独立的地址空间
3)fork 的返回值:fock 函数调用一次却返回两次;向父进程返回子进程的 ID,向子进程中返回 0,
4)fork 的子进程返回为 0;
5)父进程返回的是子进程的 pid。
fork 调用失败的原因
1)系统中有太多进程。
2)实际用户的进程数超过限制。
2.7 协程的概念?
协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
对操作系统而言,线程是最小的执行单元,进程是最小的资源管理单元。无论是进程还是线程,都是由操作系统所管理的。
协程不是被操作系统内核所管理的,而是完全由程序所控制,也就是在用户态执行。这样带来的好处是性能大幅度的提升,因为不会像线程切换那样消耗资源。
协程既不是进程也不是线程,协程仅仅是一个特殊的函数,协程它进程和进程不是一个维度的。
一个进程可以包含多个线程,一个线程可以包含多个协程。
一个线程内的多个协程虽然可以切换,但是多个协程是串行执行的,只能在一个线程内运行,没法利用 CPU 多核能力。
协程与进程一样,切换是存在上下文切换问题的。
2.8. linux 进程和线程?
进程通过 fork()创建
线程通过 pthread_create() 函数创建
2.9 通过进程 id 查看占用的端口,通过端口号查看占用的进程 id?
通过进程 id 查看占用的端口:
netstat -nap | grep 进程 id
通过端口号查看占用的进程 id :
netstat -nap | grep 端口号
2.10 如何查看占用内存比较多的进程?
ps aux | sort -k4nr | head -N
head :-N 可以指定显示的行数,默认显示 10 行。
ps :a—指代所有的进程,u—userid—执行该进程的用户 id,x—指代显示所有程序,不以终端机来区分。ps -aux 的输出格式如下:
sort -k4nr 中:k 代表从根据哪一个关键词排序,后面的数字 4 表示按照第四列排序;n 指代 numberic sort,根据其数值排序;r 指代 reverse,这里是指反向比较结果,输出时默认从小到大,反向后从大到小。%MEM 在第 4 个位置,-k4 按照内存占用排序。%CPU 在第三个位置,-k3 表示按照 cpu 占用率排序。
2.11 僵尸进程产生的原因?
僵尸进程是指它的父进程没有等待(调用 wait/waitpid)。如果子进程先结束而父进程后结束,即子进程结束后,父进程还在继续运行但是并未调用 wait/waitpid 那子进程就会成为僵尸进程。但如果子进程后结束,即父进程先结束了,但没有调用 wait/waitpid 来等待子进程的结束, 此时子进程还在运行,父进程已经结束。那么并不会产生僵尸进程。应为每个进程结束时, 系统都会扫描当前系统中运行的所有进程,看看有没有哪个进程时刚刚结束的这个进程的子 进程,如果有就有 init 来接管它,成为它的父进程。
进程设置僵尸状态的目的是维护子进程的信息,以便父进程在以后某个时间获取。要在当前 进程中生成一个子进程,一般需要调用 fork 这个系统调用,fork 这个函数的特别之处在于一次调用,两次返回,一次返回到父进程中,一次返回到子进程中,可以通过返回值来判断其 返回点。如果子进程先于父进程退出, 同时父进程又没有调用 wait/waitpid,则该子进程将成为僵尸进程。
在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存。但是 仍然保留了一些信息(如进程号 pid 退出状态 运行时间等)。这些保留的信息直到进程通过调用 wait/waitpid 时才会释放。这样就导致了一个问题,如果没有调用 wait/waitpid 的话,那 么保留的信息就不会释放。比如进程号就会被一直占用了。但系统所能使用的进程号的有限 的,如果产生大量的僵尸进程,将导致系统没有可用的进程号而导致系统不能创建进程。所 以我们应该避免僵尸进程。
如果进程不调用 wait / waitpid 的话, 那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用 的进程号而导致系统不能产生新的进程. 此即为僵尸进程的危害,应当避免。
2.13 孤儿进程产生的原因?
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤 儿进程。孤儿进程将被 init 进程(进程号为 1)所收养,并由 init 进程对它们完成状态收集工作。孤儿进程是没有父进程的进程,管理孤儿进程这个重任就落到了 init 进程身上,因此孤儿进程并 不会有什么危害。
2.14 讲一下虚拟内存。虚拟内存和物理内存的关系是什么?
虚拟内存使得应用程序认为它拥有一个连续的地址空间,而实际上,它通常是被分隔成多个物理内 存碎片,还有一部分存储在外部磁盘存储器上,在需要时进行数据交换。
虚拟内存可以让程序可以拥有超过系统物理内存大小的可用内存空间。虚拟内存让每个进程拥有一 片连续完整的内存空间。
局部性原理表现在以下两个方面:
1)时间局部性 :如果程序中的某条指令一旦执行,不久以后该指令可能再次执行;如果某数据被访问过,不久以后该数据可能再次被访问。
2)空间局部性 :一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问。
操作系统将内存抽象成地址空间。每个程序拥有自己的地址空间,这个地址空间被分割成多个块, 每一块称为一页。这些页被映射到物理内存,但不需要映射到连续的物理内存,也不需要所有页都 必须在物理内存中。当程序引用到不在物理内存中的页时,会将缺失的部分从磁盘装入物理内存。
页面置换算法:
OPT 页面置换算法(最佳页面置换算法):所选择的被换出的页面将是最长时间内不再被访问, 通常可以保证获得最低的缺页率。
FIFO(First In First Out) 页面置换算法(先进先出页面置换算法) : 总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面进行淘汰。
LRU (Least Currently Used)页面置换算法(最近最久未使用页面置换算法):将最近最久未使用的页面换出。需要在内存中维护一个所有页面的链表。当一个页面被访问时,将这个页面移到 链表表头。这样就能保证链表表尾的页面是最近最久未访问的。力扣-实现 LRU
LFU (Least Frequently Used)页面置换算法(最少使用页面置换算法):该置换算法选择在之前时期使用最少的页面作为淘汰页。力扣-实现 LFU
2.15 分段和分页讲一下?以及对应的场景?
操作系统的内存管理机制了解吗?内存管理有哪几种方式?
块式管理 : 将内存分为几个固定大小的块,每个块中只包含一个进程。
页式管理 :把主存分为大小相等且固定的一页一页的形式,页较小,相对相比于块式管理的划分力度更大,提高了内存利用率,减少了碎片。页式管理通过页表对应逻辑地址 和物理地址。
段式管理 : 页式管理虽然提高了内存利用率,但是页式管理其中的页实际并无任何实际意义。 段式管理把主存分为一段段的,最重要的是段是有实际意义的,每个段定义了一组逻辑信息。 段式管理通过段表对应逻辑地址和物理地址。例如,有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。 段式管理通过段表对应逻辑地址和物理地址。
段页式管理:段页式管理机制结合了段式管理和页式管理的优点。段页式管理机制就是 把主存先分成若干段,每个段又分成若干页。
分段和分页:
共同点
分页机制和分段机制都是为了提高内存利用率,较少内存碎片。
页和段都是离散存储的,所以两者都是离散分配内存的方式。但是,每个页和段中 的内存是连续的。
区别
页的大小是固定的,由操作系统决定;而段的大小不固定,取决于我们当前运行的 程序。
分页仅仅是为了满足操作系统内存管理的需求,而段是逻辑信息的单位,在程序中 可以体现为代码段,数据段,能够更好满足用户的需要。
![](https://static001.geekbang.org/infoq/97/97c989c88631d1cb8e77d73e453d8fff.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
2.16 讲一下用户态和内核态?所有的系统调用都会进入到内核态吗?
操作系统(Operating System,简称 OS)是管理计算机硬件与软件资源的程序。根据进程访问资源的特点,我们可以把进程在系统上的运行分为两个级别:
用户态(user mode) : 用户态运行的进程或可以直接读取用户程序的数据。
内核态(kernel mode):可以简单的理解系统态运行的进程或程序几乎可以访问计算机的任何资源,不受限制。
运行的程序基本都是运行在用户态。如果我们调用操作系统提供的内核态级别的子功能那就需要系统调用了。
系统调用:与系统态级别的资源有关的操作(如文件管理、进程控制、内存管理等),都 必须通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成。
系统调用是操作系统为应用程序提供能够访问到内核态的资源的接口。补充:
用户态切换到内核态的几种方式
系统调用: 系统调用是用户态主动要求切换到内核态的一种方式, 用户应用程序通过操作系统调用内核为上层应用程序开放的接口来执行程序。
异常:当 cpu 在执行用户态的应用程序时,发生了某些不可知的异常。 于是当前用户态的应用进程切换到处理此异常的内核的程序中去。
硬件设备的中断: 当硬件设备完成用户请求后,会向 cpu 发出相应的中断信号,这时 cpu 会暂停执行下一条即将要执行的指令,转而去执行与中断信号对应的应用程序, 如果先前执行的指令是用户态下程序的指令,那么这个转换过程也是用户态到内核态的转换。
2.17 平常用什么 linux 命令比较多?如何打开文件并进行查找某个单词?怎么在某个目录下找到包含 txt 的文件?
pwd:显示当前所在位置
sudo + 其他命令:以系统管理者的身份执行指令,也就是说,经由 sudo 所执行的指令就好像是 root 亲自执行。
grep:要搜索的字符串 要搜索的文件 --color : 搜索命令,–color 代表高亮显示
ps - ef/ps aux: 这两个命令都是查看当前系统正在运行进程,两者的区别是展示格式不同。如果想要查看特定的进程可以使用这样的格式:
ps aux|grep redis
(查看包括 redis 的进程),也可使用
pgrep redis -a
注意:如果直接用 ps((Process Status))命令,会显示所有进程的状态,通常结合 grep 命令查看某进程的状态。
kill -9 进程的 pid : 杀死进程(-9 表示强制终止),先用 ps 查找进程,然后用 kill 杀掉。
find 目录 参数 : 寻找目录(查)。在/home 目录下查找以 .txt 结尾的文件名:
find /home -name “*.txt”
ls 或者 ll :(ll 是 ls -l 的别名,ll 命令可以看到该目录下的所有目录和文件的详细信息): 查看目录信息。
free : 显示系统内存的使用情况,包括物理内存、交换内存(swap)和内核缓冲区内存。
tar -zcvf 打包压缩后的文件名 要打包压缩的文件 : 打包并压缩文件,一般情况下打包和压缩是一起进行的,打包并压缩后的文件的后缀名一般 .tar.gz。c:压缩。
tar -xvf 压缩文件 - C 解压的位置 : 解压压缩包。x: 解压。
wget : 是从远程下载的工具。
vmstat : 虚拟内存性能监控、CPU 监控。
top : 常用来监控 Linux 的系统状况,比如 CPU、内存的使用,显示系统上正在运行的进程。load average:系统负载,就是进程队列的长度。当这个值>cpu 核心数的时候就说明有进程在等待处理了,是负载过重。
2.18 用过 ping 命令么?简单介绍一下。TTL 是什么意思?
ping : 查看与某台机器的连接情况。TTL:生存时间。数据报被路由器丢弃之前允许通过的网段数量。
2.19 怎么判断一个主机是不是开放某个端口?
telnet IP 地址 端口
telnet 127.0.0.1 3389
2.20 说一下你最用的比较多得模式(我说的工厂模式和观察者模式),再实现一个单例模式。
为什么要用 voliate 修饰?出现 synchronized 为啥还需要 voliate,以及 synchronized 能保证啥?
观察者模式:观察者模式定义了一系列对象之间的一对多关系。当一个对象改变状态, 其他依赖着都会受到通知。车辆的数据时不断更新的,需要监控数据的变化,当有新数据时就通知 观测者 observers。
迭代器模式:提供一种顺序访问聚合对象元素的方法。 hasNext() 和 next() 方法。
代理模式:代理模式给某一个对象提供一个代理对象,并由代理对象控制对原对象的引用。
JDK 动态代理和 CGLIB 动态代理均是实现 Spring AOP 的基础。
适配器模式
2.21 排序算法哪些是稳定的,为什么直接插入排序是稳定的,各种排序算法的时间复杂度和空间复杂度?
什么时候快速排序的时间复杂度最高。讲一下什么情况该使用什么排序算法?堆排序算法如何实现?
影响排序的因素有很多,平均时间复杂度低的算法并不一定就是最优的。相反,有时平均时 间复杂度高的算法可能更适合某些特殊情况。同时,选择算法时还得考虑它的可读性,以利 于软件的维护。一般而言,需要考虑的因素有以下四点:
a)待排序的记录数目 n 的大小;
b)记录本身数据量的大小,也就是记录中除关键字外的其他信息量的大小;
c)关键字的结构及其分布情况;
d)对排序稳定性的要求。
1)当 n 较大,则应采用时间复杂度为 O(nlog2n)O(nlog2n) 的排序方法:快速排序、堆排序或归并排序。
快速排序:是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分 布时,快速排序的平均时间最短;
堆排序 :堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况,也就是排序效率稳定。
归并排序:它有一定数量的数据移动,所以我们可能过与插入排序组合,先获得一定长度的 序列,然后再合并,在效率上将有所提高。
2)当 n 较大,内存空间允许,且要求稳定性 => 归并排序
3) 当 n 较小,可采用直接插入或直接选择排序。
4) 一般不使用或不直接使用传统的冒泡排序。
5) 基数排序
它是一种稳定的排序算法,但有一定的局限性:
a) 关键字可分解。
b) 记录的关键字位数较少,如果密集更好
c) 如果是数字时,最好是无符号的,否则将增加相应的映射复杂度,可先将其正负分开 排序。
2.22 如何进行二叉树的各种遍历的非递归算法实现?简要讲述。
从当前节点开始遍历:(当入栈时访问节点内容,则为前序遍历;出栈时访问,则为中序遍 历)
若当前节点存在,就存入栈中,并访问左子树;
直到当前节点不存在,就出栈,并通过栈顶节点访问右子树;
不断重复 12,直到当前节点不存在且栈空。
只需要在入栈、出栈的时候,分别进行节点访问输出,即可分别得到前序、中序的非递归遍 历代码!从当前节点开始遍历:
若当前节点存在,就存入栈中,第一次访问,然后访问其左子树;
直到当前节点不存在,需要回退,这里有两种情况:
a) 从左子树回退,通过栈顶节点访问其右子树(取栈顶节点用,但不出栈)
b) 从右子树回退,这时需出栈,并取出栈节点做访问输出。(需要注意的是,输出完 毕需要置当前节点为空,以便继续回退。具体可参考代码中的 cur == null)
不断重复 12,直到当前节点不存在且栈空。
2.23 硬链接和软链接?
硬链接: 硬连接指通过索引节点 inode 来进行的连接,即每一个硬链接都是一个指向对应区域的文件。
软链接: 保存了其代表的文件的绝对路径,是另外一种文件,在硬盘上有独立的区块, 访问时替换自身路径。
2.24 中断的分类?
中断可以分为同步中断(synchronous)和异步中断(asynchronous)。
中断可分为硬中断和软中断。
中断可分为可屏蔽中断(Maskable interrupt)和非屏蔽中断(Nomaskable interrupt)。
同步中断是在指令执行时由 CPU 主动产生的,受到 CPU 控制,其执行点是可控的。异步中断是 CPU 被动接收到的,由外设发出的电信号引起,其发生时间不可预测。
2.25 软中断和硬中断?
从本质上讲,中断(硬)是一种电信号,当设备有某种事情发生的时候,他就会产生中断,通过 总线把电信号发送给中断控制器。如果中断的线是激活的,中断控制器就把电信号发送给处理器的某个特定引脚。处理器于是立即停止自己正在做的事,跳到中断处理程序的入口点, 进行中断处理。
硬中断是由硬件产生的,比如,像磁盘,网卡,键盘,时钟等。每个设备或设备集都有它自己的 IRQ(中断请求)。
软中断是由当前正在运行的进程所产生的。
软中断比硬中断少了一个硬件发送信号的步骤。产生软中断的进程一定是当前正在运行的进 程,因此它们不会中断 CPU。但是它们会中断调用代码的流程。如果硬件需要 CPU 去做一些事情,那么这个硬件会使 CPU 中断当前正在运行的代码。
2.26 红黑树和平衡二叉树?
排序二叉树虽然可以快速检索,但在最坏的情况下:如果插入的节点集本身就是有序的,要 么是由小到大排列,要么是由大到小排列,那么最后得到的排序二叉树将变成链表:所有节 点只有左节点(如果插入节点集本身是大到小排列);或所有节点只有右节点(如果插入节 点集本身是小到大排列)。在这种情况下,排序二叉树就变成了普通链表,其检索效率就会 很差。
红黑树
性质 1:节点非红即黑。
性质 2:根节点永远是黑色的。
性质 3:所有的叶节点都是空节点(即 null),并且是黑色的。
性质 4:每个红色节点的两个子节点都是黑色。(从每个叶子到根的路径上不会有两个连续的红色节点)
性质 5:从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点。
红黑树最重要的性质:从根到叶子的最长的可能路径小于等于最短的可能路径的两倍长。 红黑树并不是真正意义上的平衡二叉树,但在实际应用中,红黑树的统计性能要高于平衡二叉树,但极端性能略差。(对于 AVL 树,任何一个节点的两个子树高度差不会超过 1;对于红黑树,则是不会相差两倍以上)对于给定的黑色高度为 N 的红黑树,从根到叶子节点的最短路径长度为 N-1,最长路径长度为 2 * (N-1)2∗(N−1)。对于红黑树,插入,删除,查找的复杂度都是 O(log N)O(logN)。任何不平衡都会在 3 次旋转之内解决。
红黑树通过上面这种限制来保证它大致是平衡的——因为红黑树的高度不会无限增高,这样 保证红黑树在最坏情况下都是高效的,不会出现普通排序二叉树的情况。
由于红黑树只是一个特殊的排序二叉树,因此对红黑树上的只读操作与普通排序二叉树上的 只读操作完全相同,只是红黑树保持了大致平衡,因此检索性能比排序二叉树要好很多。
但在红黑树上进行插入操作和删除操作会导致树不再符合红黑树的特征,因此插入操作和删 除操作都需要进行一定的维护,以保证插入节点、删除节点后的树依然是红黑树。
平衡二叉树的最差情形:
由平衡二叉树的定义可知,左子树和右子树最多可以相差 1 层高度,那么多个在同一层的子树 就可以依次以相差 1 层的方式来递减子树的高度,如下图所示是一个拥有 4 棵子树的树的层高 最大差情形。也就是说,一颗高度为 H 的平衡二叉树,其内部子树高度差最多为 [H / 2]。
红黑树的最差情形:
红黑树中红节点的父亲和孩子必须是黑节点,且从根到叶子节点经过的黑节点个数相同,因此红黑树最小深度是路径上只有黑节点,最大深度是路径上红黑节点相互间隔(重要),因此最大深度 <=最小深度的两倍,最大深度是 2 * log2(n+1)2∗log2(n+1)。
对于 AVL 树,任何一个节点的两个子树高度差不会超过 1;对于红黑树,则是不会相差两倍以上。红黑树的插入删除元素的效率高于平衡二叉树,而查询时间差于平衡二叉树。红黑树的树高 可能更高。
3 Java 基础
3.1 StringBuilder 和 StringBuffer
StringBuffer 是线程安全的 StringBuilder 是不安全的
3.2 Java 实现连续空间的内存分配?
基本数据类型的数组,存放在栈内存里,连续分配对象数组,在栈内存里的引用是连续分配的,实际数据分配在堆内存,不是连续分配的;
3.3 创建对象的方式有哪几种?
new Obj…()
clone():使用 Object 类的 clone 方法。
反射
调用 public 无参构造器 ,若是没有,则会报异常:
Object o = clazz.newInstance();
有带参数的构造函数的类,先获取到其构造对象,再通过该构造方法类获取实例:
![](https://static001.geekbang.org/infoq/ea/ea57717c2b132605f291151f1e373ce5.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
通过反序列化来创建对象: 实现 Serializable 接口。
3.4 接口和抽象类有什么区别?
3.5 深拷贝和浅拷贝区别?
3.6 讲一讲封装,继承,多态(重要)。
编译时多态
方法重载 都是编译时多态。根据实际参数的数据类型、个数和次序,Java 在编译时能够确定执行重载方法中的哪一个。
方法覆盖 表现出两种多态性,当对象引用本类实例时,为编译时多态,否则为运行时多态。
运行时多态
通过父类对象引用变量引用子类对象来实现。当父类对象引用子类实例时。通过接口类型变量引用实现接口的类的对象来实现 。运行时多态主要是通过继承和接口实现的。
3.7 泛型是什么?类型擦除?
泛型:将类型当作参数传递给一个类或者是方法。
类型擦除:Java 的泛型是伪泛型,这是因为 Java 在编译期间,所有的泛型信息都会被擦掉, 正确理解泛型概念的首要前提是理解类型擦除。
Java 的泛型基本上都是在编译器这个层次上实现的,在生成的字节码中是不包含泛型中的类型信息的,使用泛型的时候加上类型参数,在编译器编译的时候会去掉,这个过程成为类型 擦除。
原始类型: 就是擦除去了泛型信息,最后在字节码中的类型变量的真正类型,无论何时定义一个泛型,相应的原始类型都会被自动提供,类型变量擦除,并使用其限定类型(无限定的变量用 Object)替换。
Java 编译器是通过先检查代码中泛型的类型,然后在进行类型擦除,再进行编译。当具体的类型确定后,泛型提供了一种类型检测的机制,只有相匹配的数据才能正常的赋值,否则编译器就不通过。
3.8 如何实现静态代理?有啥缺陷?
为现有的每一个类都编写一个 对应的 代理类,并且让它实现和目标类相同的接口。
在创建代理对象时,通过构造器塞入一个目标对象,然后在代理对象的方法内部调用目 标对象同名方法,并在调用前后增加一些其他方法。比如打印日志。代理对象 = 增强代码 + 目标对象。需要为每一个目标类编写对应的代理类,工作量太大了。
3.9 动态代理的作用?在哪些地方用到了?(AOP、RPC 框架中都有用到,面试笔试中经常要求手写一个动态代理)
为其它对象提供一种代理以控制对这个对象的访问控制,在程序运行时,通过反射机制动态生成。JDK 动态代理的调用处理程序必须实现 InvocationHandler 接口,及使用 Proxy 类中的 newProxyInstance 方法动态的创建代理类。
![](https://static001.geekbang.org/infoq/ff/ff0d74d3e41f097a392d96c2080df441.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
3.10 JDK 的动态代理和 CGLIB 有什么区别?
JDK 动态代理只能只能代理实现了接口的类,而 CGLIB 可以代理未实现任何接口的类。 另外, CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法。就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。
3.11 谈谈对 Java 注解的理解,解决了什么问题?
Java 语言中的类、方法、变量、参数和包等都可以注解标记,程序运行期间我们可以获取到相应的注解以及注解中定义的内容,这样可以帮助我们做一些事情。比如说 Spring 中如果检测到说你的类被 @Component 注解标记的话,Spring 容器在启动的时候就会把这个类归为自己管理,这样你就可以通过 @Autowired 注解注入这个对象了。
3.12 Java 反射?反射有什么缺点?你是怎么理解反射的(为什么框架需要反射)?
反射介绍:
Java 反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及 动态调用对象的方法的功能称为 Java 语言的反射机制。
反射的优缺点如下:
优点:运行期类型的判断,动态加载类,提高代码灵活度。
缺点:性能瓶颈:反射相当于一系列解释操作,通知 JVM 要做的事情,性能比直接的 Java 代码要慢很多。
安全问题,让我们可以动态操作改变类的属性同时也增加了类的安全隐患。
3.13 为什么框架需要反射技术?
在我们平时的项目开发过程中,基本上很少会直接使用到反射机制,但这不能说明反射机 制没有用,实际上有很多设计、开发都与反射机制有关。动态代理设计模式也采用了反射机制,还有我们日常使用的 Spring/Hibernate 等框架也大量使用到了反射机制。
我们在使用 JDBC 连接数据库时使用 Class.forName() 通过反射加载数据库的驱动程序;
Spring 框架的 IOC(动态加载管理 Bean)创建对象以及 AOP(动态代理)功能都和反射有联系;
动态配置实例的属性;
3.14 获取 Class 对象的两种方式
如果我们动态获取到这些信息,我们需要依靠 Class 对象。Class 类对象将一个类的方法、变量等信息告诉运行的程序。Java 提供了两种方式获取 Class 对象:
知道具体类的情况下可以使用:
Class alunbarClass = TargetObject.class;
但是我们一般是不知道具体类的,基本都是通过遍历包下面的类来获取 Class 对象
通过 Class.forName() 传入类的路径获取:
Class alunbarClass1 = Class.forName("cn.javaguide.TargetObject");
3.15 内存泄露和内存溢出的场景。
内存泄漏:内存泄漏是指无用对象(不再使用的对象)持续占有内存或无用对象的内存得不 到及时释放,从而造成内存空间的浪费称为内存泄漏。
Java 内存泄漏的根本原因是长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄漏,尽管短生命周期对象已经不再需要,但是因为长生命周期持有它的引用而导致不能被回收,这就是 Java 中内存泄漏的发生场景。
内存溢出:指程序运行过程中无法申请到足够的内存而导致的一种错误。内存溢出通常发生 于 OLD 段或 Perm 段垃圾回收后,仍然无内存空间容纳新的 Java 对象的情况。
内存泄露的场景
静态集合类引起内存泄漏:静态成员的生命周期是整个程序运行期间。比如:Map 是在堆上动态分配的对象,正常情况下使用完毕后,会被 gc 回收。而如果 Map 被 static 修饰,且没有删除机制,静态成员是不会被回收的,所以会导致这个很大的 Map 一直停留在堆内存中。懒初始化 static 变量,且尽量避免使用。
当集合里面的对象属性被修改后,再调用 remove()方法时不起作用:修改 hashset 中对象的参数值,且参数是计算哈希值的字段。当一个对象被存储进 HashSet 集合中以后, 就不能修改这个对象中的那些参与计算哈希值的字段,否则对象修改后的哈希值与最初 存储进 HashSet 集合中时的哈希值就不同了。
各种连接对象( IO 流对象、数据库连接对象、网络连接对象)使用后未关闭:因为每个流 在操作系统层面都对应了打开的文件句柄,流没有关闭,会导致操作系统的文件句柄一 直处于打开状态,而 jvm 会消耗内存来跟踪操作系统打开的文件句柄。
监听器的使用:在释放对象的同时没有相应删除监听器的时候也可能导致内存泄露。
不正确使用单例模式是引起内存泄漏:单例对象在初始化后将在 JVM 的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部的引用,那么这个对象将不能被 JVM 正常回收,导致内存泄漏。
解决措施
尽量减少使用静态变量,类的静态变量的生命周期和类同步的。
声明对象引用之前,明确内存对象的有效作用域,尽量减小对象的作用域,将类的成员 变量改写为方法内的局部变量;
减少长生命周期的对象持有短生命周期的引用;
使用 StringBuilder 和 StringBuffer 进行字符串连接,Sting 和 StringBuilder 以及 StringBuffer 等都可以代表字符串,其中 String 字符串代表的是不可变的字符串,后两者表示 可变的字符串。如果使用多个 String 对象进行字符串连接运算,在运行时可能产生大量临时字符串,这些字符串会保存在内存中从而导致程序性能下降。
对于不需要使用的对象手动设置 null 值,不管 GC 何时会开始清理,我们都应及时的将无用的对象标记为可被清理的对象;
各种连接(数据库连接,网络连接,IO 连接)操作,务必显示调用 close 关闭。
内存溢出场景
JVM Heap(堆)溢出:OutOfMemoryError: Java heap space: 发生这种问题的原因是 java 虚拟机创建的对象太多,在进行垃圾回收之间,虚拟机分配的到堆内存空间已 经用满了。JVM 在启动的时候会自动设置 JVM Heap 的值, 可以利用 JVM 提供的-Xmn -Xms -Xmx 等选项可进行设置。Heap 的大小是新生代和老年代之和。
解决方法:手动设置 JVM Heap(堆)的大小。检查程序,看是否有死循环或不必要地重复创建大量对象。
Metaspace 溢出:java.lang.OutOfMemoryError: Metaspace 程序中使用了大量的 jar 或 class,使 java 虚拟机装载类的空间不够,与 metaspace 大小有关。方法区用于存放 Java 类型的相关信息。在类装载器加载 class 文件到内存的过程中,虚拟机会提取其中的类型信息,并将这些信息存储到方法区。当需要存储类信息而方法区的 内存占用又已经达到 -XX:MaxMetaspaceSize 设置的最大值,将会抛出 OutOfMemoryError 异常。对于这种情况的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。
解决方法:通过 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 设置永久代大小即可。
栈溢出: java.lang.StackOverflowError : Thread Stack space:线程的方法嵌套调用层次太多(如递归调用),以致于把栈区溢出了。
解决方法:修改程序。通过 -Xss: 来设置每个线程的 Stack 大小即可。
3.16 讲一下,强引用,弱引用,软引用,虚引用。
强引用:被强引用关联的对象不会被回收。使用 new 一个新对象的方式来创建强引用。
Object obj = new Object();
软引用:被软引用关联的对象只有在内存不够的情况下才会被回收。使用 SoftReference 类来创建软引用。
![](https://static001.geekbang.org/infoq/b7/b7637eac0e8598958f9ddf395e5cc43b.png)
弱引用:被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发 生之前。使用 WeakReference 类来创建弱引用。
![](https://static001.geekbang.org/infoq/12/1296dda624546ea6d6e363549f4c7cd4.png)
3.17 一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用 得到一个对象。
3.18 讲一下 Java 的 NIO,AIO, BIO?
BIO (Blocking I/O):
同步阻塞 I/O 模式,数据的读取写入必须阻塞在一个线程内等待其完成。
NIO (Non-blocking/New I/O):
NIO 是一种同步非阻塞的 I/O 模型,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。Java NIO 使我们可以进行非阻塞 IO 操作。比如说, 单线程中从通道读取数据到 buffer,同时可以继续做别的事情,当数据读取到 buffer 中后, 线程再继续处理数据。写数据也是一样的。另外,非阻塞写也是如此。一个线程请求写入一 些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。JDK 的 NIO 底层由 epoll 实现。
通常来说 NIO 中的所有 IO 都是从 Channel(通道) 开始的。
从通道进行数据读取 :创建一个缓冲区,然后请求通道读取数据。
从通道进行数据写入 :创建一个缓冲区,填充数据,并要求通道写入数据。
AIO (Asynchronous I/O):异步非阻塞 IO 模型,异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的 线程进行后续的操作。AIO 的应用还不是很广泛。
3.19 Java 中 finalize()方法的使用?
finalize()是 Object 的 protected 方法,子类可以覆盖该方法以实现资源清理工作,GC 在回收对象之前调用该方法。
finalize()方法中一般用于释放非 Java 资源(如打开的文件资源、数据库连接等),或是调用非
Java 方法(native 方法)时分配的内存(比如 C 语言的 malloc()系列函数)。
避免使用的原因:
首先,由于 finalize()方法的调用时机具有不确定性,从一个对象变得不可到达开始,到 finalize()方法被执行,所花费的时间这段时间是任意长的。我们并不能依赖 finalize()方法能 及时的回收占用的资源,可能出现的情况是在我们耗尽资源之前,gc 却仍未触发,因而通常的做法是提供显示的 close()方法供客户端手动调用。另外,重写 finalize()方法意味着延长了回收对象时需要进行更多的操作,从而延长了对象回收的时间。
3.20 GC Root 对象有哪些
方法区中的静态变量和常量引用的对象
虚拟机栈中引用对象
本地方法栈中引用对象
3.21 Java 中 Class.forName 和 ClassLoader 的区别?
类的加载:
装载:通过类的全限定名获取二进制字节流,将二进制字节流转换成方法区中的运行时数据结构,在内存中生成 Java.lang.class 对象;
链接:执行下面的校验、准备和解析步骤,其中解析步骤是可以选择的;
校验:检查导入类或接口的二进制数据的正确性;(文件格式验证,元数据验证,字节 码验证,符号引用验证)
准备:给类的静态变量分配内存并初始化内存空间; 解析:将常量池中的符号引用转成直接引用;
初始化:激活类的静态变量的初始化 Java 代码和静态 Java 代码块,并初始化程序员设置的变量值。
在 java 中 Class.forName()和 ClassLoader 都可以对类进行加载。ClassLoader 就是遵循 双亲委 派模型 最终调用启动类加载器的类加载器,实现的功能是通过一个类的全限定名来获取描述 此类的二进制字节流,获取到二进制流后放到 JVM 中。classloader 只干一件事情,就是将 .class 文件加载到 jvm 中,不会执行 static 中的内容。
Class.forName() 方法实际上也是调用的 CLassLoader 来实现的。Class.forName()除了将类的 .class 文件加载到 jvm 中之外,还会对类进行初始化,执行类中的 static 块。
![](https://static001.geekbang.org/infoq/0f/0fb54e34a74f14066a206d01978a4d27.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
最后调用的方法是 forName0 这个方法,在这个 forName0 方法中的第二个参数被默认设置为了 true,这个参数代表是否对加载的类进行初始化,设置为 true 时会类进行初始化,代表会 执行类中的静态代码块,以及对静态变量的赋值等操作。
3.22 讲一下 CopyOnWriteArrayList 和 CopyOnWriteArraySet?
CopyOnWrite 容器:
写时复制的容器。当我们往一个容器添加元素的时候,不直接往当前容 器添加,而是先将当前容器进行 Copy,复制出一个新的容器,然后新的容器里添加元素,添 加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以 CopyOnWrite 容器也是一种读写分离的思想,读和写不同的容器。以下代码是向 ArrayList 里添加元素,可以发现在添加的时候是需要加锁的,否则多线程写的时候会 Copy 出 N 个副本出来。
![](https://static001.geekbang.org/infoq/e5/e5706526e1484ed88de25a566139b662.png)
读的时候不需要加锁,如果读的时候有多个线程正在向 ArrayList 添加数据,读还是会读到旧的数据,因为写的时候不会锁住旧的 ArrayList。
![](https://static001.geekbang.org/infoq/15/156ccc51883442eac806af4612bb4899.png)
CopyOnWrite 并发容器用于读多写少的并发场景。
CopyOnWrite 的缺点
CopyOnWrite 容 器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意。
内存占用问题。因为 CopyOnWrite 的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引 用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份 对象内存)。如果这些对象占用的内存比较大,比如说 200M 左右,那么再写入 100M 数据进去,内存就会占用 300M,那么这个时候很有可能造成频繁的 Yong GC 和 Full GC。针对内存占用问题,可以通过压缩容器中的元素的方法来减少大对象的内存消耗,比如,如 果元素全是 10 进制的数字,可以考虑把它压缩成 36 进制或 64 进制。或者不使用 CopyOnWrite 容器,而使用其他的并发容器,如 ConcurrentHashMap。
数据一致性问题。CopyOnWrite 容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用 CopyOnWrite 容器。
3.23 单例模式(重要)
1、懒汉式,线程不安全
是否 Lazy 初始化:是
是否多线程安全:否
实现难度:易
描述:这种方式是最基本的实现方式,这种实现最大的问题就是不支持多线程。因为没有加锁 synchronized,所以严格意义上它并不算单例模式。
这种方式 lazy loading 很明显,不要求线程安全,在多线程不能正常工作。
![](https://static001.geekbang.org/infoq/86/8601578023c84a8ce0d50f9c9e810972.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
接下来介绍的几种实现方式都支持多线程,但是在性能上有所差异。
是否 Lazy 初始化:是
是否多线程安全:是
实现难度:易
描述:这种方式具备很好的 lazy loading,能够在多线程中很好的工作,但是,效率很低,99% 情况下不需要同步。
优点:第一次调用才初始化,避免内存浪费。
缺点:必须加锁 synchronized 才能保证单例,但加锁会影响效率。
getInstance() 的性能对应用程序不是很关键(该方法使用不太频繁)。
![](https://static001.geekbang.org/infoq/2a/2a407e0ceb30681bc4b5068539b6cb96.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
3、饿汉式
是否 Lazy 初始化:否
是否多线程安全:是
实现难度:易
描述:这种方式比较常用,但容易产生垃圾对象。
优点:没有加锁,执行效率会提高。
缺点:类加载时就初始化,浪费内存。
它基于 classloader 机制避免了多线程的同步问题,不过,instance 在类装载时就实例化,虽然导致类装载的原因有很多种,在单例模式中大多数都是调用 getInstance 方法, 但是也不能确定有其他的方式(或者其他的静态方法)导致类装载,这时候初始化 instance 显然没有达到 lazy loading 的效果。
![](https://static001.geekbang.org/infoq/97/9777fb96af3115c93858a76f74f88523.png)
4、双检锁/双重校验锁(DCL,即 double-checked locking)
JDK 版本:JDK1.5 起
是否 Lazy 初始化:是
是否多线程安全:是
实现难度:较复杂
描述:这种方式采用双锁机制,安全且在多线程情况下能保持高性能。
getInstance() 的性能对应用程序很关键。
![](https://static001.geekbang.org/infoq/50/5049846f4f65a5365c2eb7687690250e.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
5、登记式/静态内部类
是否 Lazy 初始化:是
是否多线程安全:是
实现难度:一般
描述:这种方式能达到双检锁方式一样的功效,但实现更简单。对静态域使用延迟初始化,应使用这种方式而不是双检锁方式。这种方式只适用于静态域的情况,双检锁方式可在实例域需要延迟初始化时使用。
这种方式同样利用了 classloader 机制来保证初始化 instance 时只有一个线程,它跟第 3 种方式不同的是:第 3 种方式只要 Singleton 类被装载了,那么 instance 就会被实例化(没有达到 lazy loading 效果),而这种方式是 Singleton 类被装载了,instance 不一定被初始化。因为 SingletonHolder 类没有被主动使用,只有通过显式调用 getInstance 方法时,才会显式装载 SingletonHolder 类,从而实例化 instance。想象一下,如果实例化 instance 很消耗资源,所以想让它延迟加载,另外一方面,又不希望在 Singleton 类加载时就实例化,因为不能确保 Singleton 类还可能在其他的地方被主动使用从而被加载,那么这个时候实例化 instance 显然是不合适的。这个时候,这种方式相比第 3 种方式就显得很合理。
![](https://static001.geekbang.org/infoq/d6/d6c48a84556a182c06b79f70d6ebdfe1.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
6、枚举
JDK 版本:JDK1.5 起
是否 Lazy 初始化:否
是否多线程安全:是
实现难度:易
描述:这种实现方式还没有被广泛采用,但这是实现单例模式的最佳方法。它更简洁,自动支持序列化机制,绝对防止多次实例化。
这种方式是 Effective Java 作者 Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化。不过,由于 JDK1.5 之后才加入 enum 特性,用这种方式写不免让人感觉生疏,在实际工作中,也很少用。
不能通过 reflection attack 来调用私有构造方法。
![](https://static001.geekbang.org/infoq/a1/a1d1d50c02e7f9890a505444bd3bdb69.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
3.24 Java 中>>和>>>的区别
Java 中的位运算符:
表示带符号右移,如:int i=15; i>>2 的结果是 3,移出的部分将被抛弃。
转为二进制的形式可能更好理解,0000 1111(15)右移 2 位的结果是 0000 0011(3),0001 1010(18)右移 3 位的结果是 0000 0011(3)。
无符号右移:
按二进制形式把所有的数字向右移动对应巍峨位数,低位移出(舍弃),高位的空位补零。对于正数来说和带符号右移相同,对于负数来说不同。
其他结构和>>相似。
表达式为:
![](https://static001.geekbang.org/infoq/c1/c1dbe9a81bc001c70a4f3991387feb4e.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
例如:
![](https://static001.geekbang.org/infoq/3c/3c073b0ab1c62db0e8837509f1b0e0b5.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
4. 计网
4.1 为什么网络要分层?
说到分层,我们先从我们平时使用框架开发一个后台程序来说,我们往往会按照每一层做不 同的事情的原则将系统分为 三层(复杂的系统分层可能会更多):
Repository(数据库操作)
Service(业务操作)
Controller(数据交互)
网络分层的原则:每一层独立于其它层完成自己的工作,而不需要相互依赖,上下层之间通 过标准结构来互相通信,简单易用又具有拓展性。复杂的系统需要分层,因为每一层都需要专注于一类事情。我们的网络分层的原因也是一 样,每一层只专注于做一类事情。
为什么计算机网络要分层呢? ,我们再来较为系统的说一说:
各层之间相互独立:各层之间相互独立,各层之间不需要关心其他层是如何实现的,只 需要知道自己如何调用下层提供好的功能就可以了(可以简单理解为接口调用)。这个 和我们对开发时系统进行分层是一个道理。
提高了整体灵活性 :每一层都可以使用最适合的技术来实现,你只需要保证你提供的功能以及暴露的接口的规则没有改变就行了。这个和我们平时开发系统的时候要求的高内 聚、低耦合的原则也是可以对应上的。
大问题化小 :分层可以将复杂的网络间题分解为许多比较小的、界线比较清晰简单的小问题来处理和解决。这样使得复杂的计算机网络系统变得易于设计,实现和标准化。这 个和我们平时开发的时候,一般会将系统功能分解,然后将复杂的问题分解为容易理解 的更小的问题是相对应的,这些较小的问题具有更好的边界(目标和接口)定义。
4.2 TCP/IP 4 层模型了解么?
TCP/IP 4 层模型:
应用层
传输层
网络层
网络接口层
4.3 HTTP 是哪一层的协议?http 常见的状态码
HTTP 协议 属于应用层的协议。
HTTP 协议是基于 TCP 协议的,发送 HTTP 请求之前首先要建立 TCP 连接也就是要经历 3 次握手。目前使用的 HTTP 协议大部分都是 1.1。在 1.1 的协议里面,默认是开启了 Keep-
Alive 的,这样的话建立的连接就可以在多次请求中被复用了。
另外, HTTP 协议是无状态的协议,它无法记录客户端用户的状态** 一般我们都是通过
Session 来记录客户端用户的状态。
常见的状态码: 200、301、302、403、404、500、503
4.4 HTTP 和 HTTPS 什么区别?
端口 :HTTP 的 URL 由“http://”起始且默认使用端口 80,而 HTTPS 的 URL 由“https://”起始且默认使用端口 443。
安全性和资源消耗:HTTP 协议运行在 TCP 之上,所有传输的内容都是明文,客户端和服务器端都无法验证对方的身份。HTTPS 是运行在 SSL 之上的 HTTP 协议,SSL 运行在 TCP 之上。所有传输的内 容都经过加密,加密采用对称加密,但对称加密的密钥用服务器方的证书进行了非对称加密。所以说,HTTP 安全性没有 HTTPS 高,但是 HTTPS 比 HTTP 耗费更多服务器资源。
![](https://static001.geekbang.org/infoq/8c/8cabee9a756b7f516b6ef1c55c9a249d.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
4.5 讲一下对称加密算法和非对称加密算法?
对称加密:密钥只有一个,加密解密为同一个密码,且加解密速度快,典型的对称 加密算法有 DES、AES 等;
非对称密钥加密,加密和解密使用不同的密钥。通信发送方获得接收方的公开密钥之后,就 可以使用公开密钥进行加密,接收方收到通信内容后使用私有密钥解密。可以更安全地将公 开密钥传输给通信发送方;运算速度慢。典型的非对称加密算法有 RSA、DSA 等
HTTPS 采用的加密方式: HTTPS 采用混合的加密机制。所有传输的内容都经过加密,加密采用对称加密,但对称加密的密钥用服务器方的证书进行了非对称加密。
4.6 HTTP2.0 讲一下
4.7 HTTP 报文详解?详细说一下请求报文,以及 HTTP 和 TCP 的区别
HTTP 有两种报文:请求报文和响应报文 HTTP 请求报文主要包括请求行、请求头部以及请求的数据(实体)三部分请求行(HTTP 请求报文的第一行)请求行由方法字段、URL 字段和 HTTP 协议版本字段。其中,方法字段严格区分大小写,当前 HTTP 协议中的方法都是大写,方法字段如下介绍如下:
请求头部:位于请求行的下面, 是一个个的 key-value 值
空行(CR+LF):请求报文用空行表示 header 和请求数据的分隔 请求数据**:GET 方法没有携带数据, POST 方法会携带一个 body
HTTP 的响应报文包括:状态行,响应头部,相应的数据(响应体)
状态行包括:HTTP 版本号,状态码和状态值组成。响应头类似请求头,是一系列 key-value 值
空白行:同上,响应报文也用空白行来分隔 header 和数据 响应体:响应的数据
4.8 TCP 三次握手的过程,以及三次握手的原因?
假设 A 为客户端,B 为服务器端。
首先 B 处于 LISTEN(监听)状态,等待客户的连接请求。
A 向 B 发送连接请求报文,SYN=1,ACK=0,选择一个初始的序号 x。
B 收到连接请求报文,如果同意建立连接,则向 A 发送连接确认报文,SYN=1,ACK=1,确认号为 x+1,同时也选择一个初始的序号 y。
A 收到 B 的连接确认报文后,还要向 B 发出确认,确认号为 y+1,序号为 x+1。B 收到 A 的确认后,连接建立。
三次握手的目的是建立可靠的通信信道,三次握手最主要的目的就是双方确认自己与对方的 发送与接收是正常的。
第三次握手是为了防止失效的连接请求到达服务器,让服务器错误打开连接。
4.9 TCP 四次挥手的过程,以及四次挥手的原因?
假设 A 为客户端,B 为服务器端。
A 发送连接释放报文,FIN=1。
B 收到之后发出确认,它发回一 个 ACK 确认报文,确认序号为收到的序号加 1。此时 TCP 属于半关闭状态,B 能向 A 发送数据但是 A 不能向 B 发送数据。
当 B 不再需要连接时,发送连接释放报文,FIN=1。
A 收到后发出 ACK 确认报文,并将确认序号设置为收到序号加 1,进入 TIME-WAIT 状态,等待 2 MSL(最大报文存活时间)后释放连接。
B 收到 A 的确认后释放连接。
CLOSE-WAIT 状态问题:
客户端发送了 FIN 连接释放报文之后,服务器收到了这个报文,就进入了 CLOSE-WAIT 状态。这个状态是为了让服务器端发送还未传送完毕的数据,传送完毕之后,服务器会发送 FIN 连接释放报文。
TIME-WAIT 状态问题(这个问题问过很多次但总是答得不甚满意):
客户端接收到服务器端的 FIN 报文后进入此状态,此时并不是直接进入 CLOSED 状态,还需要等待一个时间计时器设置的时间 2MSL。这么做有两个理由:
确保最后一个确认报文能够到达。如果 B 没收到 A 发送来的确认报文,那么就会重新发送连接释放请求报文,A 等待一段时间就是为了处理这种情况的发生。
等待一段时间是为了让本连接持续时间内所产生的所有报文都从网络中消失,使得下一 个新的连接不会出现旧的连接请求报文。
通信双方建立 TCP 连接后,主动关闭连接的一方就会进入 TIME_WAIT 状态。
4.10 TCP 滑动窗口是干什么的?TCP 的可靠性体现在哪里?拥塞控制如何实现的?
滑动窗口:窗口是缓存的一部分,用来暂时存放字节流。发送方和接收方各有一个窗口,接 收方通过 TCP 报文段中的窗口字段告诉发送方自己的窗口大小,发送方根据这个值和其它信息设置自己的窗口大小。
发送窗口内的字节都允许被发送,接收窗口内的字节都允许被接收。接收窗口只会对窗口内 最后一个按序到达的字节进行确认。如果发送窗口内的字节已经发送并且收到了确认,那么 就将发送窗口向右滑动一定距离,直到第一个字节不是已发送并且已确认的状态;接收窗口 的滑动类似,接收窗口左部字节已经发送确认并交付主机,就向滑动接收窗口。
流量控制如何实现:流量控制是为了控制发送方发送速率,保证接收方来得及接收。
接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送 速率。将窗口字段设置为 0,则发送方不能发送数据。
拥塞控制:如果网络出现拥塞,分组将会丢失,此时发送方会继续重传,从而导致网络拥塞 程度更高。因此当出现拥塞时,应当控制发送方的速率。TCP 主要通过四个算法来进行拥塞控制:慢开始、拥塞避免、快重传、快恢复。
发送方需要维护一个叫做拥塞窗口(cwnd)的状态变量。
慢开始与拥塞避免
发送的最初执行慢开始,令拥塞窗口大小为 1,发送方只能发送 1 个报文段;当收到确认后,将拥塞窗口大小加倍。设置一个慢开始门限,当 拥塞窗口的大小大于慢开始门限 时,进入拥塞避免,每个轮次只将拥塞窗口加 1。如果出现了超时,则令慢开始门限 = 拥塞窗口大小 / 2,然后重新执行慢开始。
快重传与快恢复
在接收方,要求每次接收到报文段都应该对最后一个已收到的有序报文段进行确认。在 发送方,如果收到三个重复确认,那么可以知道下一个报文段丢失,此时执行快重传, 立即重传下一个报文段。在这种情况下,只是丢失个别报文段,而不是网络拥塞。因此 执行快恢复,令慢开始门限 = 拥塞窗口大小 / 2 ,拥塞窗口大小 = 慢开始门限 ,注意到此时直接进入拥塞避免。
(主要)TCP 使用超时重传来实现可靠传输:如果一个已经发送的报文段在超时时间内没有收到确认,那么就重传这个报文段。
应用数据被分割成 TCP 认为最适合发送的数据块。
TCP 给发送的每一个包进行编号,接收方对数据包进行排序,把有序数据传送给应用层。
校验和: TCP 将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。
TCP 的接收端会丢弃重复的数据。
流量控制: TCP 连接的每一方都有固定大小的缓冲空间,TCP 的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低 发送的速率,防止包丢失。TCP 使用的流量控制协议是可变大小的滑动窗口协议。(TCP 利用滑动窗口实现流量控制)
拥塞控制: 当网络拥塞时,减少数据的发送。
ARQ 协议:也是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组。
超时重传: 当 TCP 发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段。
4.11 TCP 和 UDP 有什么区别?及其适用的场景。
用户数据报协议 UDP 是无连接的,尽最大可能交付,没有拥塞控制,面向报文(对于应用程序传下来的报文不合并也不拆分,只是添加 UDP 首部),支持一对一、一对多、多对一和多对多的交互通信。
传输控制协议 TCP 是面向连接的,提供可靠交付,有流量控制,拥塞控制,提供全双工通信,面向字节流(把应用层传下来的报文看成字节流,把字节流组织成大小不等的数 据块),每一条 TCP 连接只能是点对点的(一对一)。
TCP 应用场景:
效率要求相对低,但对准确性要求相对高的场景。因为传输中需要对数据确认、重发、 排序等操作,相比之下效率没有 UDP 高。举几个例子:文件传输、接受邮件、远程登 录。
UDP 应用场景:
效率要求相对高,对准确性要求相对低的场景。举几个例子:QQ 聊天、在线视频、网 络语音电话、广播通信(广播、多播)。
UDP 为何快?
不需要建立连接
对于收到的数据,不用给出确认
没有超时重发机制
没有流量控制和拥塞控制
4.12 Mac 地址和 ip 地址的区别?既然有了 Mac 地址,为什么还要 ip 地址呢?
MAC 地址是烧录在网卡或者接口上的物理地址,具有全球唯一性,一般不能被改变。IP 地址是网络中的主机或接口在网络中的逻辑地址,在同一个网络内具有唯一性。
4.13 当你打开一个电商网站,都需要经历哪些过程?分别用到了什么协议。
浏览器查找域名的 IP 地址 (DNS:获取域名对应的 IP)
浏览器向 web 服务器发送 HTTP 请求(cookies 会随着请求发送给服务器)
服务器处理请求 (请求 处理请求 参数、cookies、生成一个 HTML 响应)
服务器返回 HTTP 报文,发回一个 HTML 响应。
浏览器解析渲染页面,浏览器开始显示 HTML。
连接结束
使用的协议:
DNS: 获取域名对应的 IP TCP: 与服务器建立 TCP 连接
IP: 建立 TCP 协议时,需要发送数据,发送数据在网络层上使用 IP 协议
OSPF:IP 数据包在路由器之间,路由选择使用 OSPF 协议
ARP:路由器在与服务器进行通信的过程中,将 IP 地址装换成 MAC 地址
HTTP:客户端浏览器与 Web 服务器之间的应用层通信协议,在 TCP 建立完成后,使用 HTTP 协议访问网页
4.14. 电子邮件的发送过程?
一个电子邮件系统由三部分组成:用户代理、邮件服务器以及邮件协议。
邮件协议包含发送协议和读取协议,发送协议常用 SMTP,读取协议常用 POP3 和 IMAP。
用户 A 的邮箱是 QQ 邮箱,他要发往的邮箱是 163 邮箱,用户 A 写好一封邮件点击发送, 即提交到了 QQ 邮箱服务器,使用的是 SMTP 协议。
QQ 邮箱会对 A 发送邮件的收件地址进行解析,判断是否为内部邮箱的账号,如果也是 QQ 邮箱,会直接存储到自己的存储空间,如果不是则会发送到指定邮箱服务器,使用 的也是 SMTP 协议。
163 的邮箱服务器收到邮件后会再次判断该邮件是否为自己的邮件,如果是则存到自己的存储空间,等待 POP3 服务去读取邮件。
用户 B 收到消息后,打开客户端访问 163 服务器,调用 POP3 服务。
Pop3 服务接到指令后,读取存储空间中发送给 B 的未读邮件服务。
将读取到的邮件返回给客户端软件。
4.15 DNS 解析过程,DNS 劫持了解吗?
DNS 完成的工作是:域名到 IP 地址的解析。将域名和 IP 地址相互映射的一个分布式数据库。
第一步:客户机提出域名解析请求,并将该请求发送给本地域名服务器。
第二步:当本地域名服务器收到请求后,就先查询本地缓存,如果有该纪录项,则本地域名
服务器就直接把查询结果返回。
第三步:如果本地缓存中没该纪录,则本地域名服务器就直接把请求发给根域名服务器,然 后根域名服务器再返回给本地域名服务器一个所查询域(根子域)主域名服务器地址。
第四步:本地服务器再向上一步返回的域名服务器发送请求,然后接受请求的服务器查询自己的缓存,如果没该纪录,则返回相关下级域名服务器地址。
第五步:重复第四步,直到找到正确纪录。
第六步:本地域名服务器把返回结果保存到缓存,以备下一次使用,同时还将结果返回给客户机。
DNS 劫持:在 DNS 服务器中,将 www…com 的域名对应的 IP 地址进行了变化。你解析出来的 域名对应的 IP,在劫持前后不一样。
HTTP 劫持:你 DNS 解析的域名的 IP 地址不变。在和网站交互过程中的劫持了你的请求。在网站发给你信息前就给你返回了请求。
DNS 在区域传输的时候使用 TCP 协议,其他时候使用 UDP 协议。
4.16 GET 和 POST 有什么不一样?
GET 和 POST 是 HTTP 请求的两种基本方法(记不住全部,只记这么点)
GET 在浏览器回退时是无害的,而 POST 会再次提交请求。
GET 产生的 URL 地址可以被 Bookmark,而 POST 不可以。
GET 请求会被浏览器主动 cache,而 POST 不会,除非手动设置。
GET 请求只能进行 url 编码,而 POST 支持多种编码方式。
GET 请求参数会被完整保留在浏览器历史记录里,而 POST 中的参数不会被保留。
GET 请求在 URL 中传送的参数是有长度限制的,而 POST 么有。
对参数的数据类型,GET 只接受 ASCII 字符,而 POST 没有限制。
GET 比 POST 更不安全,因为参数直接暴露在 URL 上,所以不能用来传递敏感信息。
GET 参数通过 URL 传递,POST 放在 Request body 中。
4.17 session 和 cookie 的问题?
Cookie 和 Session 都是用来跟踪浏览器用户身份的会话方式
Cookie 一般用来保存用户信息,Session 的主要作用就是通过服务端记录用户的状态
Cookie 数据保存在客户端(浏览器端),Session 数据保存在服务器端。相对来说 Session 安全性更高。如果要在 Cookie 中存储一些敏感信息,不要直接写入 Cookie 中,最好能将 Cookie 信息加密然后使用到的时候再去服务器端解密。
4.18 HTTP 是不保存状态的协议,如何保存用户状态?
HTTP 是一种无状态协议。HTTP 协议自身不对请求和响应之间的通信状态进行保存。主要通过 session 机制来进行解决,Session 的主要作用就是通过服务端记录用户的状态。
在服务端保存 Session 的方法很多,最常用的就是内存和数据库(比如是使用内存数据库 redis 保存)。既然 Session 存放在服务器端,那么我们如何实现 Session 跟踪呢?大部分情况下, 我们都是通过在 Cookie 中附加一个 Session ID 来方式来跟踪。
Cookie 被禁用怎么办**?** 最常用的就是利用 URL 把 Session ID 直接附加在 URL 路径的后面。
4.19 Arp 协议?
Arp 协议能通过接收端的 ip 地址解析到 mac 地址。
如果发送端和目标端的主机都在同一个网段,发送端发送数据帧前检查是否拥有接收端的 mac 地址,如果没有,则启动 arp,先检查缓存 ip-mac 表中是否有接收端的 mac 地址,如果有 则直接拿来即用,如果没有则在本网段(局域网)广播 arp 包,本网段各计算机都收到 arp 请 求,从发送来的数据中检查请求过来的 ip 地址与自己是否一致,如果不一致,则丢弃,如果 ip 一致,则单播返回 mac 地址给请求的计算机,发送端便获取到了接收端的 mac 地址,接收 到接收端的 mac 地址它还会缓存一份,用于下次拿来即用。
如果请求端和目标端的主机不在同一个网段呢?arp 广播的数据是被路由阻断的,不能跨到不同的网段进行广播的,因为这样广播会导致广播数据泛滥。如果不在同一个网段,则请求端 拿到的目标端的 mac 地址其实是它网关的 mac 地址,将数据帧给到网关再进行下一跳转发, 下一跳同样在自己的网段寻找到目标主机 mac 地址或再找到下一跳 mac 地址。
4.20 DDos 攻击了解吗?
分布式拒绝服务,一般来说是指攻击者利用一些被控制的设备对目标网站在较短的时间内发 起大量请求,大规模消耗目标网站的主机资源,让它无法正常服务。
5. 集合框架
5.1 ArrayList 的扩容机制?
ArrayList 扩容发生在 add()方法调用的时候,下面是 add()方法的源码:
![](https://static001.geekbang.org/infoq/e1/e1b64eb34a09e430e37fece0f1a2e058.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
ArrayList 扩容的关键方法 grow():
![](https://static001.geekbang.org/infoq/34/341a3acf28d542640cb90f85f177e160.png)
int newCapacity = oldCapacity + (oldCapacity >> 1); oldCapacity >> 1 右移运算符 原来长度的一半 再加上原长度也就是每次扩容是原来的 1.5 倍。
之前的所有都是确定新数组的长度,确定之后就是把老数组 copy 到新数组中,这样数组的扩容就结束了。
以上的一切都是 ArrayList 扩容的第一步,第二步就没啥说的了,就是把需要添加的元素添加到数组的最后一位。
5.2 HashMap 的底层实现、JDK 1.8 的时候为啥将链表转换成红黑树?HashMap 的负载因子 HashMap 和 Hashtable 的区别?HashMap 如何实现扩容?
HashMap 是用数组+链表+红黑树进行实现的,当添加一个元素(key-value)时,就首先计 算元素 key 的 hash 值,并根据 hash 值来确定插入数组中的位置,但是可能存在其他元素已经 被放在数组同一位置了,这个时候便使用链表来解决哈希冲突,当链表长度太长的时候,便 将链表转换为红黑树来提高搜索的效率。
HashMap 是基于拉链法实现的一个散列表,内部由数组和链表和红黑树实现。
数组的初始容量为 16,而容量是以 2 的次方扩充的,一是为了提高性能使用足够大的数组,二是为了能使用位运算代替取模预算。
数组是否需要扩充是通过负载因子判断的,如果当前元素个数为数组容量的 0.75 时,就会扩充数组。这个 0.75 就是默认的负载因子,可由构造传入。
为了解决碰撞,数组中的元素是单向链表类型。当链表长度到达一个阈值时(>=8), 会将链表转换成红黑树提高性能。而当链表长度缩小到另一个阈值时(<=6),又会将红黑树转换回单向链表提高性能,这里是一个平衡点。
当个数不多的时候,直接链表遍历更方便,实现起来也简单。而红黑树的实现要复杂的多。
5.3 ConcurrentHashMap 的底层实现
底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
实现线程安全的方式(重要):
① 在 JDK1.7 的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 对 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不 冲突,就不会产生并发,效率又提升 N 倍。(TreeBin: 红黑二叉树节点 Node: 链表节点)
② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
5.5 什么 ConcurrentHashMap 的读操作不需要加锁?
ConcurrentHashMap 在 jdk1.7 中是采用 Segment + HashEntry + ReentrantLock 的方式进行实现的,而 1.8 中放弃了 Segment 臃肿的设计,取而代之的是采用 Node + CAS +
Synchronized 来保证并发安全进行实现。
总结:定义成 volatile 的变量,能够在线程之间保持可见性,能够被多线程同时读,而不 会读到过期的值。由于 get 操作中只需要读不需要写共享变量,所以可以不用加锁。之所 以不会读到过期的值,依据 Java 内存模型的 happen before 原则,对 volatile 字段的写入操作先于读操作,get 总能拿到最新的值。
get 操作全程不需要加锁是因为 Node 的成员 val 是用 volatile 修饰的和数组用 volatile 修饰没有关系。
数组用 volatile 修饰主要是保证在数组扩容的时候保证可见性。
5.6 HashMap,LinkedHashMap,TreeMap 有什么区别?HashMap ,TreeMap, LinkedHashMap 使用场景?
LinkedHashMap 保存了记录的插入顺序,在用 Iterator 遍历时,先取到的记录肯定是先插入的;遍历比 HashMap 慢;
TreeMap 实现 SortMap 接口,能够把它保存的记录根据键排序(默认按键值升序排序,也可以指定排序的比较器)
一般情况下,使用最多的是 HashMap。HashMap:在 Map 中插入、删除和定位元素时;TreeMap:在需要按自然顺序或自定义顺序遍历键的情况下; LinkedHashMap:在需要输出的顺序和输入的顺序相同的情况下。
5.7 有哪些集合是线程不安全的,又有哪些集合是线程不安全的?怎么解决呢? 线程安全的集合类. Vector Stack Hashtable
java.util.concurrent 包下所有的集合类(ConcurrentHashMap,CopyOnWriteArrayList 和 CopyOnWriteArraySet 等)
5.8 什么是快速失败(fail-fast)、能举个例子吗?什么是安全失败(fail-safe)呢? 快速失败(fail-fast)
快速失败(fail-fast)是 Java 集合的一种错误检测机制。在使用迭代器对集合进行遍历的时候,我们在多线程下操作非安全失败(fail-safe)的集合类可能就会触发 fail-fast 机制,导致抛出 ConcurrentModificationException 异常。另外,在单线程下,如果在遍历过程中对集合对象的内容进行了修改的话也会触发 fail-fast 机制。
举个例子:多线程下,如果线程 1 正在对集合进行遍历,此时线程 2 对集合进行修改(增加、删除、修改),或者线程 1 在遍历过程中对集合进行修改,都会导致线程 1 抛出异常。
安全失败(fail-safe)
采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集 合内容,在拷贝的集合上进行遍历。所以,在遍历过程中对原集合所作的修改并不能被迭代器检测到,故不会抛 ConcurrentModificationException
5.8 HashMap 多线程操作导致死循环问题异常
主要原因在于并发下的 rehash 会造成元素之间会形成一个循环链表。不过,jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其他问题比如数据丢失。
![](https://static001.geekbang.org/infoq/2a/2ada5c2abda50d18d26f0633c6c03e17.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
6 多线程
6.1 在多线程情况下如何保证线程安全。
6.2 写一个死锁的例子
![](https://static001.geekbang.org/infoq/00/00d9b22a74e54dc7906d90d4424fa18c.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
![](https://static001.geekbang.org/infoq/0c/0c7a21f1d9b4115cd4e868bee78e0499.png)
6.3 讲一下 volatile 关键字的作用。
保证了不同线程对该变量操作的内存可见性。
禁止指令重排序。
当写一个 volatile 变量时,JMM 将本地内存更改的变量写回到主内存中。
当取一个 volatile 变量时,JMM 将使线程对应的本地内存失效,然后线程将从主内存读取共 享变量。
volatile 可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在 JVM 底层是基于内存屏障实现的。
当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到 CPU 缓存中。如果计算机有多个 CPU,每个线程可能在不同的 CPU 上被处理,这意味着每个线程可以拷贝到不同的本地内存中。
而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过本地内存这一步,所以就不会有可见性问题
对 volatile 变量进行写操作时,会在写操作后加一条 store 屏障指令,将工作内存中的共享变量刷新回主内存;
对 volatile 变量进行读操作时,会在读操作前加一条 load 屏障指令,从主内存中读取共享变量;
volatile 可以通过内存屏障防止指令重排序问题。硬件层面的内存屏障分为读屏障和写屏障。
对于读屏障来说,在指令前插入读屏障,可以让高速缓存中的数据失效,强制重新从主内存 加载数据。
对于写屏障来说,在指令后插入写屏障,能让写入缓存中的最新数据更新写入主内存,让其 他线程可见。
6.4 synchronized 作用,讲一讲底层实现。
synchronized 关键字解决的是多个线程之间访问资源的同步性,调用操作系统内核态做同 步,synchronized 关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
synchronized 关键字最主要的三种使用方式:
修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
修饰静态方法也就是给当前类加锁,会作用于类的所有对象实例。因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
修饰代码块指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
synchronized 关键字底层原理属于 JVM 层面。
① synchronized 同步语句块的情况:synchronized 同步语句块的实现使用的是 monitorenter 和
monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。
② synchronized 修饰方法的的情况:JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
6.5 ReetrantLock 和 synchronized 的区别
6.6 说说 synchronized 关键字和 volatile 关键字的区别
volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 关键 字要好。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代 码块。synchronized 关键字在 JavaSE1.6 之后进行了主要包括为了减少获得锁和释放锁 带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提 升,实际开发中使用 synchronized 关键字的场景还是更多一些。
多线程访问 volatile 关键字不会发生阻塞,而 synchronized 关键字可能会发生阻塞 volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字 两者都能保证。
volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
6.7 ReetrantLock 实现方式
锁的获取过程:
通过 cas 操作来修改 state 状态,表示争抢锁的操作,如果能够获取到锁,设置当前获得锁状态的线程。compareAndSetState(0, 1)
如果没有获取到锁,尝试去获取锁。acquire(1)。
通过 tryAcquire 尝试获取独占锁,如果成功返回 true,失败返回 false。如果是同 一个线程来获得锁,则直接增加重入次数,并返回 true。
如果 tryAcquire 失败,则会通过 addWaiter 方法将当前线程封装成 Node,添加到 AQS 队列尾部
acquireQueued,将 Node 作为参数,通过自旋去尝试获取锁。(如果前驱为 head 才有资格进行锁的抢夺。)
如果获取锁失败,则挂起线程。
锁的释放过程:
释放锁
如果锁能够被其他线程获取,唤醒后继节点中的线程。一般情况下只要唤醒后继结点的 线程就行了,但是后继结点可能已经取消等待,所以从队列尾部往前回溯,找到离头结 点最近的正常结点,并唤醒其线程。
在获得同步锁时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在 队列中进行自旋;移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状 态。在释放同步状态时,同步器调用 tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。
6.8 AQS 原理
AQS 是一个用来构建锁和同步器的框架。AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请 求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关 联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。
AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。
状态信息通过 protected 类型的 getState,setState,compareAndSetState 进行操作。
AQS 定义两种资源共享方式
Exclusive(独占):只有一个线程能执行,如 ReentrantLock。又可分为公平锁和非公平锁:
Share(共享):多个线程可同时执行。
以 ReentrantLock 为例,state 初始化为 0,表示未锁定状态。A 线程 lock()时,会调用 tryAcquire()独占该锁并将 state+1。此后,其他线程再 tryAcquire()时就会失败,直到 A 线程 unlock()到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证 state 是能回到零态的。
再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线 程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后 countDown()一次,state 会 CAS(Compare and Swap)减 1。等到所有子线程都执行完后(即 state=0),会 unpark()主调用线程,然后主调用线程就会从 await()函数返回,继续后余动作。
6.9 interrupt,interrupted 与 isInterrupted 方法的区别? 如何停止一个正在运行的线程
interrupt()方法的作用实际上是:在线程受到阻塞时抛出一个中断信号,这样线程就得以退出阻塞状态。
interrupted()调用的是 currentThread().isInterrupted(true)方法,即说明是返回当前 线程的是否已经中断的状态值,而且有清理中断状态的机制。测试当前线程是否已经中断,线程的中断状态由该方法清除。即如果连续两次调用该方法, 则第二次调用将返回 false(在第一次调用已清除 flag 后以及第二次调用检查中断状态之前, 当前线程再次中断的情况除外)所以,interrupted()方法具有清除状态 flag 的功能
isInterrupted()调用的是 isInterrupted(false)方法,意思是返回线程是否已经中断的状态,它没有清理中断状态的机制。
nterrupt()方法用于中断线程。调用该方法的线程的状态为将被置为"中断"状态。
中断可以理解为线程的一个标识位属性,它表示一个运行中的线程是否被其他线程进行了中 断操作。中断好比其他线程对该线程打了个招呼,其他线程通过调用该线程的 interrupt()方法对其进行中断操作。
注意:线程中断仅仅是置线程的中断状态位,不会停止线程。需要用户自己去监视线程的状 态为并做处理。
interrupted() 检测当前线程是否已经中断,是则返回 true,否则 false,并清除中断状态。换言之,如果该方法被连续调用两次,第二次必将返回 false,除非在第一次与第二次的瞬间线程再次被中断。如果中断调用时线程已经不处于活动状态,则返回 false。
isInterrupted() 检测当前线程是否已经中断,是则返回 true,否则 false。中断状态不受该方法的影响。如果中断调用时线程已经不处于活动状态,则返回 false。
在 java 中有以下 3 种方法可以终止正在运行的线程:
使用 stop 方法强行终止,但是不推荐这个方法,因为 stop 和 suspend 及 resume 一样都 是过期作废的方法
使用 interrupt()方法中断线程
6.10 线程池作用?Java 线程池有哪些参数?阻塞队列有几种?拒绝策略有几种?线程池的工作机制?
(非大厂会问:有哪些线程池)
降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会 降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
线程池通过 ThreadPoolExecutor 的方式进程创建
![](https://static001.geekbang.org/infoq/9c/9ca4bbea75c80722796602e9617e4b14.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
3 个最重要的参数:
corePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。
maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
workQueue : 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
ThreadPoolExecutor 其他常见参数:
keepAliveTime:当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime 才会被回收销毁; unit:keepAliveTime 参数的时间单位。 threadFactory:executor 创建新线程的时候会用到。 handler:饱和策略。关于饱和策略下面单独介绍一下。
阻塞队列有几种
用来保存等待被执行的任务的阻塞队列,且任务必须实现 Runable 接口,在 JDK 中提供了如下 阻塞队列:
1、ArrayBlockingQueue(有界队列):基于数组结构的有界阻塞队列,按 FIFO 排序任务;
2、LinkedBlockingQuene(有/无界队列(基于链表的,传参就是有界,不传就是无界):
基于链表结构的阻塞队列,按 FIFO 排序任务,吞吐量通常要高于 ArrayBlockingQuene;
3、SynchronousQuene(同步移交队列(需要一个线程调用 put 方法插入值,另一个线程调
用 take 方法删除值)):一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调 用移除操作,
否则插入操作一直处于阻塞状态,吞吐量通常要高于 LinkedBlockingQuene;
4、PriorityBlockingQuene(具有优先级的、无限阻塞队列):具有优先级的无界阻塞队 列;
ThreadPoolExecutor 饱和策略定义:
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任时, ThreadPoolTaskExecutor 定义一些策略:
ThreadPoolExecutor.AbortPolicy :抛出 RejectedExecutionException 来拒绝新任务的处理。
ThreadPoolExecutor.CallerRunsPolicy :调用执行自己的线程运行任务。也就是直接在调用 execute 方法的线程中运行( run )被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果 您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选 择这个策略。
ThreadPoolExecutor.DiscardPolicy : 不处理新任务,直接丢弃掉。
ThreadPoolExecutor.DiscardOldestPolicy : 此策略将丢弃最早的未处理的任务请求。
线程池的工作过程
提交任务后,线程池先判断线程数是否达到了核心线程数(corePoolSize)。如果未达 到线程数,则创建核心线程处理任务;否则,就执行下一步;
接着线程池判断任务队列是否满了。如果没满,则将任务添加到任务队列中;否则,执 行下一步;
接着因为任务队列满了,线程池就判断线程数是否达到了最大线程数。如果未达到,则 创建非核心线程处理任务;否则,就执行饱和策略,默认会抛出 RejectedExecutionException 异常。
常见线程池
**newFixedThreadPool:**最大线程和核心线程一致,用的是 LinkedBlockingQueue,无限容量。
![](https://static001.geekbang.org/infoq/bc/bc216104ca00f57365e1e187925b76c5.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
**newSingleThreadExecutor:**最大线程和核心线程一致,用的是 LinkedBlockingQueue,无限容量。
![](https://static001.geekbang.org/infoq/53/53fa9e84b94d5e860778ce8469f17e31.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
newCachedThreadPool:没有核心线程,直接向 SynchronousQueue 中提交任务,如果有空闲线程,就去取出任务执行。如果没有空闲线程,就新建一个。执行完任务的线 程有 60 秒生存时间,如果在这个时间内可以接到新任务,才可以存活下去。
![](https://static001.geekbang.org/infoq/11/115ff16b89eea2bb2e9725d23ca72ed6.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
6.11 线程池拒绝策略分别使用在什么场景?
AbortPolicy 中止策略:丢弃任务并抛出 RejectedExecutionException 异常。使用场景:这个就没有特殊的场景了,但是有一点要正确处理抛出的异常。当自己自定 义线程池实例时,使用这个策略一定要处理好触发策略时抛的异常,因为他会打断当前 的执行流程。
DiscardPolicy 丢弃策略:ThreadPoolExecutor.DiscardPolicy:丢弃任务,但是不抛出 异常。如果线程队列已满,则后续提交的任务都会被丢弃,且是静默丢弃。使用场景:如果你提交的任务无关紧要,你就可以使用它 。
DiscardOldestPolicy 弃老策略:丢弃队列最前面的任务,然后重新提交被拒绝的任务。使用场景:这个策略还是会丢弃任务,丢弃时也是毫无声息,但是特点是丢弃的是老的 未执行的任务,而且是待执行优先级较高的任务。基于这个特性,能想到的场景就是, 发布消息和修改消息,当消息发布出去后,还未执行,此时更新的消息又来了,这个时 候未执行的消息的版本比现在提交的消息版本要低就可以被丢弃了。
**CallerRunsPolicy 调用者运行策略:**由调用线程处理该任务。使用场景:一般在不允许失败的、对性能要求不高、并发量较小的场景下使用。
6.12 线程死锁,解除线程死锁有哪几种方式?(两次栽倒这题上了,时间太久又忘记了,如何解决很重要)
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某 个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
无效的图片地址
解决死锁的策略
死锁预防
死锁避免
死锁检测
死锁解除
死锁预防: 破坏导致死锁必要条件中的任意一个就可以预防死锁。例如:
(1) 破坏保持和等待条件: 一次性申请所有资源,之后不再申请资源,如果不满足资源条件则得不到资源分配。
(2) 破坏不可剥夺条件: 当一个进程获得某个不可剥夺的资源时,提出新的资源申请,若不满足,则释放所有资源。
(3) 破坏循环等待条件: 按某一顺序申请资源,释放资源则反序释放。
死锁避免: 进程在每次申请资源时判断这些操作是否安全。
死锁检测: 判断系统是否属于死锁的状态,如果是,则执行死锁解除策略。
死锁解除: 将某进程所占资源进行强制回收,然后分配给其他进程。(与死锁检测结合使用的)
6.13 ThreadLocal 是什么,应用场景是什么,原理是怎样的?
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程 都有自己的专属本地变量该如何解决呢? JDK 中提供的 ThreadLocal 类正是为了解决这样的问题。 ThreadLocal 类主要解决的就是让每个线程绑定自己的值,可以将 ThreadLocal 类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是 ThreadLocal 变量名的由来。他们可以使用 get()和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。 ThreadLocal 最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上, ThreadLocal 可以理解为只是 ThreadLocalMap 的封装,传递了变量 ThreadLocalMap 值。我们可以把 ThrealLocal 理解为 ThreadLocal 类实现的定制化的 HashMap 。类中可以通过 Thread.currentThread() 获取到当前线程对象后,直接通过 getMap(Thread t)可以访问到该线程的 ThreadLocalMap 对象。每个 Thread 中都具备一个 ThreadLocalMap ,而 ThreadLocalMap 可以存储以 ThreadLocal 为 key,Object 对象为 value 的键值对。
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {}
比如我们在同一个线程中声明了两个 ThreadLocal 对象的话,会使用 Thread 内部都是使用仅有那个 ThreadLocalMap 存放数据的, ThreadLocalMap 的 key 就是 ThreadLocal 对象,value 就是 ThreadLocal 对象调用 set 方法设置的值。
6.14 ThreadLocal 类为什么要加上 private static 修饰?
首先,private 修饰与 ThreadLocal 本身没有关系,private 更多是在安全方面进行考虑。static 修饰这个变量,这个变量是针对一个线程内所有操作共享的,所以设置为静态变量,所有此 类实例共享此静态变量,也就是说在类第一次被使用时装载,只分配一块存储空间,所有此 类的对象(只要是这个线程内定义的)都可以操控这个变量。(设置为 static 可以避免每个线程从任务队列中获取 task 后重复创建 ThreadLocal 所关联的对象)可以解决内存泄露问题(看下一问)。
6.15 ThreadLocal 有什么缺陷?如果线程池的线程使用 ThreadLocal 会有什么问题?
ThreadLocalMap 如何解决冲突?
采用线性探测的方式 。
![](https://static001.geekbang.org/infoq/27/27a06410ab752e23563e1e9dcdd20bb2.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来, ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set() 、 get() 、 remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal 方法后 最好手动调用 remove() 方法。
![](https://static001.geekbang.org/infoq/4b/4b4bdf6b3d5082d7308bcb17b3f26fc2.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
在 ThreadLocalMap 中,也是用 Entry 来保存 K-V 结构数据的。但是 Entry 中 key 只能是 ThreadLocal 对象,这点被 Entry 的构造方法已经限定死了。Entry 继承自 WeakReference( 弱引用,生命周期只能存活到下次 GC 前 ),但只有 Key 是弱引用类型的, Value 并非弱引用。由于 ThreadLocalMap 的 key 是弱引用,而 Value 是强引用。这就导致了 一个问题,ThreadLocal 在没有外部对象强引用时,发生 GC 时弱引用 Key 会被回收,而 Value 不会回收。当线程没有结束,但是 ThreadLocal 已经被回收,则可能导致线程中存在 ThreadLocalMap<null, Object>的键值对,造成内存泄露。( ThreadLocal 被回收,ThreadLocal 关联的线程共享变量还存在 )。
为了防止此类情况的出现,我们有两种手段:
1、使用完线程共享变量后,显示调用 ThreadLocalMap.remove()方法清除线程共享变量;
既然 Key 是弱引用,那么我们要做的事,就是在调用 ThreadLocal 的 get()、set()方法时完成后再调用 remove 方法,将 Entry 节点和 Map 的引用关系移除,这样整个 Entry 对象在 GC Roots 分析后就变成不可达了,下次 GC 的时候就可以被回收。
2、JDK 建议 ThreadLocal 定义为 private static,这样 ThreadLocal 的弱引用问题则不存在了。
![](https://static001.geekbang.org/infoq/26/26d164bf94a046a575c215505fc17c39.jpeg?x-oss-process=image/resize,p_80/auto-orient,1)
6.16 介绍一下 Java 有哪些锁
(synchronized、juc 提供的锁如 ReentrantLock、CountDownLatch、CyclicBarrier、Semaphore 等)
公平锁/非公平锁 (重要)
可重入锁
独享锁/共享锁 (重要)
互斥锁/读写锁
乐观锁/悲观锁 (重要)
偏向锁/轻量级锁/重量级锁 (重要)
自旋锁
公平锁/非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁。
非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比 先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。
对于 Java ReentrantLock 而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。对于 Synchronized 而言,也是一种非公平锁。由于其并不像 ReentrantLock 是通过 AQS 的来实现线程调度,所以并没有任何办法 使其变成公平锁。
ke 重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会 自动获取锁。
对于 Java ReentrantLock 而言,是一个可重入锁,其名字是 Re entrant Lock 重新进入锁。
对于 Synchronized 而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。
独享锁/共享锁 (互斥锁/读写锁)
独享锁是指该锁一次只能被一个线程所持有。共享锁是指该锁可被多个线程所持有。
对于 Java ReentrantLock 而言,其是独享锁。但是对于 Lock 的另一个实现类 ReadWriteLock,其读锁是共享锁,其写锁是独享锁。
上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。 互斥锁在 Java 中的具体实现就是 ReentrantLock
读写锁在 Java 中的具体实现就是 ReadWriteLock
乐观锁/悲观锁
乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。
对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作 一定会出问题。
乐观锁在更新数据的时候,主要就是两个步骤:冲突检测和数据更新。乐观的认为,不 加锁的并发操作是没有事情的。当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被 告知这次竞争中失败,并可以再次尝试。
从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常 多的场景,不加锁会带来大量的性能提升。
悲观锁在 Java 中的使用,就是利用各种锁。
乐观锁在 Java 中的使用,是无锁编程,常常采用的是 CAS 算法,典型的例子就是原子 类,通过 CAS 自旋实现原子操作的更新。
CAS 包含三个参数 CAS(V,E,N)。V 表示要更新的变量,E 表示预期的值,N 表示新值。仅当要更新的变量值等于预期的值时,才会将要更新的变量值的值设置成新 值,否则什么都不做。
偏向锁/轻量级锁/重量级锁
这三种锁是指锁的状态,并且是针对 Synchronized。
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取 锁的代价。
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级 锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下 去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。 重量级锁会让其他申请的线程进入阻塞,性能降低。
自旋锁
自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样 的好处是减少线程上下文切换的消耗,缺点是循环会消耗 CPU。
6.17 乐观锁和悲观锁讲一下,哪些地方用到。
乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。
悲观锁对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操 作一定会出问题。
乐观锁在更新数据的时候,会采用尝试更新,不断重试的方式更新数据。乐观的认为,不加 锁的并发操作是没有事情的。
共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程,传统的 关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做 操作之前先上锁,数据库的 for update SQL 语句。Java 中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。
乐观锁适用于多读的应用类型,这样可以提高吞吐量。在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
乐观锁适用于写比较少的情况下(多读场景),一般多写的场景下用悲观锁就比较合适,一 般会经常产生冲突,这就会导致上层应用会不断的进行 retry,这样反倒是降低了性能。
小结
作为面试官的角度,会同时看技术能力(代码考核)以及项目经历。对于项目经历,建议可以自行做一些小型项目,要么选择做的很深在某个方向有很强的表现,要么就利用开源做一些有价值的项目。
今日份分享已结束,请大家多多包涵和指点!
评论