栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

【2022Java后端研发面试题】--牛客网

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

【2022Java后端研发面试题】--牛客网

【❤️2022Java后端研发面试题】

❤️ 说一说ConcurrentHashMap的实现原理❤️介绍一下分代回收机制❤️说一说你对Spring AOP的理解?❤️UDP(用户数据报协议)是什么?❤️说一说线程同步的方式❤️说一说NIO的实现原理❤️说一说hash类型的底层数据结构给你一个文本串 T ,一个非空模板串 S ,问 S 在 T 中出现了多少次

❤️ 说一说ConcurrentHashMap的实现原理

【得分点】
数组+链表+红黑树、锁头节点
【参考答案】
在JDK8中,ConcurrentHashMap的底层数据结构与HashMap一样,也是采用“数组+链表+红黑树”的形式。同时,它又采用锁定头节点的方式降低了锁粒度,以较低的性能代价实现了线程安全。底层数据结构的逻辑可以参考HashMap的实现,下面我重点介绍它的线程安全的实现机制。

    初始化数组或头节点时,ConcurrentHashMap并没有加锁,而是CAS的方式进行原子替换(原子操作,基于Unsafe类的原子操作API)。插入数据时会进行加锁处理,但锁定的不是整个数组,而是槽中的头节点。所以,ConcurrentHashMap中锁的粒度是槽,而不是整个数组,并发的性能很好。扩容时会进行加锁处理,锁定的仍然是头节点。并且,支持多个线程同时对数组扩容,提高并发能力。每个线程需先以CAS操作抢任务,争抢一段连续槽位的数据转移权。抢到任务后,该线程会锁定槽内的头节点,然后将链表或树中的数据迁移到新的数组里。查找数据时并不会加锁,所以性能很好。另外,在扩容的过程中,依然可以支持查找操作。如果某个槽还未进行迁移,则直接可以从旧数组里找到数据。如果某个槽已经迁移完毕,但是整个扩容还没结束,则扩容线程会创建一个转发节点存入旧数组,届时查找线程根据转发节点的提示,从新数组中找到目标数据。

【加分回答】
ConcurrentHashMap实现线程安全的难点在于多线程并发扩容,即当一个线程在插入数据时,若发现数组正在扩容,那么它就会立即参与扩容操作,完成扩容后再插入数据到新数组。在扩容的时候,多个线程共同分担数据迁移任务,每个线程负责的迁移数量是 (数组长度 >>> 3) / CPU核心数。
也就是说,为线程分配的迁移任务,是充分考虑了硬件的处理能力的。多个线程依据硬件的处理能力,平均分摊一部分槽的迁移工作。另外,如果计算出来的迁移数量小于16,则强制将其改为16,这是考虑到目前服务器领域主流的CPU运行速度,每次处理的任务过少,对于CPU的算力也是一种浪费。

【延伸阅读】
插入数据的关键代码如下:

final V putVal(K key, V value, boolean onlyIfAbsent) {
    ...
    for (Node[] tab = table;;) {
        Node f; int n, i, fh;
        // 1.若数组还未初始化,则初始化数组。
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        // 2.若该位置头节点为空,则初始化头节点。
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, new Node(hash, key, value, null)))
                break;
        }


数组扩容的关键代码如下:

