【刷题】牛客模拟面试 > 模拟面试报告

https://www.nowcoder.com/interview/ai/index

1-TCP协议的流量控制和拥塞控制 

TCP的流量控制是基于窗口机制实现的:
在建立连接时, 发送方和接收方都会建立一个缓存区,在两端进行通信时,数据包头部会有一个窗口字段,标识了接收端剩余的缓存空间。发送方根据窗口字段的值去判断发送数据的大小,从而避免了缓存溢出。
TCP的拥塞控制算法包含了:
慢启动,拥塞避免,快速重传,快速恢复
慢启动指的是发送数据的量从较低的起始值,如一个报文段慢慢指数增长
拥塞避免是指当拥塞的窗口小于阈值时,又指数增长降低为线性增长
快速重传是指超过三次重复确认即视为传输失败,立即重传
快速恢复是指发生快速重传后,立刻减低窗口阈值,并进行拥塞避免的线性增长算法,避免因为拥塞阻碍了重传

2-说一说ConcurrentHashMap的实现原理

参考回答

    数组+链表+红黑树、锁头节点

标准回答

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

1. 初始化数组或头节点时,ConcurrentHashMap并没有加锁,而是CAS的方式进行原子替换(原子操作,基于Unsafe类的原子操作API)。

2. 插入数据时会进行加锁处理,但锁定的不是整个数组,而是槽中的头节点。所以,ConcurrentHashMap中锁的粒度是槽,而不是整个数组,并发的性能很好。

3. 扩容时会进行加锁处理,锁定的仍然是头节点。并且,支持多个线程同时对数组扩容,提高并发能力。每个线程需先以CAS操作抢任务,争抢一段连续槽位的数据转移权。抢到任务后,该线程会锁定槽内的头节点,然后将链表或树中的数据迁移到新的数组里。

4. 查找数据时并不会加锁,所以性能很好。另外,在扩容的过程中,依然可以支持查找操作。如果某个槽还未进行迁移,则直接可以从旧数组里找到数据。如果某个槽已经迁移完毕,但是整个扩容还没结束,则扩容线程会创建一个转发节点存入旧数组,届时查找线程根据转发节点的提示,从新数组中找到目标数据。

加分回答

    ConcurrentHashMap实现线程安全的难点在于多线程并发扩容,即当一个线程在插入数据时,若发现数组正在扩容,那么它就会立即参与扩容操作,完成扩容后再插入数据到新数组。在扩容的时候,多个线程共同分担数据迁移任务,每个线程负责的迁移数量是 (数组长度 >>> 3) / CPU核心数。

    也就是说,为线程分配的迁移任务,是充分考虑了硬件的处理能力的。多个线程依据硬件的处理能力,平均分摊一部分槽的迁移工作。另外,如果计算出来的迁移数量小于16,则强制将其改为16,这是考虑到目前服务器领域主流的CPU运行速度,每次处理的任务过少,对于CPU的算力也是一种浪费。

3-介绍一下分代回收机制

【得分点】

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

标准回答

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

1. 新生代收集:目标为新生代的垃圾收集。

2. 老年代收集:目标为老年代的垃圾收集,目前只有CMS收集器会有这种行为。

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

4. 整堆收集:目标为整个堆和方法区的垃圾收集。

加分回答

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

1. Serial + Serial Old,是客户端模式下常用的收集器。

2. ParNew + CMS,是服务端模式下常用的收集器。

3. Parallel Scavenge + Parallel Old,适用于后台运算而不需要太多交互的分析任务。

【延伸阅读】

    三个分代假说:

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

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

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

    前两条假说奠定了多款常用垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中存储。根据这两条假说,设计者一般至少会把Java堆划分为新生代和老年代两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

    第三条假说是根据前两条假说推理得出的隐含结论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构,这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。

4-说一说你对Spring IoC的理解

spring ioc是spring两大核心之一,spring为我们提供了一个ioc容器,也就是beanFactory,同时,ioc有个非常强大的功能,叫做di,也就是依赖注入,我们可以通过配置或者xml文件的方式将bean所依赖的对象通过name(名字)或者type(类别)注入进这个beanFactory中,正因为这个依赖注入,实现类与依赖类之间的解耦,如果在一个复杂的系统中,类之间的依赖关系特别复杂,首先,这非常不利于后期代码的维护,ioc就很好的帮助我们解决了这个问题,它帮助我们维护了类与类之间的依赖关系,降低了耦合性,使我们的类不需要强依赖于某个类,而且,在spring容器启动的时候,spring容器会帮助我们自动的创建好所有的bean,这样,我们程序运行的过程中就不需要花费时间去创建这些bean,速度就快了许多。

标准回答

