复盘面经(5):快手java实习面经 @byebyeneu
这是我目前写的最长的一篇面经,写了两万字。感谢@byebyeneu的面经,让我又学了很多。
作者:byebyeneu
链接:https://www.nowcoder.com/feed/main/detail/53f1385cd4da4dde84028613841ed23f?sourceSSR=users
来源:牛客网
1、自我介绍
2、实习拷打
相关流程梳理总结一下实习所做的技术难点的地方
3、hashmap的数据结构了解吗
老生常谈的问题了,再写一遍,复习一下。
先介绍一下什么是HashMap:HashMap是Java中的一个集合,是用来存键值对的,他保存的都是键值对的Key-value元素。它可以根据key来快速查找对应的value,时间复杂度最快是O(1)。它的key在集合中是唯一的,value是不唯一的。
它是怎么做到的呢?它的底层数据结构是由数组+链表+红黑树组成的,在jdk8之前,是只有数组+链表。jdk8之后就多了红黑树。我们来说为啥要这样设计:哈希表就是为了能够快速的查询元素,最开始它只有数组来存元素,怎么存的呢?它有一个哈希函数将键值对的Key经过哈希得到了一个哈希值,在通过哈希值来求余数组大小,这样就得到了它在数组中的下标位置,然后它就会将整个键值对放在这个下标位置上。其他元素也是这样,通过这种方式来找到自己在数组中的位置。元素多了,会出现一个问题,多个不同的key经过哈希函数后得到的下标位置是相同的,但数组我们知道只能存储一个元素,所以就导致了哈希冲突,有多个key要放到同一个下标位置上。怎么解决呢?有多种方式:比如说拉链法,线性探测法、在哈希法。拉链法就是将同一个下标位置上的元素都放在一个链表上。线性探测法就是发现当前位置有元素了,继续向前面的位置一个个查询,只要有空的就放进去。再哈希法是指使用另外一个哈希函数得到另外一个下标位置,空的话就放进去。拉链法的元素还是在原来的位置上,而线性探测法和再哈希法都重新选新的位置。hashmap选用的就是拉链法,这样做的好处是可以快速查询到元素,时间复杂度是O(1),即使哈希冲突了,也可以根据链表去查询,其他两种方式都需要重新寻找位置,时间复杂度可能达到O(n),所以使用拉链法解决哈希冲突适用于hashMap。所以jdk8之前使用的都是数组+链表的方式来存储。但是只使用链表会存在一些问题,如果某一个位置上的链表很长,这样就会导致查询时间变成了O(n),对于查询和增加删除都是很不利的。所以为了解决链表过长导致查询等操作时间变长的问题,jdk8提出了红黑树。红黑树是一种自平衡的二叉查找树。它的特点在于树中的每个节点都有一个哈希值,每个节点的左子树的所有节点都小于当前节点的值,右子树所有节点的值都大于当前节点的哈希值,这样查询的时候会根据key的值来查询,时间复杂度是O(logn),并且它是自平衡的,它可以保证根节点到叶子结点的长度最长的不高于最短的二倍,整个树就维持了一个相对平衡的状态,不会有某一个分支特别长,某个分支特别短,他的查询效率始终维持在logn左右。有了红黑树就能解决链表过长的问题。
4、hashmap什么时候链表变红黑树,什么时候红黑树变链表
当链表个数大于等于8,并且数组的长度大于等于64的时候链表会转换成红黑树。
当红黑树节点个数小于等于6,红黑树转换成链表。
5、为什么阈值不一样
如果阈值都是8的话,假如现在有一个链表长度是7,现在新增了一个元素长度变成8,转成红黑树了,然后又减少一个元素,又退化成链表了,这样的情况就会导致转换太频繁,影响性能。
6、 hashmap put一个元素的过程
put方法首先会去检查要put的key是否已经存在了,首先判断当前hashmap是否初始化,如果没有初始化就先初始化,然后根据这个key通过哈希函数得到他的哈希值,它&(n-1)得到了它在数组中的下标位置,然后去检查这个下标位置是否有元素,如果没有元素直接创建一个键值对放入这个位置。如果有元素了,那么就会去查询和这个节点的key是不是要put的key,如果是那么直接覆盖这个键值对的value为put方法里的value,如果不是,那么会去判断这个节点是红黑树节点还是链表节点。如果是链表节点,那么会去通过指针遍历这个链表,查询key是否存在,如果存在,就更新value,如果没找到这个key,就新增一个节点插入链表的尾部,如果新增了一个节点,那么就去判断当前链表个树是否大于等于8,数组长度是否大于等于64,如果达到了就将链表转换成红黑树,如果长度达到,但是数组长度没达到,就只扩容。如果头节点是红黑树节点,那么就根据put的key去跟头节点的哈希值去比较,如果小于就去查询左子树,如果大于就去查询右子树,如果等于,那么就会判断当前节点的key是否是put的key,如果是就更新。如果不是继续查询,查询过程中都是按照这个方式。如果最后一直没有找到这个key,会插入一个新节点到红黑树中,红黑树也会通过旋转操作来保持平衡。最后如果插入了一个新节点,就会判断是否需要扩容,扩容条件是当前节点的总个数/数组长度是否达到了负载因子0.75。如果达到了就会扩容,扩容就会将数组大小翻倍,然后将原数组的元素都迁移到新数组中。最后使用新数组执行后续操作。
7、怎么根据hashcode得到数组下标的
Hashcode & (n-1)。也就是hashcode % n,为啥不写%而是写&(n-1)呢,因为数组的大小一直都是2的幂次方,它的二进制都是1000000,n-1就是111111111,hashcode直接&就可以得到它在数组中的位置,这样执行的操作速度比%更快。
8、put只能put不存在的元素吗,put存在的元素会发生什么
不是,put存在的元素会只更新它的value,而不是创建一个新节点存入。
9、hashmap怎么扩容的
创建一个新数组,这个数组的大小是原来的二倍。然后将原数组的所有元素都迁移到新数组上来。
10、一个槽是3,长度是16,扩容后,原本3的槽的元素可能在哪些槽里面
长度为16的数组,放在下标为3的 key的哈希值都是 3 + 16 * k。
如果现在扩容后数组长度为32了,那么它们就会放在。 3 或者 19的位置上。
11、 为什么数组长度是2的n次方
如果是2的n次方,在计算数组是否扩容,以及计算key的下标位置或者个数太少导致缩容的时候,等操作,都可以直接通过位运算来判断,这样性能更好,而且不会出错,如果是奇数的时候还需要考虑各种情况。2的n次方也会将元素分布的更加均匀,每个位置元素的个数都差不多。扩容的时候元素要么还在原位置,要么在原位置加上n的位置。可以直接通过判断哈希值的某一位是0还是1就知道新位置,而不用重新计算所有元素的哈希值和下标,提高扩容效率。
12、 hashmap线程安全吗,想要用线程安全的hashmap用什么东西
线程安全的哈希表有 HashTable、ConcurrentHashMap、Collections.synchronizedMap。
HashTable 是线程安全的,因为它的所有方法都加了synchronized,保证同一时间只能有一个线程去操作HashTable对象,从而实现线程安全。
ConcurrentHashMap:也是线程安全,它的线程安全是使用了CAS和synchronized来实现并发安全,它将整个哈希表分成多个部分,每个部分都有一把锁,而不是整个哈希表使用一把锁,这样可以提高性能。
Collections.synchronizedMap:这个方法可以将一个HashMap转换成一个线程安全的Map,将所有的方法进行包装,它将所有的方法都加上synchronized关键之来保证线程安全。
13、 concurrenthashmap的底层结构是怎么样的
底层就是数组+链表+红黑树,它将整个哈希表分成了多个部分,每个部分单独加锁。这个记不清了。
在jdk8之前,将哈希表分成了一个一个段,每一个段是一个数组,每个数组里面存的是链表。
在jdk8之后,底层是一个数组+链表+红黑树。
他俩实现线程安全的方式也不同。jdk7是将每一个段加一把锁,这个锁是ReentrantLock锁。每次查询修改数据的时候,首先要拿到这把锁才能操作。
jdk8 是数组中每一个下标的元素都有一把锁,锁的是当前链表或红黑二叉树的首节点,用CAS或者synchronized实现线程安全的。所以jdk8的锁粒度更小,并发度也更大,性能也更好。
14、 1.8后concurrenthashmap的锁粒度是怎么样的
它相对于1.7的锁一个段,它的锁粒度更小,锁的是数组中的每一个下标索引上的链表或者红黑树的头节点。相对于1.7而且,并发度更大,性能也更好了。
15、为什么1.8前后锁粒度会发生这样的变化
我觉得是因为1.7的锁力度太大了,大部分情况下的性能不好,1.8有了更小的锁力度。
16、volatile讲讲他底层
violate 是java里面的一个关键字,用它来修饰变量。他可以解决多线程环境下的变量的可见性和有序性。
可见性:可见性是指一个线程对共享变量的修改能够及时被其他线程看到的特性。为啥要保证变量的可见性呢,我们知道在系统实际执行过程中,变量的数据都是在内存当中的,但每个线程都有自己独立的工作内存,它使用数据的时候,会复制一份到自己的工作内存当中,如果有其他线程修改了某个变量的数据,当前线程是不知道的,这就导致了数据不一致的问题。volatile可以解决这个问题,当一个线程在对加了volatile关键字的变量执行写操作的时候,JVM会将在自己工作内存修改的值刷新到主内存中,同时会使其他线程工作内存中该变量的副本失效。当一个线程在对加了volatile关键值得变量执行读操作时,JVM会强制该线程从主内存中读取变量的值,而不是使用它的工作内存的副本。 那它底层是怎么实现的呢?它使用了内存屏障,内存屏障是一种CPU指令,用于控制内存操作的顺序和可进行
- 对volatile变量的写指令后会加入写屏障,保证写屏障之前的写操作,都能同步到主存中。
- 对vloatile变量的读指令前会加入读屏障,保证读屏障之后的读操作,都能读到主存的数据。
我想在说一下线程的工作内存都有哪些:
1、寄存器:CPU内存储指令、数据、地址等信息,线程执行过程中,会将一些数据放入寄存器中。原因是寄存器的读写速度非常快,这样可以提高执行的效率。
2、CPU缓存:由于CPU的的运算速度远高于内存的读写速度,为了平衡两者之间速度的差异,引入了CPU高速缓存,执行的时候会讲一些数据放入CPU缓存中,这样就可以直接从CPU缓存中获取数据,提高执行效率。
3、线程栈:每个线程都有自己独立的线程栈,用具存储局部变量、方法调用的上下文信息等。当线程执行一个方法时,方法的局部变量会被分配到线程栈的栈帧中。
为了提高执行速度,才有的工作内存,有了工作内存,那数据正确性就不一定有保障,有了volatile就可以解决这些问题了。
有序性:为啥要保证变量的有序性呢?
编译器和CPU为了提高执行效率,会对指令进行重排序,比如编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。指令重排序会导致什么问题呢,比如比较经典的单例模式中的双重检查锁问题,先回顾一下这个问题,它首先判断这个这个对象是否为null,如果为null就给这个单例类加锁,如果加锁成功就会再次判断对象是否为null,如果为null才会去创建一个对象。如果一开始发现不为null,直接返回这个对象。这个操作保证对象只会被创建一次。在创建对象的时候,有三个步骤,首先会给这个创建的对象分配内存空间,然后初始化这个单例对象,然后再将这个属性引用指向刚分配好的内存空间。但由于指令重排序,第二步和第三步可能会被重排序,也就是instance引用指向内存空间,在进行对象的初始化。在多线程的环境下,当一个线程A执行了重排序后,先完成了第三步,此时instance不再为null,而另一个线程B刚好进入getInstance方法,进行第一次检查,发现不为null,直接返回instance对象,但此时这个对象还没有初始化,所以线程B获取到的是一个未初始化的对象,就会导致程序出现异常。所以由这个问题我们知道了要保证变量的有序性,它是怎么保证的呢?
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后。
- 读屏障会确保指令重排序时,不会讲读屏障之后的代码排在读屏障之前。
17、 violate讲讲他底层
同上
18、violate除了保证可见性他还能保证什么呢
同上
19、syn讲讲他底层
syn是java中的一个关键字,是用来解决多线程环境下的线程同步问题。它的底层是由对象头和监视器来实现线程同步的。首先对象头,Java里面的每一个对象都有一个对象头,对象头中包含了一些关于对象的信息,其中有一个用于实现锁机制的Mark Word信息,这个Mark Word的不同位表示不同的锁状态,比如无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。监视器是java中实现同步的基础,它是一个同步工具,每个Java对象都可以关联一个监视器,当一个线程尝试获取对象的时候,如果监视器没有被其他线程持有,则该线程可以获取监视器并进入同步代码块;如果监视器已经被其他线程持有,则该线程会被阻塞,进入监视器的等待队列中。
在jdk的早期版本,syn属于重量级锁,执行效率低下,因为监视器是依赖底层的操作系统的Mutex Lock 实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。而来就引入了锁升级的优化操作,一开始syn是无锁的状态,此时没有线程尝试过获取这把锁,当第一次有一个线程来获取这把锁的时候,就会升级为偏向锁,偏向锁会在对象头的Mark Word中标记该线程的ID,这样如果该线程再次想要获取这把锁时,发现锁是偏向锁,并且线程ID是自己,那么就直接进入同步代码块,不需要任何同步操作,提高性能,这样做的目的是因为大多数锁可能只有一个线程多次获得,这样可以减少不必要的同步操作。如果后面有了一个新线程想要获取这把锁,发现这是偏向锁并且锁的线程ID不是自己,那么就会将这把锁升级为轻量级锁,为啥要升级成轻量级锁呢,因为偏向锁只能给一个线程使用,其他线程如果要获取锁既然锁被占有了,它也没有等待的机制,所以需要升级成更高级别的锁来保证线程安全。当升级为轻量级锁的时候,线程会在自己的栈帧中创建一个锁记录,并将对象头的Mark Word复制到锁记录中,然后线程尝试使用CAS操作将对象头Mark Word只想锁记录,如果CAS成功,则表示该线程获取到了轻量级锁,如果CAS操作失败,则表示有其他线程也在竞争该锁。轻量级锁可以实现锁被占有的时候通过自旋的方式来多次尝试获取锁,并且CAS操作避免了像重量级锁中的操作系统需要切换用户态所带来的性能开销问题,所以比较好实现了锁机制。但是线程一直使用CAS操作尝试获取锁会占用CPU等资源,如果竞争锁的线程过多,就会导致性能变差,所以如果当CAS操作失败多次的时候,锁就会从轻量级锁升级为重量级锁,使用操作系统的互斥量来实现,竞赛的锁的线程都会被阻塞,进入操作系统的等待队列中,只有当持有锁的线程执行完释放锁后,操作系统才会唤醒等待队列的线程重新去竞争锁。由于操作系统中内核态和用户态的频繁切换,性能开销较大,但是可以保证在高并发的情况下保证线程安全。
除了锁升级,JVM对syn还有其他的优化操作,比如锁消除:如果jvm检测到某个锁不存在竞争的情况,就会把这把锁消除,从而提高程序性能。还有锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。自适应自旋锁:它是一种优化锁竞争的策略。当一个线程尝试获取一个已经被其他线程持有的锁时,不会马上阻塞,而是进行一段时间的自旋(循环尝试获取锁)。并且这个自旋的时间不再是固定的,而是根据以往的自旋情况动态调整。如果之前自旋成功获取到锁的概率大,那么这次就允许较长时间的自旋;反之则缩短自旋时间,甚至直接让线程阻塞。
20、synjdk1.8做了优化,不是直接就重量锁,讲讲这个过程
同上
21、syn公平还是非公平
同上
22、reentrantlock讲讲他底层
首先我会介绍一下什么是reentrantlock:
Reentrantlock 是一个Java中的一个锁实现类,它实现了Lock接口,是一种可重入的互斥锁。可重入是说同一个线程能够多次获取同一把锁,释放的时候只有当全部锁释放了才能真正释放。它内部有一个Sync类,这个类继承了AQS抽象类,它的底层主要是靠AQS抽象类来实现的。AQS是一个用来构建锁和其他同步组建的一个基础框架,它的核心思想如果被请求的共享资源,那么就将当前请求资源的线程设置为共享资源的有效线程,并且将共享资源设置为锁定状态,如果共享资源被占用,那么就将当前请求资源的线程阻塞,等待资源空闲时被唤醒,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS使用CLH队列实现的。当请求不到资源的线程都会加入到这个队列中。除此之外,AQS还有一个state状态字段,来表示当前资源被重入的次数,如果是0,表示资源空间,如果是1表示资源被占用,如果是2表示资源被重入。reentrantlock是怎么实现加锁解锁的呢?它有一个lock方法,这个方法会调用sync的lock方法,这个方法首先通过CAS操作来加锁,如果state为0就将它设置为1,如果操作成功就将这个当前线程设置为锁的占有线程。如果失败,表示锁已经被占用了,那么就会执行acquire方法,这个方法首先会执行tryAcquire方法,它首先会判断state是否为0,如果为0就去尝试CAS操作加锁,如果成功就会设置当前线程为锁的有效线程,如果失败就会去判断当前锁的有效线程是不是当前线程,如果是,就会执行锁重入,将state+1。如果这个方法执行完后还是加锁失败,那么就会将当前这个线程加入到阻塞队列中。然后执行acquireQueued方法不断尝试去获取锁,acquireQueued方法让线程在队列中等待获取锁,这个方法会不断的尝试获取锁,直到成功或者在等待过程中被中断,它有一个死循环,先判断当前节点的前驱节点是否是头节点,是的话就调用tryAcquire方法去尝试获取锁,如果获取锁成功,设置头节点为当前节点,释放头节点,返回打断状态,如果获取锁失败,就判断是否需要阻塞当前线程,如果可以阻塞,就调用Park方法阻塞当前线程,此时整个线程就被阻塞,它有可能被获取锁的线程释放锁的时候唤醒,或者被其他线程中断,如果它被唤醒或者被中断了,就可以继续执行了,返回中断状态并清除中断标记,然后就会继续进入这个for循环,继续尝试获取锁,失败后继续阻塞,直到成功获取锁,获取锁成功后会返回中断标记,如果在线程阻塞的过程中被中断了,那么就返回true,如果失败就返回false,现在回到了acquire方法,如果acquiredQueued方法返回了true,那么就会执行自我中断方法,来标记自己被中断过,由工作线程来判断这个标记然后决定是否还需要继续执行。为什么要消除中断标记在恢复呢,可以不消除吗,必须要消除,因为获取锁中的过程中还会执行park方法, park方法执行的时候首先会判断是否有中断标记,如果有就会直接返回,那么当前线程就没办法阻塞,所以需要消除中断标记。
它释放锁的过程:unlock方法会执行release方法,release方法首先会调用tryRelease方法,这个方法尝试释放锁,它先将state减去1,如果state等于0了,说明当前线程已经释放了锁,就将获取锁的线程设置为null,然后返回true,表示释放锁失败,如果state不等于0,说明当前线程还没有完全释放锁,返回false。此时回到了release方法,如果tryRelease方法返回的true,就会去唤醒阻塞队列中的第一个线程,会执行unpark方法唤醒,此时唤醒的线程就会继续执行acquireQueued方法,继续尝试获取锁,获取锁成功,等他执行完后也会释放锁唤醒下一个等待线程,这样执行下来,每个线程都可以得到执行。
23、reentlock公平还是非公平
使用new创建ReetrantLock创建的是不公平的NonFairSync锁,它不公平体现在哪呢?公平指的是先来后到,不公平就是不先来后到,后到的可能先执行。非公平锁不公平的具体体现在:刚才唤醒的队列中的第一个线程的时候,它继续执行尝试获取锁的操作,此时来了一个新的获取锁的线程,它去执行lock方法,使用CAS操作将state设置成1,它先获取到了锁,这样就导致先到的锁被后来的锁给强占了。所以是不公平的。 也可以使用FairSync公平锁,它的lock的时候删掉了直接CAS设置state状态,直接执行acquire方法,然后它的tryAcquire方法中,每次获取锁之前需要先判断队列中是否有元素,如果没有元素才可以尝试获取锁,如果队列中有元素就不能尝试获取锁,这样就实现了先来后到,也就是公平锁了。
24、讲讲线程池中提交一个任务的过程
首先会判断线程池是否还在正常执行,如果没有执行了,就拒绝执行任务。如果还在正常执行,就先判断核心线程数是否已满,如果没有满那么可以创新一个核心线程来执行提交的任务。如果核心线程数满了,那么就可以将这个任务加入到工作队列当中,如果这个队列没满,就加入到工作队列当中,如果满了,就看线程数是否等于最大线程数,如果不等于,那么可以创建的救急线程来执行这个任务,如果等于最大线程数了,那么就会执行拒绝策略,由拒绝策略来处理这个任务。
25、线程池本质上什么模式的典型实现
工厂模式吗?
线程池本质上是生产者 - 消费者模式的典型实现。下面从生产者 - 消费者模式的概念、线程池如何体现该模式以及这种模式为线程池带来的优势等方面进行详细阐述。
生产者 - 消费者模式的概念
生产者 - 消费者模式是一种并发编程中常用的设计模式,它包含两类角色:
- 生产者:负责生产数据或任务,并将其放入一个共享的缓冲区(队列)中。
- 消费者:从共享缓冲区中取出数据或任务进行处理。
通过这种模式,生产者和消费者可以独立地进行工作,它们之间通过共享缓冲区进行解耦,提高了系统的并发性能和可维护性。
线程池如何体现生产者 - 消费者模式
生产者角色
在线程池的场景中,提交任务的代码或线程充当生产者的角色。当我们调用线程池的 execute
或 submit
方法来提交任务时,就相当于生产者将任务生产出来并放入线程池的任务队列中。例如:
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class ThreadPoolProducerConsumerExample { public static void main(String[] args) { // 创建一个固定大小的线程池,包含 2 个线程 ExecutorService threadPool = Executors.newFixedThreadPool(2); // 提交任务,这里的提交操作相当于生产者生产任务 for (int i = 0; i < 5; i++) { final int taskId = i; threadPool.execute(() -> { System.out.println("Processing task " + taskId + " by thread " + Thread.currentThread().getName()); }); } // 关闭线程池 threadPool.shutdown(); } }
在上述代码中,main
方法中的 for
循环不断提交任务到线程池中,main
线程就扮演了生产者的角色。
共享缓冲区
线程池中的任务队列就是生产者 - 消费者模式中的共享缓冲区。常见的任务队列有 ArrayBlockingQueue
、LinkedBlockingQueue
等。当生产者提交任务时,任务会被放入这个队列中等待处理。例如,在创建线程池时可以指定使用的任务队列:
import java.util.concurrent.*; public class ThreadPoolWithQueueExample { public static void main(String[] args) { // 创建一个固定大小的线程池,使用 LinkedBlockingQueue 作为任务队列 BlockingQueue<Runnable> taskQueue = new LinkedBlockingQueue<>(10); ThreadPoolExecutor threadPool = new ThreadPoolExecutor( 2, // 核心线程数 5, // 最大线程数 60, // 线程空闲时间 TimeUnit.SECONDS, taskQueue ); // 提交任务 for (int i = 0; i < 5; i++) { final int taskId = i; threadPool.execute(() -> { System.out.println("Processing task " + taskId + " by thread " + Thread.currentThread().getName()); }); } // 关闭线程池 threadPool.shutdown(); } }
这里的 LinkedBlockingQueue
就是线程池中的共享缓冲区,用于存储生产者提交的任务。
消费者角色
线程池中的工作线程充当消费者的角色。这些工作线程会不断从任务队列中取出任务并执行。当任务队列中有任务时,工作线程会竞争获取任务进行处理;当任务队列为空时,工作线程会进入等待状态,直到有新的任务被放入队列中。
生产者 - 消费者模式为线程池带来的优势
- 解耦生产者和消费者:生产者(提交任务的线程)和消费者(工作线程)不需要直接交互,它们只与任务队列进行交互,降低了代码的耦合度,提高了系统的可维护性。
- 平衡生产和消费能力:通过调整任务队列的大小和线程池中的线程数量,可以平衡生产者生产任务的速度和消费者处理任务的速度,避免任务堆积或线程空闲的情况。
- 提高并发性能:生产者和消费者可以并行工作,多个生产者可以同时提交任务,多个消费者可以同时处理任务,从而提高了系统的并发性能。
26、线程池的拒绝策略有哪些
直接放弃任务、抛出异常、从队列中移除最老的任务将新任务放入队列、让调用者线程自己执行这个任务
27、jvm怎么判断一个对象能不能被回收
有两种方法,一种是引用计数法,一种是可达性分析算法。
引用计数法就是记录每个对象的引用次数,如果它的引用次数为0说明对象不在被引用,那么就可以回收了。
可达性分析算法是指从一个根集合(主要的类对象)出发,去寻找他们引用的对象,再从找到的引用的对象去寻找他们的引用对象,通过这些引用关系来追踪对象的可达性,确定哪些对象是可达的,哪些是不可达,如果一个对象没有被标记为可达的,那么就可以被垃圾回收了。
28、gc roots有哪些东西
类对象、
29、 jvm有哪些经典的垃圾回收算法,不是垃圾回收器,讲讲区别
标记-清理算法:它会使用可达性分析算法标记哪些对象是可以垃圾回收的,然后它将这些对象直接从内存中删除。这样的好处是很方便快速。缺点是会产生很多内存碎片。
标记-整理算法:它会使用可达性分析算法标记哪些对象是可以垃圾回收的,然后将这些对象直接从内存中删除,最后将所有剩下的对象往一个方向上移动,紧密存放,这样就不会有内存碎片了。但缺点是整理这个操作非常复杂耗时。
标记-复制算法:它将整个内存区域分成大小相等两块,一块用来存放对象,另一块用来做垃圾回收时的存放区域。它也是每次先标记哪些是可达的对象,然后将这些对象移动到另一个区域,在将原来区域中的不可达对象都删除。这样就不会有内存碎片了。但缺点也很明显:内存区域只有一半是可以使用的,内存利用率大大降低。
分代收集算法:将内存区域划分为了老年代和新生代,新创建的对象都会放到新生代中,新生代中经历几次垃圾回收都没有被回收的对象会放到老年代中,针对新生代和老年代可以使用不同的垃圾回收算法。为什么要分代呢?
因为在大多数java程序的执行过程中,很多对象仅仅使用一次或几次就不在使用了,而有些对象会一直使用,那么就可以将新对象都放到一个区域,这个区域中的大多数对象很快就会被垃圾回收掉,而少部分对象经过几次垃圾回收都没有被回收掉说明是经常使用的对象,可以将这些对象放到另外一个区域。另外一个区域都用来存放这些使用次数多的对象。这个区域不会经常执行垃圾回收,只有当整个内存都满了才会将整个区域内的对象进行垃圾回收。这样做的好处就是可以根据不同区域来使用不同的垃圾回收算法,执行一次垃圾回收的成本和时间开销都是比较大大的,所以选用的垃圾回收算法要应用在合适的地方,新生代中的大部分对象都会被垃圾回收,那么就可以执行标记-复制算法,因为只需要将少部分对象复制到另一个区域。老年代大部分对象都不会回收,可以使用标记-整理算法,虽然复杂但利用率高。
30、分代收集讲讲
分代收集算法:将内存区域划分为了老年代和新生代,新创建的对象都会放到新生代中,新生代中经历几次垃圾回收都没有被回收的对象会放到老年代中,针对新生代和老年代可以使用不同的垃圾回收算法。为什么要分代呢?
因为在大多数java程序的执行过程中,很多对象仅仅使用一次或几次就不在使用了,而有些对象会一直使用,那么就可以将新对象都放到一个区域,这个区域中的大多数对象很快就会被垃圾回收掉,而少部分对象经过几次垃圾回收都没有被回收掉说明是经常使用的对象,可以将这些对象放到另外一个区域。另外一个区域都用来存放这些使用次数多的对象。这个区域不会经常执行垃圾回收,只有当整个内存都满了才会将整个区域内的对象进行垃圾回收。这样做的好处就是可以根据不同区域来使用不同的垃圾回收算法,执行一次垃圾回收的成本和时间开销都是比较大大的,所以选用的垃圾回收算法要应用在合适的地方,新生代中的大部分对象都会被垃圾回收,那么就可以执行标记-复制算法,因为只需要将少部分对象复制到另一个区域。老年代大部分对象都不会回收,可以使用标记-整理算法,虽然复杂但利用率高。
31、联合索引失效
mysql中有一个id,A,B,C index(A,B) select * from where B=?走不走索引,为什么 select * from where A like“hello%”走不走索引,为什么 select * from where A lkie “%hello”走不走索引,为什么 select length(A) from where A=?走不走索引,为什么 select * from where A = ?这条sql语句执行过程 select id from where A = ?这条sql语句执行过程
- 不会走索引,要想走联合索引必须满足最左匹配元素,第一个字段必须是A。
- 会走索引,首先满足了最左匹配原则,并且字段A是字符串,并且使用的是左模糊查询,字符串构建索引是从第一个字母开始排序构建的,所以左模糊匹配可以查询前缀是hello所有索引记录。
- 不会,使用的是右模糊匹配,没办法知道第一个字母是多少,所以也就没法走索引,只能走全表扫描。
- 会、符合最左匹配,并且对A使用的是=的查询操作,会走索引,虽然对查询结果使用了函数,但是不影响查询过程中走索引。
- 直接查询这个索引记录,得到所有满足查询条件的主键ID,然后在通过主键ID去主键索引中查询对应的记录,也就是回表操作。
- 这个不需要回表,因为它只获取主键ID,每一条索引上都有ID值,所以不需要在通过主键ID去查询主键索引记录。
32、redis有什么数据结构
String、Hash、List、Set、Zset,还有不常用的GEO、bitmap、Stream
String是用来存字符串的,也可以存整数或者浮点数。Hash是用来存键值对的哈希表。List 是一个存有序数据的链表。Set用来存不重复数据的集合。Zset来时用来存不重复数据的集合,但是里面的数据存放是排序的。
然后它们的底层数据结构都是怎么实现的呢?
String:String的底层使用的是SDS(动态字符串),为啥不用C语言里的字符数组呢?主要C语言里的字符数组有几个缺点,第一点,C语言获取长度的时间复杂度是O(n),C语言的字符数组是根据
\0
作为字符串的结束标志,所以需要从字符数组的第一个位置一直遍历到结束标志才能得到这个字符串的长度。第二个缺点:二进制不安全,刚说字符数组用\0
来做结束标志,那如果原本的字符串数据里面想存\0
这个字符的话,在获取长度的时候,C语言会误认为这个就是结束标志了,所以字符数组存二进制数据是不安全的。第三点:C语言的一些操作方法会导致内存溢出:比如拼接字符串方法,它会直接将新的字符串拼接到原字符串的结束标志后面,而不关心这个原本的字符数组后面的空闲位置的长度是否满足新字符串的长度,如果小于的话,就会导致内存溢出。所以说,要解决这个问题还需要程序员编写代码来做校验。因为C语言的字符数组的这些问题,不适合做Redis的字符串数据结构,所以redis自己写一个了字符串数据结构SDS,简单的动态字符串。它定义了一个结构体,结构体里面有4个字段,分别是len长度字段,记录了字符串的长度,alloc字段,记录了当前字符数组的长度,flags字符:记录SDS的类型,buf数组,存放字符串数据。有了这四个字段就可以实现存储字符串数据也可以解决C语言的字符数组的问题。它的第一个优点:快速获取字符串长度,它的len字段记录了字符串长度,可以以O(1)的时间复杂度获取。第二个优点:可以存储任何二进制数据,因为它不再需要使用\0
来作为结束标志了,所以它想存什么数据都可以。第三个优点:不会导致内存溢出,在拼接字符串的时候,它会通过alloc-len来获取当前剩余空间,判断是否满足新字符串的需要,如果满足才会进行拼接,不满足还会扩容。第四个优点:更少的使用内存,它的flags字段记录当前SDS的类型,SDS的类型分为好几种,区别在于len字段和alloc字段使用的数据类型不同,比如是4字节的整数、8字节、16字节、32字节,它会根据字符串的长度来判断使用合适的数据类型,如果字符串很短,就可以使用更小的数据类型来存储,节省内存空间。List:List底层是由双向链表和压缩列表实现的,首先说一下双向链表:它定义了一个链表结构体,里面有链表头节点、链表尾节点以及链表长度字段等信息。链表头节点指针链表的第一个链表节点,链表尾节点指向链表最后一个节点,通过链表节点中的前驱指针和后继指针访问相邻节点,链表节点有一个value指针,指向实际的数据结构。双向链表的好处是:1、可以O(1)的时间复杂度获取链表长度,而不需要遍历链表。2、访问链表头节点和尾节点的直接复杂度是O(1)。3、链表节点的value字段使用的是void *指针类型,它可以指针任何类型的数据。但双向链表的缺点也有:1、占用内存空间较大,每个节点都需要存储前驱和后继指针,指针的大小甚至超过了数据的大小。2、链表中节点的存储是不连续的,所以没办法很好的利用CPU缓存。所以如果数据比较少的情况下,会使用压缩列表作为List的底层数据结构。压缩列表是什么样子的呢,它不是链表,类似于数组,它在内存是连续的,而且也不需要存放指针。它有这么几个字段:zlbytes:记录了整个压缩列表使用的字节总数、zltail:记录了最后一个节点到压缩列表起始位置的字节数、zllen:记录了压缩列表存储了多少个元素。zlend:压缩列表的结束标志。有了这些字段,他跟双向链表一样,都可以以O(1)的时间复杂度获取第一个节点和最后一个节点,因为它是内存连续的,前三个字段的长度也是固定的,那么过了前三个字段的长度就是第一个元素的位置。最后一个节点的位置可以通过zltail字段获取。并且它也是可以直接获取数据总个数。但如果要访问中间节点的元素,那么就跟链表一样都需要遍历才行,时间复杂度是O(n)。那么它是怎么实现访问前一个元素或者后一个元素的呢。首先我们要知道它们在内存中是连续存储的,并且每一个节点都有三个字段:第一个字段:prevlen:记录前一个节点的长度。encoding:记录当前节点数据的类型以及长度。第三个字段:data:存储数据。通过前两个字段就可以知道当前节点的前一个节点或者后一个节点的位置了。比如现在在第一个元素,我根据它的encoding字段知道了数据的长度,那么根据这个长度就可以下一个节点的起始位置了,后续节点也是这样知道的。那怎么访问前一个节点呢:可以直接根据prevlen字段获取前一个节点的长度,根据当前节点的位置就可以计算出前一个节点的起始位置了。所以,压缩列表就可以根据这些字段信息就可以实现查询操作了。但是它也是有缺点的,它的缺点是在新增或修改某个元素导致的连锁更新问题。为啥会导致连锁更新:因为每个节点的prevlen字段有两个数据类型,如果前一个节点的字节总数小于254,那么就是用1个字节的数据类型,如果前一个节点的字节总数大于等于254,那么就用5个字节的数据类型。假如现在压缩列表的第一个节点的长度是10个字节,那么它下一个节点的prevlen肯定是1个字节的数据类型,然后我修改第一个节点的数据为255个字节,那么当前节点的个数就大于254了,下一个节点的prevlen字段就要使用5个字节的数据类型了,如果下一个节点因为prevlen多了4个字节导致整个节点的长度也大于254了,那么下下个节点也要更新prevlen,以此类推,发生连锁效应,后面所有节点都需要更新,也就是连锁更新问题,所以压缩列表只适合数据不太大,个数不太多的情况,如果数据比较大,节点个数比较多,还是要使用链表来存储。
Hash:Hash的底层数据结构是哈希表,哈希表是用来存键值对数据的集合,它根据以O(1)的时间复杂度来查询数据,并且键值对的key在集合中是唯一的。怎么做到的呢,新增一个键值对,首先会将整个键值对中的key经过哈希函数计算得到一个哈希值,在通过整个哈希值得到它在数组中的下标位置,然后将这个键值对放入位置上。根据key查询的时候,也是计算它的哈希值获得它在数组中的下标位置,来获取元素,所以说它查询是O(1)的时间复杂度。但如果数据多了,肯定会出现问题,哈希冲突问题,也就是如果有不同的key经过哈希函数得到的下标位置相同怎么办,哈希表为了解决这个问题使用了拉链法,也就是将所有位置相同的key通过链表连接起来,在想获取元素的时候通过链表查询,这样也就解决了哈希冲突问题。久而久之呢,数据越来越多,哈希冲突出现的次数也越来越频繁,链表的长度也就越来越长,查询效率就从O(1)退化到了O(n),这样下去肯定是不行的,为了解决这个问题,需要扩容,也就是增加数组的长度,减少哈希冲突的次数,提高查询效率,也就是拿空间换时间。它什么时候会扩容呢?当键值对个数/数组长度大于1的时候,并且没有执行AOF重写和RDB快照的时候,才会扩容。或者当它俩的比率大于等于5的时候,无论有没有执行AOF重写或者RDB快照,它都会扩容。那它是怎么扩容的呢,其实在Hash的底层是有两个hash表,一个用来存数据和读取数据的哈希表,一个是用来做扩容的哈希表,它俩交替使用。如果此处开始扩容了,它不会直接将所有原哈希表的数据迁移到新哈希表,而是渐进式扩容,也就是一点一点扩容,那什么时候迁移呢?当来了一个读取/新增/删除/更新操作时,它首先会在原哈希表上执行这些操作,然后将这个操作的下标索引位置上所有的元素都迁移到新哈希表中,原哈希表上这个位置就空了,随着操作命令越来越多,数据也就一点一点迁移走了,直到原哈希表中彻底没数据了才算结束,之后就都使用新哈希表了,原哈希表会设置成新哈希表个数的2倍,以便下次扩容使用。它俩完成了角色互换。那它为啥要渐进式扩容呢,为啥不直接全部一次性迁移呢?首先,哈希表存放的数据一般都很多,如果一次性迁移肯定对整个redis进程的执行有影响,降低了整个redis的性能,影响了正常读取操作。而且redis是单线程的,只有一个线程来执行各种请求操作,如果一直去做迁移,肯定影响性能,各种请求会被阻塞。所以就将整个迁移操作分散到一次次操作,降低它对性能的影响。其实Hash底层还使用压缩列表作为底层数据机构,如果数据比较少的时候才会使用。
Set:Set底层数据结构有Hash表和整数集合。使用Hash表我们可以理解,哈希表中的key都是唯一的,而且可以以O(1)的时间复杂度查询元素,所以可以很好的实现set的元素唯一以及快速的查询、增加、删除操作。当set里面的数据都是整数并且数据个数比较少的时候会使用整数集合。整数集合有三个字段:1、encoding:编码方式,指的是保存元素的数组的数据类型,有16、32、64bit。2、length:元素个数。3、保存元素的数组。它是怎么使用数组来实现set集合的查询、删除、增加操作的。其实这个数组是有序的,它在新增元素和删除元素的时候都会保证数组元素的单调递增,查询的时候直接可以通过二分查询,时间复杂度logn,而且元素又很少,最多就几百个,相当于是O(1)的时间复杂度来查询数据,当新增元素的时候,它首先查询这个元素是否在数组中,如果找到了,那无需插入直接返回,如果没找到,那么它会找到比这个元素大的所有元素中的最小的那个元素的下标,然后将数组扩容,将这个下标以及后面所有的元素都向后移动一位,最后将当前元素放到这个下标上,这样就实现了插入。插入过程中可能会出现整数集合的升级操作,如果新插入的元素类型大小比当前数组元素的数据元素类型都要大,比如插入元素是32位的,而当前元素都是16位的,那么就会进行整数集合升级成更高的数据类型,这样做的目的是节省内存,能使用小的数据类型存储就使用小的,除非遇到了更大的元素才使用更大的数据类型。它是怎么执行升级操作的呢?首先进行扩容,扩容的大小等于新插入元素的数据类型乘上元素个数,这样就可以刚好放下所有元素。而且因为新插入的元素比当前数组中所有的元素都要大,所以它肯定放在数组元素的最后一个,所以先将新元素放到数组的最后一个位置,然后将之前的那些元素按照从大到小依次移动到元素的后面,这样保证有序,而且不会影响到其他元素,这样就完成了更新。如果元素是负数的话,那肯定放到数组的第一个位置,先将数组中的元素都向后移动,再将新元素放入第一个位置,这样就实现了整数集合升级操作。
Zset:Zset的底层数据结构是由压缩列表或者跳表来实现的。压缩列表刚才讲过,可以做List的数据结构,List的元素不是有序的而且只存单元素,但是Zset要存元素以及对应的分数,并且要求按分数排序,那这个压缩列表是怎么实现Zset要求的性质的呢?压缩列表中每个元素使用两个连续的节点来保存,一个保存key,后一个保存分数。然后还要按照分数从小到大的顺序排列,在插入和删除、修改的时候都要保持列表的有序。这样就能方便根据score查询数据了。但是它的插入、删除、修改这些操作的时间复杂度都是O(n),所以只适合元素个数比较少的情况。如果元素个数比较多,那么就需要使用另一种数据结构跳表,跳表底层是一个链表,我们知道,链表的插入和删除操作很快,查询很快,而数组是查询很快,插入删除很慢。那怎么样可以实现让链表查询很快?跳表中的每个节点不仅指向下一个节点的指针,还有指向其他节点的指针。比如说我现在有一个长度是100的链表,如果我想知道第五五十的节点数据是多少,那我只能从头节点遍历,但现在可以让头节点多一个指针直接指向第五十个节点,那么查询时间就变成了O(1),如果我要找第25个元素,可以在头节点多一个指针指向第25个节点,如果要找第75个节点,可以让第50个节点多一个指针指向第75个节点,通过这种在每个节点增加指向其他节点的指针方式,可以快速的实现查询,复杂度可以达到logn,但这样的指针不能太多,太多就增加内存空间了,所以redis设置基本上平均每个节点拥有一两个指针即可,每个指针只指向长度是一半的指针,比如50、25、12这样,一半长度的,这样就可以实现logn的查询速度了。并且每个指针对应有一个跨度,记录指针的节点到当前节点的长度,这个跨度就是用来记录长度的,这个长度其实也是每个元素在链表中的位次,通过跨度和指针就可以找到第多少位的节点在什么地方。
33、redis的zset底层结构
Zset:Zset的底层数据结构是由压缩列表或者跳表来实现的。压缩列表刚才讲过,可以做List的数据结构,List的元素不是有序的而且只存单元素,但是Zset要存元素以及对应的分数,并且要求按分数排序,那这个压缩列表是怎么实现Zset要求的性质的呢?压缩列表中每个元素使用两个连续的节点来保存,一个保存key,后一个保存分数。然后还要按照分数从小到大的顺序排列,在插入和删除、修改的时候都要保持列表的有序。这样就能方便根据score查询数据了。但是它的插入、删除、修改这些操作的时间复杂度都是O(n),所以只适合元素个数比较少的情况。如果元素个数比较多,那么就需要使用另一种数据结构跳表,跳表底层是一个链表,我们知道,链表的插入和删除操作很快,查询很快,而数组是查询很快,插入删除很慢。那怎么样可以实现让链表查询很快?跳表中的每个节点不仅指向下一个节点的指针,还有指向其他节点的指针。比如说我现在有一个长度是100的链表,如果我想知道第五五十的节点数据是多少,那我只能从头节点遍历,但现在可以让头节点多一个指针直接指向第五十个节点,那么查询时间就变成了O(1),如果我要找第25个元素,可以在头节点多一个指针指向第25个节点,如果要找第75个节点,可以让第50个节点多一个指针指向第75个节点,通过这种在每个节点增加指向其他节点的指针方式,可以快速的实现查询,复杂度可以达到logn,实际上指针指向的节点是随机的,并不是想指哪个就指哪个,这样的指针不能太多,太多就增加内存空间了,所以redis设置基本上平均每个节点拥有一两个指针即可,每个指针只指向长度是一半的指针,比如50、25、12这样,一半长度的,这样就可以实现logn的查询速度了。并且每个指针对应有一个跨度,记录指针的节点到当前节点的长度,这个跨度就是用来记录长度的,这个长度其实也是每个元素在链表中的位次,通过跨度和指针就可以找到第多少位的节点在什么地方。
34、skiplist的随机算法,具体父链表跳几格是怎么算的
这都问了,我不会
学了一下,大概是这样的,每个节点都有一个指针数组,指针数组的长度是这个节点的层数,数组的每个元素都是一个指针,指向该节点在对应层级链表上的下一个节点。每个节点的层数是随机的,它有一个概率p,随机生成一个数,如果这个数小于它,层数+1,然后继续生存一个数,如果还小于p,层数+1,直到大于概率p停止。那它是怎么知道这个指针数组中每个指针指向哪一个节点呢?在插入新节点的时候,会确定层数以及指针指向。首先随机确定新节点的层数,然后查找这个节点在链表中的插入位置,它的查找过程是这样的?首先在第一个节点的最高层查询,如果最高层指针的节点的分数小于等于当前插入的元素的分数,那么就跳转到指针指向的元素,然后再从这一层最高层的指针查询,如果指向的节点的分数大于等于当前节点,那么就从最高层的下一层来查询,后面就会通过这种从高层到底层的查询来确认新节点的插入位置,而且在查找的过程中还会记录每一层最后一个经过节点的指针,最后从底层到高层,将新节点插入到每一层的位置中,将新节点的指针指向记录这一层节点的指针指向的下一个节点,记录的这一层的节点指针这个新节点,这样就确定了每一层指针的指向。跳几格是根据每个指针对应的跨度来计算得到的,在查询的过程中也会记录跨度的总和,得到的结果就是跳跃的格数。
35、zset除了skiplist还有什么
压缩列表
压缩列表是什么样子的呢,它不是链表,类似于数组,它在内存是连续的,而且也不需要存放指针。它有这么几个字段:zlbytes:记录了整个压缩列表使用的字节总数、zltail:记录了最后一个节点到压缩列表起始位置的字节数、zllen:记录了压缩列表存储了多少个元素。zlend:压缩列表的结束标志。有了这些字段,他跟双向链表一样,都可以以O(1)的时间复杂度获取第一个节点和最后一个节点,因为它是内存连续的,前三个字段的长度也是固定的,那么过了前三个字段的长度就是第一个元素的位置。最后一个节点的位置可以通过zltail字段获取。并且它也是可以直接获取数据总个数。但如果要访问中间节点的元素,那么就跟链表一样都需要遍历才行,时间复杂度是O(n)。那么它是怎么实现访问前一个元素或者后一个元素的呢。首先我们要知道它们在内存中是连续存储的,并且每一个节点都有三个字段:第一个字段:prevlen:记录前一个节点的长度。encoding:记录当前节点数据的类型以及长度。第三个字段:data:存储数据。通过前两个字段就可以知道当前节点的前一个节点或者后一个节点的位置了。比如现在在第一个元素,我根据它的encoding字段知道了数据的长度,那么根据这个长度就可以下一个节点的起始位置了,后续节点也是这样知道的。那怎么访问前一个节点呢:可以直接根据prevlen字段获取前一个节点的长度,根据当前节点的位置就可以计算出前一个节点的起始位置了。所以,压缩列表就可以根据这些字段信息就可以实现查询操作了。但是它也是有缺点的,它的缺点是在新增或修改某个元素导致的连锁更新问题。为啥会导致连锁更新:因为每个节点的prevlen字段有两个数据类型,如果前一个节点的字节总数小于254,那么就是用1个字节的数据类型,如果前一个节点的字节总数大于等于254,那么就用5个字节的数据类型。假如现在压缩列表的第一个节点的长度是10个字节,那么它下一个节点的prevlen肯定是1个字节的数据类型,然后我修改第一个节点的数据为255个字节,那么当前节点的个数就大于254了,下一个节点的prevlen字段就要使用5个字节的数据类型了,如果下一个节点因为prevlen多了4个字节导致整个节点的长度也大于254了,那么下下个节点也要更新prevlen,以此类推,发生连锁效应,后面所有节点都需要更新,也就是连锁更新问题,所以压缩列表只适合数据不太大,个数不太多的情况,如果数据比较大,节点个数比较多,还是要使用链表来存储。
Zset要存元素以及对应的分数,并且要求按分数排序,那这个压缩列表是怎么实现Zset要求的性质的呢?压缩列表中每个元素使用两个连续的节点来保存,一个保存成员,后一个保存分数。然后还要按照分数从小到大的顺序排列,在插入和删除、修改的时候都要保持列表的有序。这样就能方便根据score查询数据了。但是它的插入、删除、修改这些操作的时间复杂度都是O(n),所以只适合元素个数比较少的情况。
36、redis对于ttl过期的key是怎么处理的
过期删除策略:如果我们将一些数据设置了过期时间,这个时间过了,这个数据就删除不能再使用了。首先redis是怎么知道哪个数据过期了,只要设置一个数据的过期时间,redis就会将这个数据以及过期时间存入过期字典中,这个过期字典其实就是哈希表。哈希表的好处是快速查询数据,当我们要查询一个数据时,首先会到过期字典中查询这个数据是否已经过期了或者不存在过期字典中,如果不存在,那么直接去读取这个数据,如果存在,会比较这个key的过期时间跟当前系统时间的大小,如果超过,说明过期了,否则没有过期。Redis有三种过期策略:定时删除、惰性删除、定期删除。定时删除是指,创建一个带有过期时间的key时,同时会创建一个定时任务,当时间到了,这个任务就去删除这个key。这么做的好处是可以快速的删除过期数据,尽快的释放内存。缺点是如果过期数据很多,会有很多的定时任务来删除数据,这样肯定会影响服务器的性能,影响了正常的请求执行。惰性删除:惰性删除就是很懒,它不会去等待过期了就去主动删除数据,而是等有请求访问数据时,去查询这个数据,如果这个数据过期了,直接将这个数据删除。优点是:不会很大的影响整个redis的性能,对CPU友好。但缺点是如果没有请求访问过期的数据,那么这个数据就一直不会被删除,造成空间浪费。定期删除:定期删除是指每隔一段时间随机从数据库中检查key是否过期,如果过期就删除。这个方式的平衡了定时删除和惰性删除的优点,它不会很大的影响redis的性能,也可以将过期数据删除,减少空间的浪费。缺点是:不能保证过期数据及时删除,同时也会使用很多CPU资源来处理,如果这个间隔时间太大,那么就跟惰性删除一样了,如果间隔时间太小,就跟定时删除一样了。
Redis使用的过期删除策略是「惰性删除+定期删除」,这两种策略搭配使用,合理的利用CPU资源并且减少内存空间浪费。
37、redis的持久化机制讲讲
我们知道redis是把数据都存放在内存中的,如果redis重启那么存储在内存中的数据就会丢失,为了保证redis重启数据不会丢失,redis实现了数据持久化的机制,也就是将redis中的数据保存到磁盘当中,当redis重启后能从磁盘中恢复数据。它有两种机制来实现持久化:
- AOF:当有一条写命令要执行后,会将这条命令也写入到AOF文件中。
- RDB:将当前redis中所有数据以二进制的形式存入磁盘。
首先说一说AOF是如何实现持久化的。针对每一条写命令,都会将这条命令以追加的形式写入一个文件中。如果redis重启了,在将这个文件当中的所有写命令执行一遍,这样就恢复了数据。AOF中的写入文件有三种策略:
- Always:总是,每次执行一条写命令,就将这个写命令写入磁盘中。它的优点是,可靠性高,如果Redis突然宕机或者重启,那么也就丢失一条命令。缺点是每次写命令都要协会磁盘,影响redis的性能。
- EverySec:每秒,每次执行一条写命令的时候,将这个命令写入AOF内存缓冲区,然后每秒将缓冲区中的数据写回磁盘中。它的优点是不怎么影响redis的性能。缺点是如果发生宕机那么就会丢失1s的数据。
- No:每次执行一条写命令,将这个命令写入内存缓冲区,由操作系统来决定什么时候写回磁盘。优点是性能很好,缺点是可能会丢失很多数据。
再来说一说RDB是如何实现持久化的,AOF日志文件中存取的都是一条条命令,当恢复数据时,就会一条一条的执行这些命令,如果命令很多的话,执行时间就很长,非常影响性能,随意呢,就有了RDB快照,顾名思义,就是将某一时刻的所有数据以二进制的形式存入磁盘,恢复的时候,直接读取数据到内存中,不需要执行那么多命令了。Redis提供了两种执行RDB快照的命令,区别在于是否在主线程中执行:
- sava命令:在主线程中生成RDB快照,但是主线程还要执行读写操作命令,所以会阻塞主线程的执行。
- bgsave命令:由后台线程来生成RDB快照,不影响主线程的执行。
还可以在Redis的配置文件中配置RDB快照执行的时机,比如在10分钟内如果有100个键被修改就执行一次bgsave命令。
它俩的优缺点分别是啥呢?
- AOF:优点:安全性高,如果redis某一时刻宕机了,那么只有最后一个写命令丢失,前面的数据都保存在磁盘中,而且提供了三种写回策略,根据需要调整性能和安全性的平衡。缺点:AOF文件中存的都是命令,导致文件很大,占用更多的磁盘空间,每次执行写操作都会写回磁盘,肯定影响redis的性能,恢复数据的时候,如果文件太大,恢复的时间也会很长。
- RDB:优点:生成的RDB快照文件只保存某一时刻的二进制数据,所以文件肯定要比AOF小很多,恢复数据的时候可以直接读取这些数据到内存中,不需要在执行很多写命令。而且可以在后台线程中去执行RDB快照,不会阻塞主线程。缺点:RDB快照执行的间隔时间需要控制,如果间隔时间太长,就会丢失很多数据。如果间隔时间太短,那么就会影响redis的性能。
38、AOF文件里面存什么,RDB文件里面存什么
AOF文件中存的是写命令,RDB文件中存的是二进制数据。 恢复数据的时候,AOF是将所有的写命令在执行一遍,而RDB可以直接将所有的数据读取到内存中,更快捷。
39、生产实践中RDB和AOF什么用的多,还是用其他的
应该是这俩都会使用,
4.算法一个有序数组,输入一个数字,求这个数字在这个数组中重复的次数