// 3.若头节点哈希值为MOVED,则表示数组正在扩容,则当前线程也去参与扩容。
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        // 4.若该位置头节点非空,则追加一个新节点。
        else {
            V oldVal = null;
            // 加锁,锁的不是整个数组,而是头节点。
            synchronized (f) {
               if (tabAt(tab, i) == f) {
                    // 头节点为链表节点(参考状态常量)
                    if (fh >= 0) {
                        ...
                    }
                    // 头节点为红黑树节点
                    else if (f instanceof TreeBin) {
                        ...
                    }
                }
            }
            // 当链表中节点数达到8时,则扩容数组或者转为红黑树。
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                ...
            }
        }
    }
    // 计数,若发现数组过小则进行扩容。
    addCount(1L, binCount);
    return null;
}
private final void transfer(Node[] tab, Node[] nextTab) {
    ...
    // 每个CPU处理的节点数(步长)
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        // 最少设定为16
        stride = MIN_TRANSFER_STRIDE;
if (nextTab == null) {
        ...
        // 扩容至2倍
         Node[] nt = (Node[])new Node[n << 1];
        ...
    }
    // 特殊节点,标识该位置的元素已被转移,并指向新的数组。
    ForwardingNode fwd = new ForwardingNode(nextTab);
    // bound为遍历的边界,因为每个线程负责转移一部分节点,不是全部。
    for (int i = 0, bound = 0;;) {
        Node f; int fh;
        // advance表示从transferIndex-1遍历到bound位置的过程中,是否需要继续。
        // 自旋
        while (advance) {
            ...
            // 抢任务(CAS修改TRANSFERINDEX)
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                ...
            }
        }
        // i越界,整个集合遍历完成。
        if (i < 0 || i >= n || i + n >= nextn) {
            // 转移完成
            if (finishing) {
                // 替换旧数组
                nextTable = null;
                table = nextTab;
                // 将sizeCtl设置为扩容后的1.5倍
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
            ...
        }
        // tab[i]迁移完毕
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
// tab[i]正在迁移
        else if ((fh = f.hash) == MOVED)
            advance = true;
        else {
            synchronized (f) {
                ...
            }
        }
    }
}
❤️介绍一下分代回收机制

【得分点】

新生代收集、老年代收集、混合收集、整堆收集

【参考答案】
标准回答
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则。而分代收集理论,建立在如下三个分代假说之上,即弱分代假说、强分代假说、跨代引用假说。依据分代假说理论,垃圾回收可以分为如下几类:

    新生代收集:目标为新生代的垃圾收集。老年代收集:目标为老年代的垃圾收集,目前只有CMS收集器会有这种行为。混合收集:目标为整个新生代及部分老年代的垃圾收集,目前只有G1收集器会有这种行为。整堆收集:目标为整个堆和方法区的垃圾收集。

【加分回答】
HotSpot虚拟机内置了很多垃圾收集器,其中针对新生代的垃圾收集器有Serial、ParNew、Parallel Scavenge,针对老年代的垃圾收集器有CMS、Serial Old、Parallel Old。此外,HotSpot还内置了面向整堆的G1收集器。在上述收集器中,常见的组合方式有:

    Serial + Serial Old,是客户端模式下常用的收集器。ParNew + CMS,是服务端模式下常用的收集器。Parallel Scavenge + Parallel Old,适用于后台运算而不需要太多交互的分析任务。

【延伸阅读】

三个分代假说:

    弱分代假说:绝大多数对象都是朝生夕灭的。

    强分代假说:熬过越多次垃圾收集过程的对象越难以消亡。

    跨代引用假说:跨代引用相对于同代引用来说只占极少数。

    前两条假说奠定了多款常用垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中存储。根据这两条假说,设计者一般至少会把Java堆划分为新生代和老年代两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
    第三条假说是根据前两条假说推理得出的隐含结论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构,这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。

❤️说一说你对Spring AOP的理解?

【得分点】

概念、作用、实现

【参考答案】

标准回答
AOP是一种编程思想,是通过预编译方式和运行期动态代理的方式,在不修改源代码的情况下实现给程序动态统一添加功能的技术。面向对象编程将程序抽象成各个层次的对象,而面向切面编程是将程序抽象成各个切面。所谓切面,相当于应用对象间的横切点,我们可以将其单独抽象为单独的模块。
AOP技术利用一种称为“横切”的技术,剖解开封装对象的内部,将影响多个类的公共行为封装到一个可重用的模块中,并将其命名为切面。所谓的切面,简单来说就是与业务无关,却为业务模块所共同调用的逻辑,将其封装起来便于减少系统的重复代码,降低模块的耦合度,有利用未来的可操作性和可维护性。
利用AOP可以对业务逻辑各个部分进行隔离,从而使业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高开发效率。
AOP可以有多种实现方式,而Spring AOP支持如下两种实现方式。