IoC是控制反转的意思,是一种面向对象编程的设计思想。在不采用这种思想的情况下,我们需要自己维护对象与对象之间的依赖关系,很容易造成对象之间的耦合度过高。尤其是在一个大型的项目中,对象与对象之间的关系是十分复杂的,这十分不利于代码的维护。IoC则可以解决这种问题,它可以帮我们维护对象与对象之间的依赖关系,并且降低对象之间的耦合度。

说到IoC就不得不说DI,DI是依赖注入的意思,它是IoC实现的实现方式。由于IoC这个词汇比较抽象而DI比较直观,所以很多时候我们就用DI来代替它,在很多时候我们简单地将IoC和DI划等号,这是一种习惯。实现依赖注入的关键是IoC容器,它的本质就是一个工厂。

加分回答

在以Spring为代表的轻量级Java EE开发风行之前,实际开发中是使用更多的是EJB为代表的开发模式。在EJB开发模式中,开发人员需要编写EJB组件,这种组件需要满足EJB规范才能在EJB容器中运行,从而完成获取事务,生命周期管理等基本服务。

Spring提供的服务和EJB并没有什么区别,只是在具体怎样获取服务的方式上两者的设计有很大不同:Spring IoC提供了一个基本的JavaBean容器,通过IoC模式管理依赖关系,并通过依赖注入和AOP切面增强了为JavaBean服务于事务管理、生命周期管理等基本功能。

而对于EJB,一个简单的EJB组件需要编写远程/本地接口、Home接口和Bean的实体类,而且EJB运行不能脱离EJB容器,查找其他EJB组件也需要通过诸如JNDI的方式,这就造成了对EJB容器和技术规范的依赖。也就是说Spring把EJB组件还原成了POJO对象或者JavaBean对象,以此降低了用用开发对于传统J2EE技术规范的依赖。

在应用开发中开发人员设计组件时往往需要引用和调用其他组件的服务,这种依赖关系如果固化在组件设计中,会造成依赖关系的僵化和维护难度的增加,这个时候使用IoC把资源获取的方向反转,让IoC容器主动管理这些依赖关系,将这些依赖关系注入到组件中,这就会让这些依赖关系的适配和管理更加灵活。

延伸阅读

Spring主要提供了两种类型的容器:BeanFactory和ApplicationContext。

  • BeanFactory:是基础类型的IoC容器,提供完整的IoC服务支持。如果没有特殊指定,默认采用延
    迟初始化策略。只有当客户端对象需要访问容器中的某个受管对象的时候,才对该受管对象进行初始化以及依赖注入操作。所以,相对来说,容器启动初期速度较快,所需要的资源有限。对于资源有限,并且功能要求不是很严格的场景,BeanFactory是比较合适的IoC容器选择。
  • ApplicationContext:它是在BeanFactory的基础上构建的,是相对比较高级的容器实现,除了拥有BeanFactory的所有支持,ApplicationContext还提供了其他高级特性,比如事件发布、国际化信息支持等。ApplicationContext所管理的对象,在该类型容器启动之后,默认全部初始化并绑定完成。所以,相对于BeanFactory来说,ApplicationContext要求更多的系统资源,同时,因为在启动时就完成所有初始化,容
    器启动时间较之BeanFactory也会长一些。在那些系统资源充足,并且要求更多功能的场景中,ApplicationContext类型的容器是比较合适的选择。

在具体的实现中,主要有三种注入方式:

  1. 构造方法注入

    就是被注入对象可以在它的构造方法中声明依赖对象的参数列表,让外部知道它需要哪些依赖对象。然后,IoC Service Provider会检查被注入的对象的构造方法,取得它所需要的依赖对象列表,进而为其注入相应的对象。构造方法注入方式比较直观,对象被构造完成后,即进入就绪状态,可以马上使用。

  2. setter方法注入

    通过setter方法,可以更改相应的对象属性。所以,当前对象只要为其依赖对象所对应的属性添加setter方法,就可以通过setter方法将相应的依赖对象设置到被注入对象中。setter方法注入虽不像构造方法注入那样,让对象构造完成后即可使用,但相对来说更宽松一些,可以在对象构造完成后再注入。

  3. 接口注入

    相对于前两种注入方式来说,接口注入没有那么简单明了。被注入对象如果想要IoC Service Provider为其注入依赖对象,就必须实现某个接口。这个接口提供一个方法,用来为其注入依赖对象。IoC Service Provider最终通过这些接口来了解应该为被注入对象注入什么依赖对象。相对于前两种依赖注入方式,接口注入比较死板和繁琐。

总体来说,构造方法注入和setter方法注入因为其侵入性较低,且易于理解和使用,所以是现在使用最多的注入方式。而接口注入因为侵入性较强,近年来已经不流行了。

5-从上到下按层打印二叉树,同一层结点从左至右输出。每一层输出一行。

