面试题

148 阅读28分钟

mysql

change buffer的使用场景

通过上面的分析,你已经清楚了使用change buffer对更新过程的加速作用,也清楚了change buffer只限于用在普通索引的场景下,而不适用于唯一索引。那么,现在有一个问题就是:普通索引的所有场景,使用change buffer都可以起到加速作用吗?

因为merge的时候是真正进行数据更新的时刻,而change buffer的主要目的就是将记录的变更动作缓存下来,所以在一个数据页做merge之前,change buffer记录的变更越多(也就是这个页面上要更新的次数越多),收益就越大。

因此,对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时change buffer的使用效果最好。这种业务模型常见的就是账单类、日志类的系统。

反过来,假设一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录在change buffer,但之后由于马上要访问这个数据页,会立即触发merge过程。这样随机访问IO的次数不会减少,反而增加了change buffer的维护代价。所以,对于这种业务模式来说,change buffer反而起到了副作用。

间隙锁

记录锁、间隙锁、临键锁

Mysql如何解决幻读

集合

HashMap源码

1 put方法流程

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
​
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //判断数组是否未初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        //如果未初始化,调用resize方法 进行初始化
        n = (tab = resize()).length;
    //通过 & 运算求出该数据(key)的数组下标并判断该下标位置是否有数据
    if ((p = tab[i = (n - 1) & hash]) == null)
        //如果没有,直接将数据放在该下标位置
        tab[i] = newNode(hash, key, value, null);
    //该数组下标有数据的情况
    else {
        Node<K,V> e; K k;
        //判断该位置数据的key和新来的数据是否一样
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            //如果一样,证明为修改操作,该节点的数据赋值给e,后边会用到
            e = p;
        //判断是不是红黑树
        else if (p instanceof TreeNode)
            //如果是红黑树的话,进行红黑树的操作
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //新数据和当前数组既不相同,也不是红黑树节点,证明是链表
        else {
            //遍历链表
            for (int binCount = 0; ; ++binCount) {
                //判断next节点,如果为空的话,证明遍历到链表尾部了
                if ((e = p.next) == null) {
                    //把新值放入链表尾部
                    p.next = newNode(hash, key, value, null);
                    //因为新插入了一条数据,所以判断链表长度是不是大于等于8
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        //如果是,进行转换红黑树操作
                        treeifyBin(tab, hash);
                    break;
                }
                //判断链表当中有数据相同的值,如果一样,证明为修改操作
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                //把下一个节点赋值为当前节点
                p = e;
            }
        }
        //判断e是否为空(e值为修改操作存放原数据的变量)
        if (e != null) { // existing mapping for key
            //不为空的话证明是修改操作,取出老值
            V oldValue = e.value;
            //一定会执行  onlyIfAbsent传进来的是false
            if (!onlyIfAbsent || oldValue == null)
                //将新值赋值当前节点
                e.value = value;
            afterNodeAccess(e);
            //返回老值
            return oldValue;
        }
    }
    //计数器,计算当前节点的修改次数
    ++modCount;
    //当前数组中的数据数量如果大于扩容阈值
    if (++size > threshold)
        //进行扩容操作
        resize();
    //空方法
    afterNodeInsertion(evict);
    //添加操作时 返回空值
    return null;
}

2 扩容

