(21-40)计算机 Java后端 实习 and 秋招 面试高频问题汇总
写在前面
我们这一届的秋招已经结束了,回想过去几个月,从暑期实习到秋招,我总计经历了上百场面试,也算是小有经验,也阅读了数千篇面经,受益匪浅。所以最终我想要做这样一份这样的汇总,是因为在我经历秋招最艰难、最迷茫的时候,正是众多前辈和同学们分享的宝贵经验与经历,陪伴并帮助我度过了那些焦虑而迷茫的时刻。对于他们的无私分享,我心怀深深的感激,也希望能够尽自己的一份绵薄之力,继续帮助更多后来的人。
在过去的两年里,我陆陆续续地积累了大约 30 万字的内容,涵盖了近 300 个后端高频面试问题(有些太过刁钻的我会做删除处理,不保证最终达到300个),如果之后时间允许,我会将它们逐步整理并分享出来。在可以预见的未来,如果没有意外的变动,这些内容都会免费地“开源”,因为我曾经无数次从别人的分享中获得过帮助,如果有机会,我愿意把这份温暖和善意传递下去
前文链接
21.怎么判断用户是否登录、如果有多台服务器,怎么判断一个请求是否已经登录
- 怎么判断用户是否登录:一种常见的方法是使用cookie和session机制,原理就是用户登录的时候在服务器端创建一个session,登录成功后,把用户信息放在session里,接下里,服务器会把sessionID返回给用户的浏览器,浏览器接收到这个cookie后,用户再访问网站的URL地址时,浏览器会顺带着把这个网站下的cookies全部发送给服务器,服务器检查cookies里有木有sessionID,如果有,会根据sessionID找到session,然后再判断session里有木有用户信息,有则用户已登录,反之就是没登录³。
- 如果有多台服务器,怎么判断一个请求是否已经登录:这个问题涉及到分布式系统中的会话管理问题,一种常用的解决方案是使用单点登录(Single Sign On, SSO),即用户只需在一个系统中进行登录认证,就可以访问其他系统而无需再次登录。SSO的实现方式有多种,例如使用OAuth协议、使用Redis存储共享session、使用JWT(JSON Web Token)等¹²。
22.synchronize原理,自旋锁等⼀系列锁了解吗
- synchronize原理:synchronize是Java中的一个关键字,用于实现线程同步,保证多个线程对共享资源的操作是互斥的。synchronize的原理是基于对象内部的一个监视器锁(monitor)来实现的,每个对象都有一个monitor,当一个线程进入synchronize修饰的方法或代码块时,就会获取该对象的monitor,其他线程就无法进入该方法或代码块,直到持有monitor的线程退出并释放monitor。synchronize可以保证原子性、可见性和有序性¹²³。
- 自旋锁等一系列锁了解吗:自旋锁是一种锁的优化策略,它的思想是当一个线程尝试获取锁时,如果锁已经被占用,就不会立即阻塞,而是在循环中不断尝试获取锁,直到成功或超时。自旋锁适用于锁占用时间较短、线程数不多的场景,可以避免线程切换带来的开销。但是如果锁占用时间较长或者线程数较多,自旋锁会消耗CPU资源,降低性能。自旋锁有多种实现方式,例如CAS(Compare And Swap)操作、Ticket Lock、CLH Lock等⁴⁵。除了自旋锁之外,还有其他类型的锁,例如悲观锁、乐观锁、公平锁、非公平锁、可重入锁、独占锁、共享锁,偏向锁,轻量级锁等²⁴。
偏向锁是Java虚拟机对synchronized关键字的一种优化策略,它利用对象头中的标志位来记录第一个获取该对象锁的线程ID,如果该线程再次请求该对象锁,就不需要进行CAS操作来获取锁,而是直接进入同步代码块。如果有其他线程尝试获取该对象锁,就会触发偏向锁的撤销,将偏向锁升级为轻量级锁或重量级锁 。
- 轻量级锁需要做CAS吗:是的。轻量级锁是一种利用CAS操作来实现的锁,它的目的是在没有多线程竞争的情况下,减少重量级锁的开销。轻量级锁的加锁过程是通过CAS操作将对象头中的Mark Word替换为指向当前线程栈中的锁记录(Lock Record)的指针,如果替换成功,则表示获取锁成功,否则表示有其他线程竞争锁,需要升级为重量级锁¹²³。
- CAS操作是什么:CAS(Compare And Swap)操作是一种原子性的比较并替换操作,它需要三个参数:内存地址V、旧值A、新值B。CAS操作会比较内存地址V中的值是否等于旧值A,如果相等,则用新值B替换旧值A,并返回true,否则不做任何操作,并返回false。CAS操作可以保证多线程对共享变量的更新不会发生冲突⁴⁵。
23.java中 G1、CMS是什么
- Java中G1、CMS是什么:G1和CMS都是Java虚拟机中的垃圾收集器,它们的目的是回收堆内存中不再使用的对象,以释放空间给新的对象。G1和CMS有一些共同点,比如都支持并发和分代收集,都关注降低垃圾回收的停顿时间,都适用于服务端应用。但它们也有一些区别,比如:
- G1是基于Region(区域)的垃圾收集器,它将堆内存划分为多个大小相等的独立区域,每个区域可以属于新生代或老年代,这样可以避免在整个堆范围内进行回收。CMS是基于分代模型的垃圾收集器,它将堆内存划分为新生代和老年代,每个代可以由多个不连续的空间组成¹²。
- G1是基于标记-整理算法的垃圾收集器,它在回收时不会产生内存碎片,而且可以根据用户指定的停顿时间来制定回收计划,从而达到可预测的停顿时间。CMS是基于标记-清除算法的垃圾收集器,它在回收时会产生内存碎片,而且无法处理浮动垃圾(并发清理阶段产生的垃圾),可能导致Concurrent Mode Failure(并发模式失败)¹²³。
- G1是Java 9之后的默认垃圾收集器,它是CMS的长期替代方案,也是未来发展的方向。CMS已经在Java 14中被废弃⁴ 。
CMS是把堆空间划分为新生代和老年代,每个代可以由多个不连续的空间组成,但是在回收时,CMS会对整个新生代或老年代进行扫描,而不是只针对某个空间。G1则是把堆空间划分为多个大小相等的独立区域,每个区域可以属于新生代或老年代,这样在回收时,G1可以根据区域的回收价值和成本来选择性地回收部分区域,而不是对整个堆进行扫描。这样可以减少垃圾回收的停顿时间和开销。
24.rabbit内部存储结构是什么
RabbitMQ存储层包含两个部分:队列索引和消息存储。
RabbitMQ消息有两种类型:持久化消息和非持久化消息,这两种消息都会被写入磁盘。
持久化消息在到达队列时写入磁盘,同时会内存中保存一份备份,当内存吃紧时,消息从内存中清除。非持久化消息一般只存于内存中,当内存吃紧时会被换入磁盘,以节省内存空间。
你好,这是Bing。根据我查到的信息,RabbitMQ中的持久化消息和非持久化消息是由用户声明的¹²。持久化消息会被保存在磁盘中,不会因为RabbitMQ服务重启或崩溃而丢失²³。非持久化消息只会在内存中存储,当内存不足时可能会被写入磁盘,但是重启后就会消失²。持久化消息的性能要低于非持久化消息,因为需要写入磁盘²。
持久化队列和非持久化队列的区别是,持久化队列会被保存在磁盘中,固定并持久的存储,当Rabbit服务重启后,该队列会保持原来的状态在RabbitMQ中被管理¹³⁴,而非持久化队列不会被保存在磁盘中,Rabbit服务重启后队列就会消失¹³⁴。非持久化队列的性能要高于持久化队列,因为不需要写入磁盘¹。持久化队列的优点是会一直存在,不会随服务的重启或服务器的宕机而消失¹。
持久化队列和非持久化队列中保存的消息可以是持久化消息也可以是非持久化消息¹。持久化消息会同时写入磁盘和内存,加快读取速度¹。非持久化消息一般只保存在内存中,在内存吃紧的时候会被写入到磁盘中,以节省内存空间¹。这两种类型的消息的落盘处理都在RabbitMQ的"持久层"中完成¹。
是的,你说得对。非持久化队列中的消息,如果用户指定了持久化消息,仍然要保存到磁盘中的。但是,如果RabbitMQ服务重启或崩溃,非持久化队列本身会消失,所以保存在磁盘中的消息也就无法被消费了。
队列索引:rabbit_queue_index(下文简称index)
index维护队列的落盘消息的信息,如存储地点、是否已被交付给消费者、是否已被消费者ack等。每个队列都有相对应的index。
index使用顺序的段文件来存储,后缀为.idx,文件名从0开始累加,每个段文件中包含固定的segment_entry_count条记录,默认值是16384。每个index从磁盘中读取消息的时候,至少要在内存中维护一个段文件,所以设置queue_index_embed_msgs_below值得时候要格外谨慎,一点点增大也可能会引起内存爆炸式增长。
消息存储:rabbit_msg_store(下文简称store)
store以键值的形式存储消息,所有队列共享同一个store,每个节点有且只有一个。 从技术层面上说,store还可分为msg_store_persistent和msg_store_transient,前者负责持久化消息的持久化,重启后消息不会丢失;后者负责非持久化消息的持久化,重启后消息会丢失。通常情况下,两者习惯性的被当作一个整体。
store使用文件来存储,后缀为.rdq,经过store处理的所有消息都会以追加的方式写入到该文件中,当该文件的大小超过指定的限制(file_size_limit)后,将会关闭该文件并创建一个新的文件以供新的消息写入。文件名从0开始进行累加。在进行消息的存储时,RabbitMQ会在ETS(Erlang Term Storage)表中记录消息在文件中的位置映射和文件的相关信息。
消息(包括消息头、消息体、属性)可以直接存储在index中,也可以存储在store中。最佳的方式是较小的消息存在index中,而较大的消息存在store中。这个消息大小的界定可以通过queue_index_embed_msgs_below来配置,默认值为4096B。当一个消息小于设定的大小阈值时,就可以存储在index中,这样性能上可以得到优化(可理解为数据库的覆盖索引和回表)。 读取消息时,先根据消息的ID(msg_id)找到对应存储的文件,如果文件存在并且未被锁住,则直接打开文件,从指定位置读取消息内容。如果文件不存在或者被锁住了,则发送请求由store进行处理。
删除消息时,只是从ETS表删除指定消息的相关信息,同时更新消息对应的存储文件和相关信息。在执行消息删除操作时,并不立即对文件中的消息进行删除,也就是说消息依然在文件中,仅仅是标记为垃圾数据而已。当一个文件中都是垃圾数据时可以将这个文件删除。当检测到前后两个文件中的有效数据可以合并成一个文件,并且所有的垃圾数据的大小和所有文件(至少有3个文件存在的情况下)的数据大小的比值超过设置的阈值garbage_fraction(默认值0.5)时,才会触发垃圾回收,将这两个文件合并,执行合并的两个文件一定是逻辑上相邻的两个文件。
25.zset是用什么实现的
有序集合(zset)同样使用了两种不同的存储结构,分别是 zipList(压缩列表)和 skipList(跳跃列表),当 zset 满足以下条件时使用压缩列表:
ziplist:满足以下两个条件的时候
- 元素数量少于128的时候
- 每个元素的长度小于64字节
skiplist:不满足上述两个条件就会使用跳表,具体来说是组合了map和skiplist
- map用来存储member到score的映射,这样就可以在O(1)时间内找到member对应的分数
- skiplist按从小到大的顺序存储分数
- skiplist每个元素的值都是[score,value]对
skiplist本质上是并行的有序链表,但它克服了有序链表插入和查找性能不高的问题。它的插入和查询的时间复杂度都是O(logN)
普通有序链表的插入需要一个一个向前查找是否可以插入,所以时间复杂度为O(N),比如下面这个链表插入23,就需要一直查找到22和26之间。
在上面这个结构中,插入23的过程是
- 先使用第2层链接head->7->19->26,发现26比23大,就回到19
- 再用第1层连接19->22->26,发现比23大,那么就插入到26之前,22之后
上面这张图就是跳表的初步原理,但一个元素插入链表后,应该拥有几层连接呢?跳表在这块的实现方式是随机的,也就是23这个元素插入后,随机出一个数,比如这个数是3,那么23就会有如下连接:
- 第3层head->23->end
- 第2层19->23->26
- 第1层22->23->26
下面这张图展示了如何形成一个跳表
总结一下跳表原理:
每个跳表都必须设定一个最大的连接层数MaxLevel
第一层连接会连接到表中的每个元素
插入一个元素会随机生成一个连接层数值[1, MaxLevel]之间,根据这个值跳表会给这元素建立连接
插入某个元素的时候先从最高层开始,当跳到比目标值大的元素后,回退到上一个元素,用该元素的下一层连接进行遍历,周而复始直到第一层连接,最终在第一层连接中找到合适的位置
redis中skiplist的MaxLevel设定为32层
skiplist原理中提到skiplist一个元素插入后,会随机分配一个层数,而redis的实现,这个随机的规则是:
一个元素拥有第1层连接的概率为100%
一个元素拥有第2层连接的概率为50%
一个元素拥有第3层连接的概率为25%
以此类推...
为了提高搜索效率,redis会缓存MaxLevel的值,在每次插入/删除节点后都会去更新这个值,这样每次搜索的时候不需要从32层开始搜索,而是从MaxLevel指定的层数开始搜索
这句话是关于跳跃表的一种优化方法,跳跃表是一种用来实现有序集合的数据结构,它由多层链表组成,每一层链表都是前一层链表的子集,每个结点都有一个指向下一层的指针。跳跃表的查询效率很高,因为它可以从高层链表开始搜索,然后逐渐降低层级,直到找到目标结点。但是如果每次搜索都从最高层开始,而最高层的结点数很少,那么就会浪费很多时间。所以为了提高搜索效率,redis会缓存MaxLevel的值,这个值表示当前跳跃表的最高有效层级,也就是最高层中有两个或以上的结点的层级。这样每次搜索的时候就不需要从32层开始搜索,而是从MaxLevel指定的层数开始搜索²³。这样可以减少不必要的比较次数,提高搜索效率。
26.CAS和版本号
乐观锁和悲观锁是两种并发控制的策略,乐观锁认为数据在一般情况下不会造成冲突,所以在访问数据时不会加锁,而是在更新数据时判断是否有其他线程修改过数据,如果有则重试或者放弃操作;悲观锁认为数据在一般情况下会造成冲突,所以在访问数据时会加锁,防止其他线程修改数据,直到操作完成后释放锁。乐观锁和悲观锁各有优缺点,适用于不同的场景。乐观锁适合读多写少的场景,可以减少锁的开销,提高并发性能;悲观锁适合写多读少的场景,可以避免数据不一致的问题,保证数据的安全性。
乐观锁的实现方式有两种:CAS(Compare And Swap)和版本号机制。CAS是一种原子操作,它需要三个参数:内存地址V,预期值A和新值B。CAS指令执行时,比较内存地址V的值是否等于预期值A,如果相等则将新值B写入V,否则不做任何操作。CAS可以保证共享变量的原子性,但是也有一些缺点,比如ABA问题(即一个值被修改了两次又恢复原值),自旋开销(即如果更新失败则不断重试),以及只能操作一个变量¹²。
版本号机制是另一种实现乐观锁的方式,它需要在数据表中增加一个版本号字段,每次更新数据时,将版本号加一,并且检查更新前后的版本号是否一致。如果一致,则说明没有其他线程修改过数据,更新成功;如果不一致,则说明有其他线程修改过数据,更新失败³⁴。版本号机制可以避免ABA问题,也可以操作多个变量,但是也需要额外的空间来存储版本号,并且可能存在并发冲突导致更新失败的情况。
CAS只能保证单个变量的原子性,是因为它的底层实现是基于CPU的cmpxchg指令,这个指令只能对一个内存地址进行比较和交换操作²⁴。如果要对多个变量进行原子操作,就需要使用锁或者其他的方式¹³⁵。CAS只能操作一个变量,也是它的一个缺点之一,除此之外,CAS还可能存在ABA问题,自旋开销和并发冲突等问题¹⁶。
27.mysql怎么处理并发访问呢
mysql处理并发访问的主要方式是通过读写锁和多版本并发控制 (MVCC)。¹
读写锁可以分为表锁和行锁,表锁会锁定整张表,行锁会锁定某一行数据。¹²
多版本并发控制 (MVCC) 是一种优化并发读写的机制,它可以让读操作不用加锁,而是根据每行记录的版本号来判断数据的可见性。¹
mysql的并发控制也受到存储引擎的影响,不同的存储引擎有不同的实现方式。³
MVCC是一种不加锁的读取方式,它可以让读操作和写操作并行进行,提高数据库的性能。¹²
但是MVCC并不是完全不需要加锁,它只能避免读写冲突,也就是快照读不加锁,但是写写冲突还是需要加锁的,也就是当前读需要加锁。³⁴
当前读是指对数据进行修改的操作,例如select ... for update, update, delete, insert等语句,这些语句都会对数据加排他锁,防止其他事务修改同一条记录。⁴⁵
快照读是指对数据进行查询的操作,例如select语句,这些语句不会对数据加锁,而是根据记录的版本号来判断数据是否可见。⁴⁵
MVCC只能工作在REPEATABLE READ (可重复读)和READ COMMITED (提交读)两种事务隔离级别下。其他两个隔离级别都与MVCC不兼容,因为READ UNCOMMITED (未提交读)总是读取最新的数据行,而不是符合当前事务版本的数据行;而SERIALIZABLE则会对所有读取的行都加锁,也不符合MVCC的思想。
InnoDB通过为每一行记录添加两个额外的隐藏的值来实现MVCC,这两个值一个记录这行数据何时被创建,另外一个记录这行数据何时过期(或者被删除)。但是InnoDB并不存储这些事件发生时的实际时间,相反它只存储这些事件发生时的系统版本号(LSN)。这是一个随着事务的创建而不断增长的数字。每个事务在事务开始时会记录它自己的系统版本号。每个查询必须去检查每行数据的版本号与事务的版本号是否相同。
28.线程通信与进程通信
线程通信和进程通信是两种不同的并发编程的方式,它们的区别和联系如下:
- 线程通信是指同一进程内的不同线程之间的信息交换,它们可以共享进程的地址空间和资源,因此通信效率高,但需要注意同步和互斥的问题。¹²
- 进程通信是指不同进程之间的信息交换,它们拥有独立的地址空间和资源,因此通信效率低,但安全性高,需要借助操作系统提供的通信机制,如管道、消息队列、共享内存、信号量、信号、套接字等。¹³
- 线程通信的目的主要是用于线程同步,保证数据的一致性和正确性,而进程通信的目的主要是用于数据交换,实现不同进程之间的协作和功能拓展。²⁴
- 线程通信和进程通信都可以提高程序的并发度和性能,但也增加了编程的复杂度和开销,需要根据具体的应用场景和需求来选择合适的方式。¹²
29.Java锁的底层实现
根据我的搜索结果¹²³,Java锁的底层实现主要依赖于AbstractQueuedSynchronizer(简称AQS)类,它是一个抽象类,提供了一套基于CAS和双向链表的同步器框架。AQS中维护了一个状态值和一个等待队列,状态值用于表示锁的占用情况,等待队列用于存储等待获取锁的线程节点。AQS定义了一些模板方法,如acquire、release、tryAcquire、tryRelease等,具体的锁实现类,如ReentrantLock、ReadWriteLock等,需要继承AQS并实现其中的抽象方法。
当一个线程调用lock方法时,它会首先尝试通过CAS操作修改状态值,如果成功,则表示获取到了锁;如果失败,则表示锁已经被其他线程占用,此时它会将自己封装成一个Node节点,通过自旋和CAS操作将自己加入到等待队列的尾部。然后它会调用LockSupport.park方法将自己阻塞,直到被唤醒或中断。
当一个线程调用unlock方法时,它会将状态值减一,如果减到零,则表示释放了锁;如果不为零,则表示还有重入次数,不需要释放锁。如果释放了锁,则它会检查等待队列中的头节点是否有后继节点,如果有,则调用LockSupport.unpark方法唤醒后继节点的线程,让它再次尝试获取锁。
30.concurrentHashMap如何保证线程安全
根据我的搜索结果¹²³,ConcurrentHashMap是一种基于分段锁和CAS的线程安全的Map实现。它内部维护了一个Node数组作为存储结构,每个Node节点包含了键值对和一个指向下一个节点的引用。Node数组被分为多个段,每个段相当于一个小型的HashMap,有自己的锁和容量。当多个线程同时访问ConcurrentHashMap时,它们只会竞争同一个段上的锁,而不会影响其他段的操作,从而提高了并发性能。
当一个线程要插入或更新一个键值对时,它会先根据键的哈希值定位到对应的段,然后尝试通过CAS操作修改该段的状态值,如果成功,则表示获取到了锁;如果失败,则表示该段已经被其他线程占用,此时它会将自己加入到该段的等待队列中,并调用LockSupport.park方法将自己阻塞,直到被唤醒或中断。
当一个线程要释放一个段的锁时,它会将该段的状态值减一,如果减到零,则表示释放了锁;如果不为零,则表示还有重入次数,不需要释放锁。如果释放了锁,则它会检查该段的等待队列中是否有后继节点,如果有,则调用LockSupport.unpark方法唤醒后继节点的线程,让它再次尝试获取锁。
31.如何判断JVM⾥是否出现死锁
死锁是指两个或两个以上的进程或线程,在执行过程中,由于竞争资源而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。³
判断JVM里是否出现死锁,有以下几种常用的方法:
- 使用JDK自带的命令行工具jps和jstack。jps可以查看当前运行的Java进程的ID,jstack可以打印出指定进程的线程堆栈信息。如果在堆栈信息中发现“Found one Java-level deadlock”或者“waiting to lock”等字样,就说明存在死锁。¹²
- 使用JDK自带的可视化工具jconsole或者jvisualvm。这些工具可以连接到运行中的Java进程,并提供线程监控功能。如果在线程标签页中发现“检测到死锁!”或者“Deadlock detected!”等字样,就说明存在死锁。¹²
- 使用第三方的监控工具,如JProfiler等。这些工具通常提供更强大和更友好的界面和功能,可以帮助分析和定位死锁问题。¹
以上方法都需要在运行时对Java进程进行监控和分析,如果想要预防和避免死锁,还需要注意以下几点:
- 打破死锁产生的四个必要条件之一,即互斥条件、请求与保持条件、不剥夺条件和循环等待条件。³
- 保持一致的加锁顺序,避免出现交叉锁的情况。²
- 使用可中断的锁,如ReentrantLock,可以响应中断请求并释放锁。²
- 使用限时等待的锁,如ReentrantLock的tryLock方法,可以在指定时间内尝试获取锁,如果失败则放弃并释放已占有的锁。²
32.给定a、b两个文件,各存放50亿个url,每个url各占64字节,内存限制是4G,让你找出a、b文件共同的url?
如果是这样的问题还能用布隆过滤器
给定a、b两个文件,各存放50亿个url,每个url各占64字节,内存限制是4G,让你找出a、b文件共同的url?
布隆过滤器,底层也是一个位数组。1表示有,0表示无
最开始,集合里没有元素,所有位都是0:
0, 0, 0, 0, 0, 0, 0, 0, 0, 0
然后,插入“xy”,利用多次hash,把每次hash的结果下标3, 5, 7都插入到相应的地方:
0, 0, 0, 1, 0, 1, 0, 1, 0, 0
h1(“xy”) = 3, h2(“xy”) = 5, h3(“xy”) = 7; h1(“技术圈”) = 5, h2(“技术圈”) = 6, h3(“技术圈”) = 7; h1(“xy的技术圈”) = 3, h2(“xy的技术圈”) = 6, h3(“xy的技术圈”) = 9;
然后,插入“技术圈”,利用多次hash,把每次hash的结果下标5, 6, 7都插入到相应的地方,已经是1的下标不变:
0, 0, 0, 1, 0, 1, 1, 1, 0, 0
这个时候,如果想要判断“xy”是否在集合中,只需要使用同样的3个hash算法,来计算出下标是3, 5, 7,发现这3个下标都为1,那么就认为“xy”这个字符串在集合中。而“xy的技术圈”计算出来的下标是3, 6, 9。发现这三个下标有不是1的地方,比如下标为9的地方是0,那就说明“xy的技术圈”这个字符串还不在集合中。
从原理可以看得出来,布隆过滤器是有可能存在一定的误差的。尤其是当hash函数比较少的时候。布隆过滤器是根据多次hash计算下标后,数组的这些下标是否都为1来判断这个元素是否存在的。所以是存在一定的几率,要检查的元素实际上没有插入,但被其它元素插入影响,导致所有下标都为1。
如果使用布隆过滤器判断一个函数是否存在于一个集合,如果它返回true,则代表可能存在。如果它返回false,则代表一定不存在。
如果空间越大,hash函数越多,结果就越精确,但空间效率和查询效率就会越低。
在标准布隆过滤器中,是不能直接删除元素的,原因包括:
- 共享位:当添加元素到布隆过滤器时,会使用多个哈希函数生成多个索引,然后将这些索引对应的位设为1。因为布隆过滤器使用的哈希函数可能会导致不同的元素产生相同的索引,所以一个位可能代表多个元素。如果直接将某个元素对应的位从1改为0,就可能会错误地表示其他也映射到这个位的元素不在集合中,从而破坏了布隆过滤器“不会有假阴性”的特性。
采用分治的思想
Step1:遍历文件a,对每个url求取hash(url)%1000,然后根据所取得的值将url分别存储到1000个小文件(记为a0,a1,...,a999,每个小文件约300M);
Step2:遍历文件b,采取和a相同的方式将url分别存储到1000个小文件(记为b0,b1,...,b999);
巧妙之处:这样处理后,所有可能相同的url都被保存在对应的小文件(a0vsb0,a1vsb1,...,a999vsb999)中,不对应的小文件不可能有相同的url。然后我们只要求出这个1000对小文件中相同的url即可。
Step3:求每对小文件ai和bi中相同的url时,可以把ai的url存储到hash_set/hash_map中。然后遍历bi的每个url,看其是否在刚才构建的hash_set中,如果是,那么就是共同的url,存到文件里面就可以了。
33.其他方式在内存中创建对象 对象分配优化策略
Java堆是唯一可分配对象的地方吗?
不一定。Java堆是用来存放由new创建的对象和数组的内存区域,但是也有其他方式可以在内存中创建对象,比如:
- 使用反射API,如Class.newInstance()方法
- 使用本地方法,如JNI或JNA
- 使用类加载器,如ClassLoader.defineClass()方法
- 使用序列化和反序列化,如ObjectInputStream.readObject()方法
- 使用动态代理,如Proxy.newProxyInstance()方法
这些方式可能会在堆外内存(off-heap memory)或者元空间(metaspace)中分配对象,这些内存区域不受Java堆的大小限制,也不受Java垃圾回收器的管理。
不一定。根据我从网络上搜索到的信息,我可以告诉你,Java中的对象分配不仅仅取决于new操作符,还取决于JVM的优化策略,比如逃逸分析和TLAB(Thread Local Allocation Buffer)¹。
逃逸分析是一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针。在JVM的即时编译语境下,逃逸分析将判断新建的对象是否逃逸。即时编译判断对象是否逃逸的依据:一种是对象是否被存入堆中(静态字段或者堆中对象的实例字段),另一种就是对象是否被传入未知代码¹。
如果JVM通过逃逸分析,发现一个对象只能从一个线程被访问到,或者只能在方法内部使用,那么这个对象就没有逃逸,就有可能被优化成在栈上分配或者直接消除¹²。这样可以减少堆内存的占用和垃圾回收的压力。
TLAB是一种为每个线程在堆中预先分配一块内存区域的技术,每个线程在创建对象时,首先在自己的TLAB上分配,只有当TLAB用完并重新分配时,才需要同步锁定堆¹。这样可以减少线程间的竞争和内存碎片。
所以,Java中new出来的对象并不一定都在堆中,也有可能在栈中或者TLAB中。
34.synchronized的底层实现
你好,这是Bing。根据搜索结果,synchronized的底层实现主要依赖于Monitor对象,它是同步的基本单元¹。Monitor对象是存放在对象头中的⁵,它包含了锁的状态和持有者信息。synchronized可以用在方法或代码块上,它们都会通过monitorenter和monitorexit两个字节码指令来获取和释放锁³⁴。如果获取锁失败,线程就会被阻塞等待,直到锁被释放为止⁶。
在JVM虚拟机中,对象在内存中的存储布局,可以分为三个区域:
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充(Padding)
数组长度(仅限数组对象): 如果对象是数组类型,对象头中会额外包含一个用于存储数组长度的字段,表示数组的大小。
synchronized使用的锁对象是存储在Java对象头里的标记字段里。
monitorenter和monitorexit是两个字节码指令,它们用于实现synchronized的同步功能¹。它们的执行过程涉及到Monitor对象和对象头的操作²。Monitor对象是一个互斥锁,每个对象都有一个Monitor对象与之关联³。对象头中存储了锁的状态和持有者信息⁴。当线程执行monitorenter指令时,会尝试获取Monitor对象的所有权,如果成功,就将锁的计数器加一;当线程执行monitorexit指令时,会将锁的计数器减一,如果计数器为零,就释放锁。这两个指令都是原子操作,也就是说,在执行它们的过程中,不会被其他线程打断或干扰。
35.java多线程池的各参数意义
java多线程池的各参数意义是指创建java线程池时需要传入的七个参数,它们分别是:
- corePoolSize:线程池核心线程大小,即线程池中会维护的最小线程数量,即使这些线程处于空闲状态,也不会被销毁,除非设置了allowCoreThreadTimeOut¹²。
- maximumPoolSize:线程池最大线程数量,即线程池中能够容纳的最大线程数量,当工作队列满了并且当前线程数小于maximumPoolSize时,会创建新的线程来处理任务¹²。
- keepAliveTime:空闲线程存活时间,即一个线程如果处于空闲状态,并且当前的线程数量大于corePoolSize,那么在指定时间后,这个空闲线程会被销毁¹²。
- unit:空闲线程存活时间单位,即keepAliveTime的计量单位¹²。
- workQueue:工作队列,即新任务被提交后,会先进入到此工作队列中,任务调度时再从队列中取出任务¹²。jdk中提供了四种工作队列:ArrayBlockingQueue、LinkedBlockingQuene、SynchronousQuene和PriorityBlockingQueue¹²。
- threadFactory:线程工厂,即创建一个新线程时使用的工厂,可以用来设定线程名、是否为daemon线程等等¹²。
- handler:拒绝策略,即当工作队列中的任务已到达最大限制,并且线程池中的线程数量也达到最大限制,这时如果有新任务提交进来,该如何处理呢。jdk中提供了四种拒绝策略:CallerRunsPolicy、AbortPolicy、DiscardPolicy和DiscardOldestPolicy¹²。
尽量不要使用 Executors 返回的线程池,而是使用 ThreadPoolExecutor 来进行更精细的控制。
手动配置 ThreadPoolExecutor
当然可以。这四种拒绝策略是指当线程池和工作队列都满了,无法接受新的任务时,采取的不同处理方式¹²。它们分别是:
- CallerRunsPolicy:该策略下,在调用者线程中执行被拒绝任务的run方法,除非线程池已经shutdown,则直接抛弃任务¹²。这种方式可以减少任务丢失,但是会降低调用者线程的性能。
- AbortPolicy默认策略:该策略下,直接丢弃任务,并抛出RejectedExecutionException异常¹²。这种方式可以快速响应拒绝情况,但是可能会导致调用者无法处理异常。
- DiscardPolicy:该策略下,直接丢弃任务,什么都不做¹²。这种方式可以避免抛出异常,但是会导致任务丢失。
- DiscardOldestPolicy:该策略下,抛弃进入队列最早的那个任务,然后尝试把这次拒绝的任务放入队列¹²。这种方式可以尽量保证最新的任务被执行,但是可能会影响已经在队列中等待的任务。
36.规则引擎是什么
规则引擎(Rule Engine)是一种用于表示和执行规则的软件系统。规则是一种用来表达业务逻辑、决策过程或系统行为的表达式,通常以“如果...那么...”(IF...THEN...)的形式出现。规则引擎的核心功能是评估和执行这些规则,以自动化决策过程或提供决策支持。
规则引擎的关键特点包括:
- 规则库:规则库是存储规则的地方。这些规则可以是硬编码的,也可以是动态定义的,允许业务专家或系统管理员根据需要添加、修改或删除规则。
- 规则处理:规则引擎根据输入数据来评估规则。它会检查每条规则的条件部分(IF部分),并确定哪些规则适用于当前的数据或情境。
- 动作执行:一旦规则的条件满足,规则引擎会执行相应的动作(THEN部分)。这可能包括更改数据、触发流程、发出警告等。
- 推理和推导:高级规则引擎支持推理(Inference)和推导(Deduction),可以根据现有的数据和规则集推导出新的结论。
- 业务逻辑和程序代码分离:规则引擎允许将业务逻辑从程序代码中分离出来。这使得非技术人员(如业务分析师)可以更容易地管理和更新业务逻辑,而无需了解底层编程。
规则引擎广泛应用于各种行业和场景,如金融服务(用于信贷审批、风险评估等)、电子商务(用于定价策略、推荐系统等)、医疗保健(用于临床决策支持)等。通过使用规则引擎,组织可以提高决策的一致性、效率和透明度。
EasyRule是轻量级的规则引擎API(专业)。它提供Rule抽象来创建带有条件和动作的规则,以及RulesEngine通过一组规则运行以测试条件和执行动作的API。
规则引擎确实可以通过数据库来实现,尽管这种实现方式通常不如专门的规则引擎软件那么高效和灵活。以下是使用数据库实现规则引擎的基本思路和步骤:
- 设计规则表
在数据库中设计一张或多张表来存储规则。这些表应该包含用于定义规则的各种字段,比如:
- 规则ID:唯一标识每条规则。
- 条件字段:描述规则触发条件的字段。可以是一个或多个字段,用于存储条件的逻辑表达式。
- 动作字段:描述当规则满足时所采取动作的字段。这可以是一个简单的指令或更复杂的逻辑。
- 优先级:确定规则执行顺序的字段。
- 状态:标识规则是否激活的字段。
- 规则评估逻辑
在应用程序中编写代码来评估这些规则。这通常涉及以下步骤:
- 读取规则:从数据库中检索所有激活的规则。
- 条件评估:对于每条规则,评估其条件字段中定义的逻辑表达式是否满足当前的数据环境。
- 执行动作:如果条件满足,则执行相应的动作。
- 规则管理
提供一个界面或工具,允许业务用户或管理员添加、修改和删除规则,而无需直接操作数据库。
- 优化和扩展
- 索引和查询优化:确保数据库查询是高效的,特别是在规则数量很多的情况下。
- 复杂逻辑处理:对于更复杂的规则逻辑,可能需要结合存储过程、触发器等数据库功能。
注意事项
- 性能问题:数据库不是专为规则评估设计的,因此在处理大量规则或复杂逻辑时可能面临性能瓶颈。
- 可维护性和灵活性:相比于专业的规则引擎软件,数据库实现的规则引擎可能在维护和灵活性方面有所不足。
- 安全性和一致性:确保规则的修改和执行不会影响数据库的安全性和数据的一致性。
总的来说,虽然数据库可以用来实现规则引擎,但它更适合于规模较小、逻辑相对简单的场景。对于复杂或大规模的应用,使用专业的规则引擎软件可能是更好的选择。
37.redis可以作为数据库使用吗,不使用mysql
在此期间 Redis 已经发展几年了,很多团队已经验证了它是一个靠谱的数据库。但是它并不通用,使用场景是有限的。
知乎日报和新浪微博的基础数据和统计信息是用 Redis 存储的,这使得请求的平均响应时间能在 10ms 以下。
其他数据仍然需要存放在另外的地方,其实完全用 Redis 也是可行的,主要的考量是内存占用。
就使用经验而言,Redis 的数据结构很丰富,精心设计地话,能满足很多应用场景。至少很多时候比 MySQL 更方便。
更重要的是,它很 cool,开发时有新鲜感。
目前redis做数据库还不太靠谱。它支持的数据类型太少,而且查询功能太弱。redis并不是为了作为数据库使用的,它更多地是一个高速存取器,一般用作缓存和类似场景。
如果你想找一个关系型数据库如mysql的替代者,推荐使用mongodb,支持海量数据,查询功能强大,数据类型支持广泛。目前已有一些团队在后台完全使用mongodb作为数据库。
redis能否做数据库用取决于如下几个条件:
1:数据量,毕竟内存数据库,还是受限于内存的容量,虽然可以redis可以持久化。
2:数据的结构,是否能够将关系型数据结构都转换为key/value的形式。
3:查询的效率,对范围查询等,是否能转换为高效的hash索引查询
redis能不能拿来当数据库,取决于你想要存储什么数据:
如果你打算存储一些临时数据,数据规模不大,不需要太复杂的查询,但是对性能的要求比较高,那可以拿redis当数据库使用。否则别拿来当数据库用。
redis 能不能做数据库,要看你具体的需求了:
- 像上面提到的,redis的持久化有问题,如果使用aof模式,并且fsync always,则性能比mysql 还低,如果你喜欢redis 方便的数据结构而对性能要求不高,或者性能要求很高,但允许一定程度的丢失数据,则可以用redis做为数据库。
- redis 是内存数据库, 内存写满后,数据不会存储到硬盘上(VM 不稳定,diskstore未启用),如果你内存足够大,则可以用redis作为数据库。
redis是一种k/v的内存数据库,适合小数据量的存储以及实时要求高的地方,但是不适合做完整数据库,完整数据库基本上都有一套详细解决方案,基本上没有做了的,比如mysql。
项目里用到的redis是用来做缓存的,设置过期时间,到时就自动清掉。数据库还是用mysql等这种成熟的方案。
如果你非要用一种nosql来做数据库,推荐你用Mongodb。这种KV存储完全不具备数据库所能提供的数据安全性保障。所以还是用来做缓存比较合适。redis做数据库不靠谱,不是所有的数据都是立即回写磁盘的。
最后,我个人觉得哈Redis到底靠不靠谱,最终还是要看使用者对他熟悉的程度,你要 redis 得作者来使用的话, 肯定是非常靠谱的。对吧但要是个newbie 的话, 肯定是不行的。对吧
38.软中断 硬中断
中断(Interrupt)是计算机系统中的一个重要概念,主要用于管理由硬件或软件触发的事件。中断可以被视为硬件或软件的一种信号,通知处理器暂停当前的工作,转而处理更紧急或更重要的任务。中断机制使得计算机可以更有效地响应外部事件,提高了系统的效率和响应速度。
中断大致可以分为以下几种类型:
- 硬件中断:由计算机硬件系统的组件发出。例如,当键盘被按下时,它会向处理器发送一个中断信号,处理器则会停下当前的工作,处理键盘输入。
- 软件中断:由运行在处理器上的软件发出。例如,操作系统中的一个程序可能需要执行某个特定的服务,它会通过生成一个软件中断来请求这个服务。
中断处理通常涉及以下步骤:
- 中断请求:当事件发生时,硬件或软件会向处理器发送一个中断请求。
- 中断识别:处理器会在完成当前指令后识别并确认这个中断请求。
- 中断服务:处理器暂停当前任务,保存必要的状态信息,然后执行一个特定的中断处理程序来响应这个中断。
- 返回原任务:一旦中断处理完成,处理器会恢复之前的任务,继续执行中断之前的操作。
软中断(Software Interrupt)和硬中断(Hardware Interrupt)是中断处理机制中的两个基本概念,它们在触发源和处理方式上有所不同。
- 硬中断(Hardware Interrupt):
- 触发源:硬中断是由硬件设备生成的。例如,当用户按下键盘上的一个键或者鼠标发生移动时,键盘和鼠标硬件会向CPU发送中断信号。
- 处理:硬件中断通常与特定的硬件事件紧密相关,因此它们需要快速响应。当硬件中断发生时,CPU会立刻中断当前的操作,保存当前的状态,并跳转到相应的中断处理程序执行所需的操作。
- 特点:硬中断通常是异步发生的,意味着它们可以在任何时候发生,而与CPU的当前任务无关。
- 软中断(Software Interrupt):
- 触发源:软中断是由软件指令产生的,比如操作系统中的系统调用。软件中断是程序员通过编程主动发起的,通常用于请求操作系统提供服务或者处理某些特殊任务。
- 处理:当软件中断指令执行时,CPU会停止当前程序的执行,转而执行与该软件中断关联的服务程序。
- 特点:软中断是同步发生的,它们只会在执行特定的中断指令时发生。由于软中断是预定的,因此它们的发生和处理通常比硬中断更容易控制和预测。
总结来说,硬中断和软中断的主要区别在于它们的触发源和触发方式:硬中断由外部硬件事件触发,通常是异步的;软中断由程序中的指令触发,是同步的。两者都是现代计算机系统中处理多任务和响应外部事件的重要机制。
39.redis rdb备份用的线程还是进程
Redis 的 RDB 备份是通过 fork 出的子进程来完成的,而不是线程。当 Redis 需要创建一个 RDB 快照时,它会使用操作系统的 fork() 系统调用来创建一个子进程。这个子进程是 Redis 主进程的一个完整副本,包括其内存中的所有数据。然后,子进程将内存中的数据写入到一个临时的 RDB 文件中。在这个过程中,主进程可以继续处理客户端请求,从而减少对服务可用性的影响。
使用子进程而非线程的优点在于,它可以利用操作系统的写时复制(copy-on-write, COW)机制。这意味着在备份过程中,只有当主进程或子进程尝试修改数据时,被修改的内存页面才会被复制。这样可以极大地减少内存的使用量,因为只有修改过的数据需要额外的内存。此外,由于进程间的隔离性比线程间的隔离性更好,使用子进程可以提高系统的稳定性和安全性。
Fork子进程:当执行BGSAVE命令时,Redis会fork一个子进程,这个子进程最初与父进程共享内存。
写时复制:在备份进行期间,如果有新的写请求到来,父进程会处理这些请求。操作系统会将这些新写入的数据放在新的内存页中,而不会影响子进程正在读取的原始内存页。
子进程进行备份:子进程继续基于fork时刻的内存快照进行RDB文件的写入操作,这个过程中子进程不会看到父进程在fork之后的任何修改。
父进程继续处理请求:父进程处理新来的写请求,并将这些修改保存到内存中,但这些修改不会反映在当前的RDB文件中。
当 Redis 调用 fork() 来创建子进程时,操作系统并不会立即复制整个父进程的内存。相反,父进程和子进程会共享同一份物理内存,即它们的虚拟内存地址空间是相同的,直到有一方(通常是父进程)试图修改这部分内存时,操作系统才会触发 写时复制。
写时复制的具体机制如下:
- 共享内存:在 fork() 之后,父进程和子进程最初会共享所有的内存页(pages)。
- 内存写入时触发复制:当父进程处理新的写请求并尝试修改共享的内存页时,操作系统会将这些页标记为只读。一旦父进程试图写入这些页,操作系统会创建这些页的副本,并将副本分配给父进程以供其修改。子进程依然保留原来的只读内存页,确保它看到的内存快照是 fork() 时刻的状态。 这种机制允许子进程看到的是创建 fork() 时刻的内存快照,即父进程在 fork() 时的内存状态。此后,子进程不会受到父进程后续内存修改的影响。
在 Redis 执行 RDB 备份时,子进程并不需要与主进程进行通信来备份数据。备份过程遵循以下步骤:
- fork 子进程: Redis 通过调用 fork() 创建一个子进程。由于写时复制(Copy-On-Write, COW)技术的作用,这个子进程会共享父进程(即 Redis 主进程)的内存空间,而不立即复制整个内存内容。这意味着 fork 操作本身是非常快的,且初始时不会额外消耗大量内存。
- 子进程写入数据: 一旦子进程被创建,它将开始将内存中的数据写入到磁盘上的临时 RDB 文件中。子进程有一个内存数据的快照,这个快照是在 fork 时刻的数据状态。由于子进程是 Redis 主进程的副本,它拥有所有必要的数据和状态信息来独立完成 RDB 文件的写入,无需与主进程交换数据。
- 处理写时复制: 在子进程写入 RDB 文件的同时,主进程会继续处理客户端的请求。如果主进程修改了某些数据,操作系统的写时复制(COW)机制会确保被修改的内存页被复制,这样子进程的内存快照就不会被更改。这意味着即便在备份过程中数据发生变化,备份的数据仍然是一致的,反映的是备份操作开始时的数据状态。
- 替换 RDB 文件: 子进程完成数据写入后,它会将新的 RDB 文件替换旧的 RDB 文件。这通常是通过重命名临时 RDB 文件来完成的,确保这一步操作的原子性。
- 子进程退出: 完成数据备份后,子进程退出。主进程会收到子进程退出的通知,并清理相关资源。
通过上述步骤,Redis 能够在不中断服务的情况下,创建数据的一致性快照。这个过程对正在进行的客户端请求影响很小,既保证了服务的可用性,也确保了数据备份的一致性和可靠性。
40.mysql中的一行数据在b+树中是作为一个叶子节点存储吗,这个叶子节点仅仅存储了一行数据吗
聚簇索引的B+树叶子节点存储的是整个数据行。
辅助索引的B+树叶子节点存储的是索引列的值和对应的主键值。
不,一行数据并不是作为一个单独的叶子节点存储的。在 B+ 树中,叶子节点实际上是数据页(也称为叶子页),每个叶子节点包含多个行记录。InnoDB 的默认数据页大小为 16KB,一个数据页可以存储多条记录,这取决于每条记录的大小。
叶子节点通常存储多行数据。B+ 树的设计旨在优化磁盘 I/O 操作,通过在一个节点中存储多条记录,可以减少访问数据所需的磁盘读取次数,提高查询效率。
进一步解释:
- 聚簇索引(Clustered Index):在 InnoDB 中,主键索引就是聚簇索引。聚簇索引的叶子节点存储了表的实际行数据。也就是说,数据行被存储在 B+ 树的叶子节点中,但每个叶子节点包含了多个数据行。
- 辅助索引(Secondary Index):辅助索引的叶子节点存储的是主键值或行指针,而不是整个行数据。但同样地,一个叶子节点也包含了多个索引记录。
为什么不是一行一个叶子节点?
- 空间效率:如果每个叶子节点只存储一行数据,会导致大量的空间浪费,并增加树的高度,从而降低查询性能。
- I/O 效率:磁盘 I/O 是数据库性能的关键因素之一。通过在一个节点中存储多条记录,可以在一次磁盘读取中获取更多的数据,减少 I/O 操作次数。
#牛客创作赏金赛#写在后面
最近有一些学弟学妹找我帮忙看简历,我也很开心能帮到大家~加上这段时间我比较闲,后面可以更多跟大家交流嘿嘿。我建议还是以后端方向的学弟学妹为主,因为这方面我能给出更多具体建议,希望能为你们提供一些有价值的帮助!
如果需要,欢迎发给我你的学校和名字,我帮你看看,本科(你羊)和研究生(你南)的学弟学妹们优先
实习/秋招面经