给定一个节点数为 n 二叉树,要求从上到下按层打印二叉树的 val 值,同一层结点从左至右输出,每一层输出一行,将输出的结果存放到一个二维数组中返回。
例如:
给定的二叉树是{1,2,3,#,#,4,5}
该二叉树多行打印层序遍历的结果是
[
[1],
[2,3],
[4,5]
]
数据范围:二叉树的节点数 
要求:空间复杂度 ,时间复杂度 
/*
public class TreeNode {
    int val = 0;
    TreeNode left = null;
    TreeNode right = null;
 
    public TreeNode(int val) {
        this.val = val;
 
    }
 
}
*/
public class Solution {
    ArrayList<ArrayList<Integer> > Print(TreeNode pRoot) {
        ArrayList<ArrayList<Integer>> result = new ArrayList<ArrayList<Integer>>();
        if(pRoot==null) return result;
        ArrayList<TreeNode> queue = new ArrayList<TreeNode>();
        ArrayList<Integer> temp = new ArrayList<Integer>();
        ArrayList<Integer> start = new ArrayList<Integer>();
        start.add(pRoot.val);
        result.add(start);
        int low = 0;
        int high = 1;
        int end = high;    
        queue.add(pRoot);
        while(low<high){            
            TreeNode t = queue.get(low);
            if(t.left!=null){
                queue.add(t.left);
                temp.add(t.left.val);
                high++;
            }
            if(t.right!=null){
                queue.add(t.right);
                temp.add(t.right.val);
                high++;
            }
            low++;
            if(low==end){
                end = high;
                if(temp.size()!=0)
                    result.add(temp);
                temp = new ArrayList<Integer>();
            }
        }
        return result;
    }
     
}

 

round-2

1-谈谈InnoDB引擎中的锁

两种思想:乐观锁 和悲观锁两种
乐观锁:假设在并发操作中不发生冲突,因此在访问、处理数据过程中不加锁,只在更新数据时再根据版本号或时间戳判断是否有冲突,有则处理,无则提交事务
悲观锁:假设在并发操作中大概率发生冲突,因此访问、处理数据前就加排它锁,在整个数据处理过程中锁定数据,事务提交或回滚后才释放锁
两种上锁方式:排它锁和共享锁
共享锁:允许获取共享锁的事务获取数据(多个事务可以同时获得共享锁)
排它锁:允许获取排他锁的事务获取更改数据(只允许一个事务获得排他锁)
innodb支持行级锁,粒度最小,冲突发生的概率极低,支持并发操作的程度最高

悲观锁-Pessimistic lock
全局锁:flush table with read lock;使用全局锁会锁住整个数据库,使其处于只读状态;
表锁: lock table和 意向锁(Intention Locks) MataData Lock ,意向锁不用显示调用;
行锁(Record-lock)
间隙锁( gap locks)
临键锁( next-key lock) ,由行锁和间隙锁组成;
乐观锁- Optimistic Lock
自旋cas机制;可通过version版本和时间戳来判断;

Mysql的行锁和表锁区别:
表级锁: 锁住整张表。 开销小,加锁快;不会出现死锁;锁粒度最大,发生锁冲突的概率最高,并发度低;
行级锁: 锁住一行数据。开销大,加锁慢;容易出现死锁;锁粒度最小,发生锁冲突的概率最低,并发度高;
行锁的实现:
如果有索引,那么会先扫描索引文件,查询到主键id,通过索引锁定行记录实现行锁;
如果没有索引,就会锁住全表的数据;
————————————————

2-创建线程有哪几种方式

一、继承Thread类创建线程类
(1)定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。
(2)创建Thread子类的实例,即创建了线程对象。
(3)调用线程对象的start()方法来启动该线程。
二、通过Runnable接口创建线程类
(1)定义runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。
(2)创建 Runnable实现类的实例,并依此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。
(3)调用线程对象的start()方法来启动该线程。
三、通过Callable和Future创建线程
(1)创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且有返回值。
(2)创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
(3)使用FutureTask对象作为Thread对象的target创建并启动新线程。
(4)调用FutureTask对象的get()方法来获得子线程执行结束后的返回值

3-介绍一下分代回收机制

全称:垃圾分代回收机制。JVM内存(运行时数据区)划分了5个区域,分别是:

1.栈:存放一个个对应方法的栈帧

2.堆:储存的是容器和对象

3.程序计数器(寄存器):当前线程所执行的字节码的行号指示器

4.本地方法栈:为虚拟机使用到的本地方法服务

5.方法区:储存类信息,常量,静态常量以及编译器编译后的代码等

分代是指堆内存又分新生代(Young Generation)和老年代(Old Generation),新生代又分为伊甸区(eden)和幸存区(surivivor),幸存区由from space与to space两块相等的内存区域组成。eden:from:to = 8:1:1。

发生在新生代的回收:Minor GC

发生在老年代的回收:Major GC

对象创建后会先存放在新生代伊甸区,经过一次回收后移入新生代幸存区;在经过多次(一般为15次,可调整)回收后,移入老年代;如果老年代内存不足触发Major回收后该依然无法放开该对象(对象的保存),则会报OutOMemryError。

————————————————

4-说一说你对布隆过滤器的理解

布隆过滤器可以用很小的代价来估算出数据是否真实存在,相比于传统的 List、Set、Map 等数据结构,它更高效、占用空间更少,但是缺点是其返回的结果是概率性的,而不是确切的。

布隆过滤器的数据结构是一个大型的位数组,而如果我们要映射一个值到布隆过滤器中,我们还需要使用多个不同的哈希函数来生成多个哈希值,并对每个生成的哈希值指向的位置设置为1。查询key是否存在时,每个哈希函数都利用这个key计算出一个哈希值,再根据哈希值计算一个位置。然后对比这些哈希函数在位数组中对应位置的数值,如果这几个位置中,有一个的位置值为0,则说明过滤器中不存在这个key。如果这几个位置中,所有位置的值都是1,就说明这个布隆过滤器中,极有可能存在这个key。之所以不是百分之百确定,是因为也可能是其他的key运算导致该位置为1。

加分回答

过小的布隆过滤器bit位很快就会都被置为1,那么查询任何值都会返回“可能存在”,这就起不到过滤的目的了。这说明布隆过滤器的长度会直接影响误报率,布隆过滤器越长其误报率越小。

另外,哈希函数的个数也需要权衡,个数越多则布隆过滤器 bit 位置位为 1 的速度越快,且布隆过滤器的效率越低。但是如果太少的话,误报率会变高。

布隆过滤器的典型应用有:

  • 数据库防止穿库。 Google Bigtable,HBase 和 Cassandra 以及 Postgresql 使用BloomFilter来减少不存在的行或列的磁盘查找。避免代价高昂的磁盘查找会大大提高数据库查询操作的性能。
  • 业务场景中判断用户是否阅读过某视频或文章,比如抖音或头条,当然会导致一定的误判,但不会让用户看到重复的内容。
  • 缓存宕机、缓存击穿场景,一般判断用户是否在缓存中,如果在则直接返回结果,不在则查询db,如果来一波冷数据,会导致缓存大量击穿,造成雪崩效应,这时候可以用布隆过滤器当缓存的索引,只有在布隆过滤器中,才去查询缓存,如果没查询到,则穿透到db。如果不在布隆器中,则直接返回。
  • WEB拦截器,如果相同请求则拦截,防止重复被攻击。用户第一次请求,将请求参数放入布隆过滤器中,当第二次请求时,先判断请求参数是否被布隆过滤器命中。可以提高缓存命中率。Squid 网页代理缓存服务器在 cache digests 中就使用了布隆过滤器。Google Chrome浏览器使用了布隆过滤器加速安全浏览服务。
  • Venti 文档存储系统也采用布隆过滤器来检测先前存储的数据。
  • SPIN 模型检测器也使用布隆过滤器在大规模验证问题时跟踪可达状态空间。

Redis 因其支持 setbit 和 getbit 操作,且纯内存性能高等特点,因此天然就可以作为布隆过滤器来使用。但是布隆过滤器的不当使用极易产生大 Value这会增加 Redis 阻塞风险,因此实际使用中建议对体积庞大的布隆过滤器进行拆分。拆分的形式方法多种多样,但是本质是不要将 Hash(Key) 之后的请求分散在多个节点的多个小 bitmap 上,而是应该拆分成多个小 bitmap 之后,对一个 Key 的所有哈希函数都落在这一个小 bitmap 上。

————————————————

5-有一种将字母编码成数字的方式:'a'->1, 'b->2', ... , 'z->26'。

现在给一串数字,返回有多少种可能的译码结果
示例1 输入"12" 输出2
import java.util.*;
 
public class Solution {
    /**
     * 解码
     * @param nums string字符串 数字串
     * @return int整型
     */
    public int solve (String nums) {
        // write code here
        int len = nums.length();
        int[] possible = new int[len+1];
        possible[0] = 1;
        for (int i = 0; i < len; i++) {
            // 查表求 possible[i+1]
            // 1 个字符场景的可能
            int pos = 0;
            if (nums.charAt(i) != '0') {
                pos += possible[i];
            }
            // 2 个字符场景的可能
            if (i != 0 && nums.charAt(i-1) != '0' && Integer.parseInt(nums.substring(i-1, i+1)) <= 26) {
                pos += possible[i-1];
            }
            possible[i+1] = pos;
        }
        return possible[len];
    }
}

 

posted @ 2022-05-24 21:19  hanease  阅读(134)  评论(0编辑  收藏  举报