//扩容、初始化数组
final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
      //如果当前数组为null的时候,把oldCap老数组容量设置为0
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //老的扩容阈值
      int oldThr = threshold;
        int newCap, newThr = 0;
        //判断数组容量是否大于0,大于0说明数组已经初始化
      if (oldCap > 0) {
            //判断当前数组长度是否大于最大数组长度
            if (oldCap >= MAXIMUM_CAPACITY) {
                //如果是,将扩容阈值直接设置为int类型的最大数值并直接返回
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //如果在最大长度范围内,则需要扩容  OldCap << 1等价于oldCap*2
            //运算过后判断是不是最大值并且oldCap需要大于16
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold  等价于oldThr*2
        }
      //如果oldCap<0,但是已经初始化了,像把元素删除完之后的情况,那么它的临界值肯定还存在,            如果是首次初始化,它的临界值则为0
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        //数组未初始化的情况,将阈值和扩容因子都设置为默认值
      else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
      //初始化容量小于16的时候,扩容阈值是没有赋值的
        if (newThr == 0) {
            //创建阈值
            float ft = (float)newCap * loadFactor;
            //判断新容量和新阈值是否大于最大容量
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
      //计算出来的阈值赋值
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        //根据上边计算得出的容量 创建新的数组       
      Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
      //赋值
      table = newTab;
      //扩容操作,判断不为空证明不是初始化数组
        if (oldTab != null) {
            //遍历数组
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                //判断当前下标为j的数组如果不为空的话赋值个e,进行下一步操作
                if ((e = oldTab[j]) != null) {
                    //将数组位置置空
                    oldTab[j] = null;
                    //判断是否有下个节点
                    if (e.next == null)
                        //如果没有,就重新计算在新数组中的下标并放进去
                        newTab[e.hash & (newCap - 1)] = e;
                    //有下个节点的情况,并且判断是否已经树化
                    else if (e instanceof TreeNode)
                        //进行红黑树的操作
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    //有下个节点的情况,并且没有树化(链表形式)
                    else {
                        //比如老数组容量是16,那下标就为0-15
                        //扩容操作*2,容量就变为32,下标为0-31
                        //低位:0-15,高位16-31
                        //定义了四个变量
                        //        低位头          低位尾
                        Node<K,V> loHead = null, loTail = null;
                        //        高位头      高位尾
                        Node<K,V> hiHead = null, hiTail = null;
                        //下个节点
                        Node<K,V> next;
                        //循环遍历
                        do {
                            //取出next节点
                            next = e.next;
                            //通过 与操作 计算得出结果为0
                            if ((e.hash & oldCap) == 0) {
                                //如果低位尾为null,证明当前数组位置为空,没有任何数据
                                if (loTail == null)
                                    //将e值放入低位头
                                    loHead = e;
                                //低位尾不为null,证明已经有数据了
                                else
                                    //将数据放入next节点
                                    loTail.next = e;
                                //记录低位尾数据
                                loTail = e;
                            }
                            //通过 与操作 计算得出结果不为0
                            else {
                                 //如果高位尾为null,证明当前数组位置为空,没有任何数据
                                if (hiTail == null)
                                    //将e值放入高位头
                                    hiHead = e;
                                //高位尾不为null,证明已经有数据了
                                else
                                    //将数据放入next节点
                                    hiTail.next = e;
                               //记录高位尾数据
                                hiTail = e;
                            }
                            
                        } 
                        //如果e不为空,证明没有到链表尾部,继续执行循环
                        while ((e = next) != null);
                        //低位尾如果记录的有数据,是链表
                        if (loTail != null) {
                            //将下一个元素置空
                            loTail.next = null;
                            //将低位头放入新数组的原下标位置
                            newTab[j] = loHead;
                        }
                        //高位尾如果记录的有数据,是链表
                        if (hiTail != null) {
                            //将下一个元素置空
                            hiTail.next = null;
                            //将高位头放入新数组的(原下标+原数组容量)位置
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
      //返回新的数组对象
        return newTab;
    }

3 get方法

public V get(Object key) {
    Node<K,V> e;
    //hash(key),获取key的hash值
    //调用getNode方法,见下面方法
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
​
​
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    //找到key对应的桶下标,赋值给first节点
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //判断hash值和key是否相等,如果是,则直接返回,桶中只有一个数据(大部分的情况)
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        
        if ((e = first.next) != null) {
            //该节点是红黑树,则需要通过红黑树查找数据
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            
            //链表的情况,则需要遍历链表查找数据
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

image-20230716205848304

(n-1)&hash : 得到数组中的索引,代替取模,性能更好,数组长度必须是2的n次幂

ConcurrentHashMap

  • 因为不再使用 Segment,初始化操作大大简化,修改为 lazy-load 形式,这样可以有效避免初始开销,解决了老版本很多人抱怨的这一点。
  • 数据存储利用 volatile 来保证可见性。
  • 使用 CAS 等操作,在特定场景进行无锁并发操作。
  • 使用 Unsafe、LongAdder 之类底层手段,进行极端情况的优化。

先看看现在的数据存储内部实现,我们可以发现 Key 是 final 的,因为在生命周期中,一个条目的 Key 发生变化是不可能的;与此同时 val,则声明为 volatile,以保证可见性。

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;
        // … 
    }

IO多路复用机制

image-20230705141010540

4种IO多路复用模型与选择 select,poll,epoll、kqueue都是IO多路复用的机制。

I/O多路复用就是通过一种机制,一个进程可以监视多个描述符(socket),一旦某个描述符就绪(一 般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

select

select 函数监视的文件描述符分3类,分别是:writefds readfds except的fds

调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时 (timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过 遍历fd列表,来找到就绪的描述符。

优点 select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。 windows linux ... 缺点

int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

单个进程打开的文件描述是有一定限制的,它由FD_SETSIZE设置,默认值是1024,采用数组存储 另外在检查数组中是否有文件描述需要读写时,采用的是线性扫描的方法,即不管这些socket是不是活

跃的,都轮询一遍,所以效率比较低

poll

int poll (struct pollfd *fds, unsigned int nfds, int timeout); struct pollfd { int fd; //文件描述符 short events; //要监视的事件 short revents; //实际发生的事件 };

poll使用一个 pollfd的指针实现,pollfd结构包含了要监视的event和发生的event,不再使用select“参 数-值”传递的方式。

优点:

采样链表的形式存储,它监听的描述符数量没有限制,可以超过select默认限制的1024大小

缺点:

另外在检查链表中是否有文件描述需要读写时,采用的是线性扫描的方法,即不管这些socket是不是活 跃的,都轮询一遍,所以效率比较低。

epoll

epoll是在linux2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更 加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件 存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

创建一个epoll的句柄。自从linux2.6.8之后,size参数是被忽略的。需要注意的是,当创建好epoll 句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所 以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

poll的事件注册函数,它不同于select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先 注册要监听的事件类型。

第一个参数是epoll_create()的返回值。 第二个参数表示动作,用三个宏来表示: EPOLL_CTL_ADD:注册新的fd到epfd中; EPOLL_CTL_MOD:修改已经注册的fd的监听事件; EPOLL_CTL_DEL:从epfd中删除一个fd;

第三个参数是需要监听的fd。 第四个参数是告诉内核需要监听什么事

int epoll_create(int size)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

等待内核返回的可读写事件,最多返回maxevents个事件。 优点:

epoll 没有最大并发连接的限制,上限是最大可以打开文件的数目,举个例子,在1GB内存的机器上大约 是10万左 右

效率提升, epoll 最大的优点就在于它只管你* *“活跃”**的连接 ,而跟连接总数无关,因此在实际的网络环 境 中, epoll 的效率就会远远高于 select 和 poll 。

epoll使用了共享内存,不用做内存拷贝 kqueue

kqueue 是 unix 下的一个IO多路复用库。最初是2000年Jonathan Lemon在FreeBSD系统上开发的一个 高性能的事件通知接口。注册一批socket描述符到 kqueue 以后,当其中的描述符状态发生变化时, kqueue 将一次性通知应用程序哪些描述符可读、可写或出错了。

struct kevent { uintptr_t 句柄 EVFILT_WRITE flags; uint32_t fflags; // intptr_t data; //数据长度 void *udata; //数据 }; int16_t ident; filter; //是事件唯一的 key,在 socket() 使用中,它是 socket 的 fd //是事件的类型(EVFILT_READ socket 可读事件 socket 可 写事件) uint16_t //操作方式
优点:
能处理大量数据,性能较高

多线程:

ab字符交替打印共100次。

a先打印

解法一:

 public static void main(String[] args) {
        Thread[] threads = new Thread[2];
        threads[0] = new Thread(() -> {
            for (int i = 0; i < 50; ++i) {
                System.out.println("A");
                LockSupport.unpark(threads[1]);
                LockSupport.park();
            }
        });
​
        threads[1] = new Thread(() -> {
            for (int i = 0; i < 50; ++i) {
                LockSupport.park();
                System.out.println("B");
                LockSupport.unpark(threads[0]);
​
            }
        });
        threads[0].start();
        threads[1].start();
    }

解法二:

pubi

springSecurity

认证过程

时序图

认证时序图[1]

tokengranter 配置authenticationManager,增强方法、new OAuth2Authentication()

授权过程:

FilterSecurityInterceptor

img

img

授权拦截器结构图

img

授权时序图[1]

justauth配置:

继承AbstractAuthenticationProcessingFilter,重写attemptAuthentication方法。

通过 AuthenticationManager 转到相应的 Provider 对 Auth2LoginAuthenticationToken 进行认证。

如果需要绑定系统用户,需要自定义注册逻辑,在successfulAuthenticatio方法中调用重定向逻辑,(redis调用RedisConnectionFactory设置缓存数据)

jvm

pdf

2

G1 ◾Mark-Copy、多线程; ◾四个阶段(初始标记、并发标记、最终标记、筛选回收),第一和第三和第四阶段都需要 STW; ◾采用 “原始快照” 解决 “对象消失” 问题; ◾面向局部收集、基于 Region 的内存布局; ◾非纯粹地追求低延迟,而是在延迟可控的情况下获得尽可能高的吞吐量; ◾G1 无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比 CMS 要高; ◾目前在小内存应用上 CMS 的表现大概率仍然要会优于 G1,而在大内存应用上 G1 则大多能发挥其优势


Java 中的堆也是 GC 收集垃圾的主要区域。GC 分为两种:

一种是部分收集器(Partial GC)

一类是整堆收集器(Fu'll GC) 部分收集器: 不是完整收集java堆的的收集器,它又分为:

新生代收集(Minor GC / Young GC): 只是新生代的垃圾收集 老年代收集 (Major GC / Old GC): 只是老年代的垃圾收集 (CMS GC 单独回收老年代) 混合收集(Mixed GC):收集整个新生代及老年代的垃圾收集 (G1 GC会混合回收, region区域回收)

整堆收集(Full GC):收集整个java堆和方法区的垃圾收集器

为什么要废弃永久代,引入元空间?

  1. 永久代需要存放类的元数据、静态变量和常量等。 它的大小不容易确定 ,容易造成永久 代内存溢出。
  2. 永久代会为GC带来不必要的复杂度,并且回收效率偏低。

image-20230718131652854

类加载器将Class文件加载到内存之后,将类的信息存储到方法区中。

方法区中存储的内容:

  • 类信息( 类型信息、域信息、方法信息)
  • 运行时常量池
jinfo -flag MetaspaceSize 进程号 #查看Metaspace 最大分配内存空间

main函数,设置虚拟机参数为"-XX:+TraceClassLoading"来获取类加载信息。运行一下:

 [Opened E:\developer\JDK8\JDK\jre\lib\rt.jar]
[Loaded java.lang.Object from E:\developer\JDK8\JDK\jre\lib\rt.jar]
[Loaded java.io.Serializable from E:\developer\JDK8\JDK\jre\lib\rt.jar]
[Loaded java.lang.Comparable from E:\developer\JDK8\JDK\jre\lib\rt.jar]
[Loaded java.lang.CharSequence from E:\developer\JDK8\JDK\jre\lib\rt.jar]
[Loaded java.lang.String from E:\developer\JDK8\JDK\jre\lib\rt.jar]
[Loaded java.lang.reflect.AnnotatedElement from E:\developer\JDK8\JDK\jre\lib\rt.jar]
......

引用计数算法: 无法检测出循环引用

但是在java程序中这两个对象仍然会被回收,因为java中并没有使用引用计数算法。

可达性分析算法:当一个对象到任何GC Roots都没有引用链时,则表明对象“不可 达” ,即该对象是不可用的。

GC Roots的对象包括下面几种:

  • 栈帧中的局部变量表中的reference引用所引用的对象
  • 方法区中static静态引用的对象
  • 方法区中final常量引用的对象
  • 被同步锁(synchronized关键字) 持有的对象。
  • 本地方法栈中JNI(Native方法)引用的对象

image-20230718162020480

垃圾收集算法

  • 标记-清除算法

    • 内存空间的碎片化问题
    • 执行效率不稳定,导致标记和清除两个过 程的执行效率都随对象数量增长而降低
  • 标记-复制算法

    • 需要提前预留一半的内存区域用来存放存活的对象
    • 出现存活对象数量比较多的时候,需要复制较多的对象,成本上升,效率降低
    • 如果99%的对象都是存活的(老年代),那么老年代是无法使用这种算法的。
  • 标记-整理算法

    • 标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法, 而后者是移动式的

垃圾收集器

image-20230718163423167

JDK8中默认使用组合是: Parallel Scavenge GC 、ParallelOld GC

JDK9默认是用G1为垃圾收集器 JDK14 弃用了: Parallel Scavenge GC 、Parallel OldGC

JDK14 移除了 CMS GC

Serial收集器

image-20230718163634814

ParNew 收集器

image-20230718163707541

Parallel Scavenge收集器

复制算法的并行多线程收集器,多线程回收。

自适应调节策略,自动指定年轻代、Eden、Suvisor区的比例

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本, 支持多线程并发收集, 基于标记-整理算法实现

应用场景: JDK1.6及之后用来代替老年代的Serial Old收集器; 特别是在Server模式,多CPU的情况下; 这样在注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge加Parallel Old收集器的"给力"应用组合;

CMS 收集器

整个过程分4个步骤:

1)初始标记

  1. 并发标记
  2. 重新标记
  3. 并发清除 中 初始标记 和 重新标记 都需要stopTheWorld

CMS收 集器使用的算法是标记-清除算法实现的;

注重服务器的响应速度,尽可能缩短 垃圾收集时用户线程的停顿时间。

三色标记
  • 白色 :尚未访问过。
  • 黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。
  • 灰色:本对象已访问过,但是本对象 引用到 的其他对象尚未全部访问完。全部访问后,会转换为黑色。

标记期间应用线程还在继续跑, 对象间的引用可能发生变化 , 多标 和 漏标 的情况就有可能发生。

多标

本应该回收 但是 没有回收到的内存,被称之为“浮动垃圾”。只是 需要等到下一轮垃圾回收中才被清除。

漏标

指向改变,使为白色的G被回收,导致D引用时为空 报错

image-20230718165000105

G1收集器

  1. G1把内存划分为多个独立的区域Region
  2. G1仍然保留分代思想,保留了新生代和老年代,但他们不再是物理隔离,而是一部分Region的集合
  3. G1能够充分利用多CPU、多核环境硬件优势,尽量缩短STW
  4. G1整体采用标记整理算法,局部是采用复制算法, 不会产生内存碎片
  5. G1的停顿可预测,能够明确指定在一个时间段内,消耗在垃圾收集上的时间不超过设置时间
  6. G1跟踪各个Region里面垃圾的价值大小,会维护一个优先列表,每次根据允许的时间来回收价值最大的区域,从而保证在有限事件内高效的收集垃圾

把连续的Java堆划分为多个独立区域(Region) , 每一 个Region都可以 根据需要, 扮演新生代的Eden空间、 Survivor空间, 或者老年代空间

1使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实 际大小而定,为2的N次幂,即1MB, 2MB, 4MB, 8MB, 16MB,32MB。

2 虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region (不需要连 续)的集合。通过Region的动态分配方式实现逻辑上的连续。

3 G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如 果超过1 .5个region,就放到H。一般被视为老年代.

G1 GC过程

初始标记 :和CMS一样只标记GC Roots直接关联的对象 并发标记 :进行GC Roots Traceing过程 最终标记 :修正并发标记期间,因程序运行导致发生变化的那一部分对象 筛选回收 :根据时间来进行价值最大化收集

jstack

命令可以定 位线程出现长时间卡顿的原因,例如死锁,死循环等

jmap

jmap -heap 11666
输出堆的详细信息

高并发

高并发处理

秒杀:

  1. 页面静态化
  2. CDN加速
  3. 缓存
  4. mq异步处理
  5. 限流
  6. 分布式锁

读多写少,大部分都在查询库存等,使用redis缓存,商品数据预热,防止缓存击穿。查库时添加分布式锁。

布隆过滤器,缓存中数据有更新,则要及时同步到布隆过滤器中。所以布隆过滤器绝大部分使用在缓存数据更新很少的场景中。

使用lua脚本

 StringBuilder lua = new StringBuilder();
  lua.append("if (redis.call('exists', KEYS[1]) == 1) then");
  lua.append("    local stock = tonumber(redis.call('get', KEYS[1]));");
  lua.append("    if (stock == -1) then");
  lua.append("        return 1;");
  lua.append("    end;");
  lua.append("    if (stock > 0) then");
  lua.append("        redis.call('incrby', KEYS[1], -1);");
  lua.append("        return stock;");
  lua.append("    end;");
  lua.append("    return 0;");
  lua.append("end;");
  lua.append("return -1;");

分布式锁:

使用redis的set命令,它可以指定多个参数。是原子操作。

String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
    return true;
}
return false;

在释放锁的时候,只能释放自己加的锁,不允许释放别人加的锁。

这里为什么要用requestId,用userId不行吗?

答:如果用userId的话,假设本次请求流程走完了,准备删除锁。此时,巧合锁到了过期时间失效了。而另外一个请求,巧合使用的相同userId加锁,会成功。而本次请求删除锁的时候,删除的其实是别人的锁了。

当然使用lua脚本也能避免该问题:

if redis.call('get', KEYS[1]) == ARGV[1] then 
 return redis.call('del', KEYS[1]) 
else 
  return 0 
end

它能保证查询锁是否存在和删除锁是原子操作。

使用redisson。

8.1 消息丢失问题

加一张消息发送表。

image-20230711225113799

限流

redis

Redis 使用了 SDS(Simple Dynamic String)。用于存储字符串和整型数据

buf[] 的长度=len+free+1 SDS的优势:

1、SDS 在 C 字符串的基础上加入了 free 和 len 字段,获取字符串长度:SDS 是 O(1),C 字符串是 O(n)。

buf数组的长度=free+len+1 2、 SDS 由于记录了长度,在可能造成缓冲区溢出时会自动重新分配内存,杜绝了缓冲区溢出。 3、可以存取二进制数据,以字符串长度len来作为结束标识 C: \0 空字符串 二进制数据包括空字符串,所以没有办法存取二进制数据 SDS : 非二进制 \0

二进制: 字符串长度 可以存二进制数据。

Redis事件处理机制采用单线程的Reactor* 模式 ,属于I/O*多路复用的一种常见模式。

RDB(Redis DataBase),是redis默认的存储方式,RDB方式是通过快照( snapshotting )完成 的。

  1. 符合自定义配置的快照规则
  2. 执行save或者bgsave命令
  3. 执行flushall命令
  4. 执行主从复制操作 (第一次)

RDB执行流程

image-20230713153233646

  1. Redis父进程首先判断:当前是否在执行save,或bgsave/bgrewriteaof(aof文件重写命令)的子 进程,如果在执行则bgsave命令直接返回。
  2. 父进程执行fork(调用OS函数复制主进程)操作创建子进程,这个过程中父进程是阻塞的,Redis 不能执行来自客户端的任何命令。
  3. 父进程fork后,bgsave命令返回”Background saving started”信息并不再阻塞父进程,并可以响 应其他命令。
  4. 子进程创建RDB文件,根据父进程内存快照生成临时快照文件,完成后对原有文件进行原子替换。 (RDB始终完整)
  5. 子进程发送信号给父进程表示完成,父进程更新统计信息。
  6. 父进程fork子进程后,继续工作。

优点

RDB是二进制压缩文件,占用空间小,便于传输(传给slaver) 主进程fork子进程,可以最大化Redis性能,主进程不能太大,复制过程中主进程阻塞

缺点

不保证数据完整性,会丢失最后一次快照以后更改的所有数据

AOF

Redis 将所有对数据库进行过写入的命令(及其参数) (RESP)记录到 AOF 文件。

AOF会记录过程,RDB只管结果。

  • 原理

Redis 目前支持三种 AOF 保存模式,它们分别是:

AOF_FSYNC_NO :不保存。 AOF_FSYNC_EVERYSEC :每一秒钟保存一次。(默认) AOF_FSYNC_ALWAYS :每执行一个命令保存一次。(不推荐)

Redis可以在 AOF体积变得过大时,自动地在后台(Fork子进程)对 AOF进行重写,子进程进行 AOF 重写期间,主进程可以继续处理命令请求

重写后的新 AOF文 件包含了恢复当前数据集所需的最小命令集合。

RDB与AOF对比

  1. RDB存某个时刻的数据快照,采用二进制压缩存储,AOF存操作命令,采用文本存储(混合)
  2. RDB性能高、AOF性能较低
  3. RDB在配置触发状态会丢失最后一次快照以后更改的所有数据,AOF设置为每秒保存一次,则最多 丢2秒的数据
  4. Redis以主服务器模式运行,RDB不会保存过期键值对数据,Redis以从服务器模式运行,RDB会保 存过期键值对,当主服务器向从服务器同步时,再清空过期键值对。

AOF写入文件时,对过期的key会追加一条del命令,当执行AOF重写时,会忽略过期key和del命令。

在数据还原时

有rdb+aof 则还原aof,因为RDB会造成文件的丢失,AOF相对数据要完整。 只有rdb,则还原rdb。

事务命令:

multi:用于标记事务块的开始,Redis会将后续的命令逐个放入队列中,然后使用exec原子化地执行这个 命令队列

exec:执行命令队列

discard:清除命令队列

watch:监视

key unwatch:清除监视key

  • Redis不支持事务回滚(为什么呢)

1、大多数事务失败是因为语法错误或者类型错误,这两种错误,在开发阶段都是可以预见的

2、Redis为了性能方面就忽略了事务回滚。 (回滚记录历史版本)

EVAL命令

EVAL script numkeys key [key ...] arg [arg ...]

命令说明:

script参数:是一段Lua脚本程序,它会被运行在Redis服务器上下文中,这段脚本不必(也不应该)

定义为一个Lua函数。

numkeys参数:用于指定键名参数的个数。

key [key ...] 参数: 从EVAL的第三个参数开始算起,使用了numkeys个键(key),表示在脚本中 所用到的那些Redis键(key),这些键名参数可以在Lua中通过全局变量KEYS数组,用1为基址的形 式访问( KEYS[1] , KEYS[2] ,以此类推)。

arg [arg ...] 参数:可以在Lua中通过全局变量ARGV**数组访问,访问的形式和KEYS变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)。

例如:

eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
使用redis-cli直接执行lua脚本。
return redis.call('set',KEYS[1],ARGV[1])
./redis-cli -h 127.0.0.1 -p 6379 --eval test.lua name:6 , 'caocao' #,两边有空格
local key=KEYS[1]
local list=redis.call("lrange",key,0,-1);
return list;
./redis-cli --eval list.lua list

管道(pipeline),事务和脚本(lua)三者的区别

三者都可以批量执行命令

管道无原子性,命令都是独立的,属于无状态的操作。

事务和脚本是有原子性的,其区别在于脚本可借助Lua语言可在服务器端存储的便利性定制和简化操作

脚本的原子性要强于事务,脚本执行期间,另外的客户端 其它任何脚本或者命令都无法执行,脚本的执行时间应该尽量短,不能太耗时的脚本

主从复制

可以通过执行slaveof(Redis5以后改成replicaof)或者在配置文件中设置 slaveof(Redis5以后改成replicaof)来开启复制功能。

从redis配置,修改从服务器上的 redis.conf 文件:

replicaof 127.0.0.1 6379

全量同步

同步快照阶段: Master 创建并发送快照RDB给 Slave , Slave 载入并解析快照。 Master 同时将
此阶段所产生的新的写命令存储到缓冲区。
同步写缓冲阶段: Master 向 Slave 同步存储在缓冲区的写操作命令。
同步增量阶段: Master 向 Slave 同步写操作命令。

大key的影响:

大key会大量占用内存,在集群中无法均衡 Redis的性能下降,主从复制异常 在主动删除或过期删除时会操作时间过长而引起服务阻塞。

大key的处理:

优化big key的原则就是string减少字符串长度,list、hash、set、zset等减少成员数。

  1. string类型的big key,尽量不要存入Redis中,可以使用文档型数据库MongoDB或缓存到CDN上。如果必须用Redis存储,最好单独存储,不要和其他的key一起存储。采用一主一从或多从。
  2. 单个简单的key存储的value很大,可以尝试将对象分拆成几个key-value, 使用mget获取值,这样 分拆的意义在于分拆单次操作的压力,将操作压力平摊到多次操作中,降低对redis的IO影响。
  3. hash, set,zset,list 中存储过多的元素,可以将这些元素分拆。(常见)
以hash类型举例来说,对于field过多的场景,可以根据field进行hash取模,生成一个新的key,例如原 来的
hash_key:{filed1:value, filed2:value, filed3:value ...},可以hash取模后形成如下 key:value形式
hash_key:1:{filed1:value}
hash_key:2:{filed2:value}
hash_key:3:{filed3:value}
...
取模后,将原先单个key分成多个key,每个key filed个数为原先的1/N
  1. 删除大key时不要使用del,因为del是阻塞命令,删除时会影响性能。

  2. 使用 lazy delete

    unlink命令

删除指定的key(s),若key不存在则该key被跳过。但是,相比DEL会产生阻塞,该命令会在另一个线程中 回收内存,因此它是非阻塞的。 这也是该命令名字的由来:仅将keys从key空间中删除,真正的数据删 除会在后续异步操作。

redis> SET key1 "Hello"
"OK"
redis> SET key2 "World"
"OK"
redis> UNLINK key1 key2 key3
(integer) 2

JUC

blog.csdn.net/xt199711/ar…

中断和虚假唤醒是可能的,并且该方法应该始终在循环中使用。这种现象叫做【虚假唤醒】。所谓虚假唤醒,就是 wait()方法的一个特点,总结来说 wait() 方法使线程在哪里睡就在哪里醒。

synchronized关键字

image-20230716154923312

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁。只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

  • 重量级锁:

    底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的 上下文切换,成本较高,性能比较低。

  • 轻 量 级 锁

    线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优 化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次 修改都是CAS操作,保证原子性

  • 偏 向 锁

    一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一 次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断 mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令

对应多锁重入,重入一次(轻量级锁),重入多次(偏向锁)

cas

B线程工作内存写入主内存使用cas,发现值不一样,重新获取一份内存再进行cas操作,直到cas成功退出while死循环

比较典型的AQS实现类ReentrantLock,它默认就是非公平锁,新的线程与队 列中的线程共同来抢资源

/Users/zhouzekun/data/docker/fileBrowser

docker run -d -v /Users/zhouzekun/data/docker/fileBrowser/data:/srv -v /Users/zhouzekun/data/docker/fileBrowser/config/filebrowserconfig.json:/etc/config.json -v /Users/zhouzekun/data/docker/fileBrowser/config/database.db:/etc/database.db -p 8080:80 filebrowser/filebrowser

并发集合

并发集合 CopyOnWriteArrayList

底层原理为 写时复制技术

  • 读的时候并发(多个线程操作)
  • 写的时候独立,先复制相同的空间到某个区域,将其写到新区域,旧新合并,并且读新区域(每次加新内容都写到新区域,覆盖合并之前旧区域,读取新区域添加的内容)
// 复制一个与原来的列表一样的列表
	Object[] newElements = Arrays.copyOf(elements, len + 1);
// 将新加入的元素放到列表末尾

ConcurrentHashMap

并非锁住整个方法,而是通过原子操作和局部加锁的方法保证了多线程的线程安全,且尽可能减少了性能损耗

ConcurrentHashMap原理详解(太细了)

总结 做插入操作时,首先进入乐观锁, 然后,在乐观锁中判断容器是否初始化, 如果没初始化则初始化容器, 如果已经初始化,则判断该hash位置的节点是否为空,如果为空,则通过CAS操作进行插入。 如果该节点不为空,再判断容器是否在扩容中,如果在扩容,则帮助其扩容。 如果没有扩容,则进行最后一步,先加锁,然后找到hash值相同的那个节点(hash冲突), 循环判断这个节点上的链表,决定做覆盖操作还是插入操作。 循环结束,插入完毕。


//ConcurrentHashMap的get()方法是不加锁的,方法内部也没加锁。

ConcurrentHashMapget()方法是不加锁的,为什么可以不加锁?因为tablevolatile关键字修饰,保证每次获取值都是最新的。

多线程环境下,更新少,查询多时使用的话,性能比较高


synchronized和ReentrantLock都是可重入的

zookeeper实现的分布式锁

spring生命周期

Spring IOC详解及Bean生命周期详细过程,看完直接吊打面试官!

image-20230716073038892

循环依赖在spring中是允许存在,spring框架依据三级缓存已经解决了大部

分的循环依赖

1一级缓存:单例池,缓存已经经历了完整的生命周期,已经初始化完成的

bean对象

2二级缓存:缓存早期的bean对象(生命周期还没走完) 3三级缓存:缓存的是ObjectFactory,表示对象工厂,用来创建某个对象的

官试面:那具体解决流程清楚吗?

选候人 :

第一,先实例A对象,同时会创建ObjectFactory对象存入三级缓存 singletonFactories 第二,A在初始化的时候需要B对象,这个走B的创建的逻辑

第三,B实例化完成,也会创建ObjectFactory对象存入三级缓存 singletonFactories 第四,B需要注入A,通过三级缓存中获取ObjectFactory来生成一个A的对象

同时存入二级缓存,这个是有两种情况,一个是可能是A的普通对象,另外

一个是A的代理对象,都可以让ObjectFactory来生产对应的对象,这也是三

级缓存的关键

第五,B通过从通过二级缓存earlySingletonObjects 获得到A的对象后可以正

常注入,B创建成功,存入一级缓存singletonObjects

第六,回到A对象初始化,因为B对象已经创建完成,则可以直接注入B,A

创建成功存入一次缓存singletonObjects

第七,二级缓存中的临时对象A清除

seata

XA模式:

image-20230716135932055

AT:

高可用,推荐的方式。平时开发用的多。可以防止事务阻塞。

image-20230716135705194

TCC模式

image-20230716135842166

netty

image-20230719061658378

每个 BossGroup 中的线程循环执行以下三个步骤

  • 轮训注册在其上的 ServerSocketChannel 的 accept 事件(OP_ACCEPT 事件)
  • 处理 accept 事件,与客户端建立连接,生成一个 NioSocketChannel,并将其注册到 WorkerGroup 中某个线程上的 Selector 上
  • 再去以此循环处理任务队列中的下一个事件

每个 WorkerGroup 中的线程循环执行以下三个步骤

  • 轮训注册在其上的 NioSocketChannel 的 read/write 事件(OP_READ/OP_WRITE 事 件)
  • 在对应的 NioSocketChannel 上处理 read/write 事件
  • 再去以此循环处理任务队列中的下一个事件

分布式

juejin.cn/post/725600…

分布式锁:

分布式锁两种实现方式:

  1. 基于缓存(Redis等)实现分布式锁
  • 获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自 动释放锁,锁的value值为一个随机生成的UUID, 释放锁的时候进行判断。
  • 获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
  • 释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放。

SETNX :set一个key为value的字符串,返回1;若key存在,则什么都不做,返回0。 expire: 为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。 delete :删除key

  1. ZooKeeper是一个为分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树 结构,规定同一个目录下只能有一个唯一文件名, 基于ZooKeeper实现分布式锁的步骤如下:
  • 创建一个目录mylock
  • 线程A想获取锁就在mylock目录下创建临时顺序节点
  • 获取mylock目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前 线程顺序号最小,获得锁
  • 线程B获取所有节点,判断自己不是最小节点,设置监听比自己次小的节点
  • 线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是不是最小的节点,如果 是则获得锁

项目问题:

  1. 1

    登录失败

  1. 封装justauth的starter 实现google登录。
  2. 财务期初期末等数据导出,OOM排查,封装导出模块的依赖包,实现分页查询导出,使用模版设计模式。
  3. 黑名单开发,添加限流规则,使用过滤器去过滤拦截请求。

数据中台数据初始化:

image-20230716201633844

cpu占用过高问题

1.使用Process Explorer工具找到cpu占用率较高的线程

2.在thread卡中找到cpu占用高的线程id

3.线程id转换成16进制 4.使用jstack -l 查看进程的线程快照

5.线程快照中找到指定线程,并分析代码

jmap生成dump文件

jmap -dump:live,format=b,file=dump.bin 11666

面试题:

1. BeanFactory ApplicationContext 有什么区别

BeanFactory 可以理解为含有 bean 集合的工厂类。BeanFactory 包含了种 bean 的定义, 以便在接收到客户端请求时将对应的 bean 实例化。 > BeanFactory 还能在实例化对象的时生成协作类之间的关系。此举将 bean 自身与 bean 客 户端的配置中解放出来。

BeanFactory 还包含了 bean 生命周期的控制,调用客户端的初始 化方法(initialization methods)和销毁方法(destruction methods)。

从表面上看,application context 如同 bean factory 一样具有 bean 定义、bean 关联关 系的设置,根据请求分发 bean 的功能。但 application context 在此基础上还提供了其他 的功能。 > 提供了支持国际化的文本消息

统一的资源文件读取方式 > 已在监听器中注册的 bean 的事件