· JDK动态代理:这是Java提供的动态代理技术,可以在运行时创建接口的代理实例。Spring AOP默认采用这种方式,在接口的代理实例中织入代码。

· CGLib动态代理:采用底层的字节码技术,在运行时创建子类代理的实例。当目标对象不存在接口时,Spring AOP就会采用这种方式,在子类实例中织入代码。

【加分回答】
在应用场景方面,Spring AOP为IoC的使用提供了更多的便利。一方面,应用可以直接使用AOP的功能,设计应用的横切关注点,把跨越应用程序多个模块的功能抽象出来,并通过简单的AOP的使用,灵活地编制到模块中,比如可以通过AOP实现应用程序中的日志功能。另一方面,在Spring内部,例如事务处理之类的一些支持模块也是通过Spring AOP来实现的。

AOP不能增强的类:

    Spring AOP只能对IoC容器中的Bean进行增强,对于不受容器管理的对象不能增强。

    由于CGLib采用动态创建子类的方式生成代理对象,所以不能对final修饰的类进行代理。

【延伸阅读】

AOP的术语:

· 连接点(join point):对应的是具体被拦截的对象,因为 Spring 只能支持方法,所以被拦截的对象往往就是指特定的方法, AOP将通过动态代理技术把它织入对应的流程中。

· 切点(point cut):有时候,我们的切面不单单应用于单个方法,也可能是多个类的不同方法。这时,可以通过正则式和指示器的规则去定义,从而适配连接点。切点就是提供这样一个功能的概念。

· 通知(advice):就是按照约定的流程下的方法,分为前置通知( befor advice )、后置通知( after advice )、环绕通知 (around advice)、事后返回通知(afterRetuming advice)和异常通知 ( after Throwing advice ),它会根据约定织入流程中,需要弄明白它们在流程中的顺序和运行的条件。

· 目标对象(target):即被代理对象。

· 引入(introduction): 是指引入新的类和其方法,增强现有 Bean 的功能。

· 织入(weaving):它是一个通过动态代理技术,为原有服务对象生成代理对象然后将与切点定义匹配的连接点拦截,并按约定将各类通知织入约定流程的过程。

· 切面(aspect):是一个可以定义切点、各类通知和引入的内容,SpringAOP 将通过它的信息来增强 Bean 的功能或者将对应的方法织入流程。

❤️UDP(用户数据报协议)是什么?

udp介绍

❤️说一说线程同步的方式

【得分点】
synchronized、Lock

【参考答案】

【标准回答】
Java主要通过加锁的方式实现线程同步,而锁有两类,分别是synchronized和Lock。
synchronized可以加在三个不同的位置,对应三种不同的使用方式,这三种方式的区别是锁对象不同:

    加在普通方法上,则锁是当前的实例(this)。

    加在静态方法上,则锁是当前类的Class对象。

    加在代码块上,则需要在关键字后面的小括号里,显式指定一个对象作为锁对象。

    不同的锁对象,意味着不同的锁粒度,所以我们不应该无脑地将它加在方法前了事,尽管通常这可以解决问题。而是应该根据要锁定的范围,准确的选择锁对象,从而准确地确定锁的粒度,降低锁带来的性能开销。
    synchronized是比较早期的API,在设计之初没有考虑到超时机制、非阻塞形式,以及多个条件变量。若想通过升级的方式让synchronized支持这些相对复杂的功能,则需要大改它的语法结构,不利于兼容旧代码。因此,JDK的开发团队在1.5引入了Lock,并通过Lock支持了上述的功能。Lock支持的功能包括:支持响应中断、支持超时机制、支持以非阻塞的方式获取锁、支持多个条件变量(阻塞队列)。

【加分回答】
synchronized采用“CAS+Mark Word”实现,为了性能的考虑,并通过锁升级机制降低锁的开销。在并发环境中,synchronized会随着多线程竞争的加剧,按照如下步骤逐步升级:无锁、偏向锁、轻量级锁、重量级锁。
Lock则采用“CAS+volatile”实现,其实现的核心是AQS。AQS是线程同步器,是一个线程同步的基础框架,它基于模板方法模式。在具体的Lock实例中,锁的实现是通过继承AQS来实现的,并且可以根据锁的使用场景,派生出公平锁、不公平锁、读锁、写锁等具体的实现。

【延伸阅读】
想要保证线程安全,不止线程同步一种手段,还包含如下常见办法:

    原子类:可以用原子方式更新一个变量,即在变量未被其他线程修改时才出发更新,否则会引发失败。volatile:volatile是一个轻量级的锁,它通过保证内存可见性的办法来实现线程安全。并发工具:还有很多并发工具类,一样可以保证线程安全,如Semaphore、CountDownLatch、CyclicBarrier。
❤️说一说NIO的实现原理

【得分点】

Buffer、Channel、Selector

【参考答案】

【标准回答】
NIO是基于IO多路复用模型的实现,它包含三个核心组件,分别是Buffer、Channel、Selector。

    NIO是面向缓冲区的,在NIO中所有的数据都是通过缓冲区处理的。Buffer就是缓冲区对象,无论读取还是写入,数据都是先进入Buffer的。Buffer的本质是一个数组,通常它是一个字节数组,也可以是其他类型的数组。Buffer是一个接口,它的实现类有ByteBuffer、CharBuffer、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer。Channel是一个通道,可以通过它读取和写入数据。与流不同的是,流是单向的,而Channel是双向的。数据可以通过Channel读到Buffer里,也可以通过Channel写入到Buffer里。为了支持不同的设备,Channel接口有好几种子类,如FileChannel用于访问磁盘文件、SocketChannel和ServerSocketChannel用于TCP协议的网络通信、DatagramChannel用于UDP协议的网络通信。Selector是多路复用器,可以通过它监听网络IO的状态。它可以不断轮询注册的Channel,如果某Channel上有连接、读取、写入事件发生,则这个Channel就处于就绪状态,就会被Selector轮询出来。所有被轮询出来的Channel集合,我们可以通过SelectionKey获取到,然后进行后续的IO操作。

【加分回答】
Buffer对象包含三个重要的属性,分别是capacity、position、limit。其中,capacity代表Buffer的容量,就是说Buffer中最多只能写入capacity个数据。position代表访问的位置,它的初始值为0,每读取/写入一个数据,它就会向后移动一个位置。limit代表访问限制,就是本次操作最多能读取/写入多少个数据。这三个属性的关系是,position<=limit<=capacity,Buffer通过不断调整position和limit的值,使得自身可以不断复用。

【延伸阅读】
Java NIO根据操作系统的不同,会对Selector做不同的实现。如Linux上的PollSelectorProvider、Windows上的WindowsSelectorProvider、MacOS上的KQueueSelectorProvider等。在使用Selector时,我们不需要指定哪一个实现,JDK会自动选择合适的Selector实现。

❤️说一说hash类型的底层数据结构

【得分点】

ziplist、hashtable

【参考答案】

标准回答

Redis的哈希对象的底层存储可以使用ziplist(压缩列表)和hashtable(字典)。当哈希类型元素个数小于hash-max-ziplist-entries 配置(默认512个),同时所有值都小于hash-max-ziplist-value配置(默认64 字节)时,Redis会使用ziplist作为哈希的内部实现。ziplist使用更加紧凑的结构实现多个元素的连续存储,所以在节省内存方面比hashtable更加优秀。当哈希类型无法满足ziplist的条件时,Redis会使用hashtable作为哈希的内部实现,因为此时ziplist的读写效率会下降,而 hashtable的读写时间复杂度为O(1)。

加分回答

压缩列表是Redis为了节约内存而开发的,它是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者一个整数值。一个压缩列表的重要保存部分包括—zlbytes、zltail、zllen、entryX、zlend,其类型长度以及用途如下表所示:
属性类型长度说明
zlbytesuint32_t4字节压缩列表占用的内存字节数;
zltailuint32_t4字节压缩列表表尾节点距离列表起始地址的偏移量(单位字节);
zllenuint16_t2字节压缩列表包含的节点数量,等于UINT16_MAX时,需遍历列表计算真实数量;
entryX列表节点不固定压缩列表包含的节点,节点的长度由节点所保存的内容决定;
zlenduint8_t1字节压缩列表的结尾标识,是一个固定值0xFF;

字典(dict)又称为散列表,是一种用来存储键值对的数据结构。C语言没有内置这种数据结构,所以Redis构建了自己的字典实现。Redis字典的实现主要涉及三个结构体:字典、哈希表、哈希表节点。其中,每个哈希表节点保存一个键值对,每个哈希表由多个哈希表节点构成,而字典则是对哈希表的进一步封装。

【延伸阅读】
压缩列表结构示意图:

压缩列表节点示意图:

字典:三个结构体:字典、哈希表、哈希表节点之间的关系图:

其中,dict代表字典,dictht代表哈希表,dictEntry代表哈希表节点。可以看出,dictEntry是一个数组,这很好理解,因为一个哈希表里要包含多个哈希表节点。而dict里包含2个dictht,多出的哈希表用于REHASH。当哈希表保存的键值对数量过多或过少时,需要对哈希表的大小进行扩展或收缩操作,在Redis中,扩展和收缩哈希表是通过REHASH实现的,执行REHASH的大致步骤如下:

    为字典的ht[1]哈希表分配内存空间
    如果执行的是扩展操作,则ht[1]的大小为第1个大于等于ht[0].used*2的2n。如果执行的是收缩操作,则ht[1]的大小为第1个大于等于ht[0].used的2n。将存储在ht[0]中的数据迁移到ht[1]上
    重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。将字典的ht[1]哈希表晋升为默认哈希表
    迁移完成后,清空ht[0],再交换ht[0]和ht[1]的值,为下一次REHASH做准备。

当满足以下任何一个条件时,程序会自动开始对哈希表执行扩展操作:

    服务器目前没有执行bgsave或bgrewriteof命令,并且哈希表的负载因子大于等于1;服务器目前正在执行bgsave或bgrewriteof命令,并且哈希表的负载因子大于等于5。

为了避免REHASH对服务器性能造成影响,REHASH操作不是一次性地完成的,而是分多次、渐进式地完成的。渐进式REHASH的详细过程如下:

    为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表;在字典中的索引计数器rehashidx设置为0,表示REHASH操作正式开始;在REHASH期间,每次对字典执行添加、删除、修改、查找操作时,程序除了执行指定的操作外,还会顺带将ht[0]中位于rehashidx上的所有键值对迁移到ht[1]中,再将rehashidx的值加1;随着字典不断被访问,最终在某个时刻,ht[0]上的所有键值对都被迁移到ht[1]上,此时程序将rehashidx属性值设置为-1,标识REHASH操作完成。

REHSH期间,字典同时持有两个哈希表,此时的访问将按照如下原则处理:

    新添加的键值对,一律被保存到ht[1]中;删除、修改、查找等其他操作,会在两个哈希表上进行,即程序先尝试去ht[0]中访问要操作的数据,若不存在则到ht[1]中访问,再对访问到的数据做相应的处理。
给你一个文本串 T ,一个非空模板串 S ,问 S 在 T 中出现了多少次
class Solution {
public:
    
    int kmp(string s, string t) {
        // write code here
        int n = s.size(), m = t.size();
        s = ' ' + s, t = ' ' + t;
        int ne[n + 10];
        memset(ne, 0, sizeof ne);
        for (int i = 2, j = 0; i <= n; i ++) {
            while (j && s[j + 1] != s[i]) j = ne[j];
            if (s[j + 1] == s[i]) j ++;
            ne[i] = j;
        }
        int ans = 0;
        for (int i = 1, j = 0; i <= m; i ++) {
            while (j && s[j + 1] != t[i]) j = ne[j];
            if (s[j + 1] == t[i]) j ++;
            if (j == n) ans ++;
        }
        return ans;
    }
};
转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/749011.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号