(一)java集合类
- 在java集合类中最常用的是Collection和Map的接口实现类。Collection又分为List和Set两类接口,List的实现类有ArrayList、LinkedList、Vector、Stack,Set接口的实现类有HashSet、TreeSet,而Map的实现类主要有HashMap、ConcurrentHashMap、TreeMap。
- ArrayList是容量可以改变的非线程安全集合(可以使用Collections.synchronizedList方法实现线程安全),内部使用数组进行存储。ArrayList支持对元素的快速随机访问,但是插入和删除时速度通常会很慢,因为这个过程需要移动其他元素。ArrayList的默认大小为10,在不传入指定的列表大小时,默认使用空列表。ArrayList在每次添加数据时都会检查空间是否足够,若不足就会按原数组空间的1.5倍扩容(向下取整),最后会将新的数组空间大小和插入数据后需要的空间大小作比较,取两者之前的更大者。之后就会将旧数组整体拷贝进新的数组空间。
- HashMap的默认容量值为16,默认负载因子为0.75,默认阈值为12。HashMap的容量不会在new的时候就分配,而是在put的时候才进行分配。HashMap的实际容量为比传入容量参数大的2的幂,而ConcurrentHashMap实际容量为比(传入容量参数除4/3后)大的2的幂,此后每次扩容都是增加2倍。HashMap的容量为2的幂的主要是因为HashMap在计算槽位的时候使用的算法是(n-1)& hash,当n为2的幂时元素可以更加均匀的散列。HashTable是HashMap的线程安全版本,两者主要区别在于HashTable在一些关键的函数中增加了synchronized关键字,由于性能不佳目前已经被ConcurrentHashMap淘汰了。HashMap是线程不安全的,在java8以前甚至可能出现由于多线程resize导致的循环链表,线程不安全的两个小例子:两个线程同时对同一桶位插入数据可能导致某个线程的数据被覆盖、某个线程在执行resize时另一线程可能读不到数据。
- HashMap的key和value都可以为null,key为null的元素会被散列到数组的第一个元素,但在使用stream的Collectors.toMap方法时HashMap的value不可以为null,且key不可以重复,否则会抛出异常。另外不同于HashMap,ConcurrentHashMap的key和value都不能为null。
- java8对HashMap的改进?
主要有两大改动
(1)在数组桶的链表长度超过8时,HashMap通过红黑树替换链表。
(2)在HashMap扩容时不再重新对每个节点计算桶位,而是通过(节点的hash值&旧数组容量 == 0)来形成两条新的链表,分别插在原桶位和旧数组容量+原桶位。 - 树的基础知识:
高度:从某节点出发到叶子节点的简单路径上边的数量被称为该节点的“高度”
重要的二叉树:平衡二叉树、二叉查找树、红黑树
平衡二叉树:树及所有子树的左右高度差不超过1
二叉查找树:对于树的任意节点而言它的左子树的所有节点值都小于它,而他的右子树的所有节点值都大于它。它主要有前序遍历、中序遍历、后序遍历三种遍历方式。随着数据不停的增加或删除,二叉查找树容易失衡
红黑树:红黑树是一种可以自平衡的二叉查找树。红黑树在每个节点上增加了颜色属性,可以为红色或黑色,红黑树通过按规则着色和特定的旋转来保持自身的平衡,它新增、删除、查找的最坏时间复杂度均为O(logN)。相对于其他的自平衡树例如AVL树,红黑树并不严格保证时左右子树的高度差超过1,这使得红黑树在删除时能够更快的恢复平衡,成本比较低,所以面对频繁的插入删除时,红黑树更适合,而面对低频修改,大量查询时AVL树更合适 - Comparable接口和Comparator接口的区别? (面试未问)
如果要使用Comparable接口,就必须实现该接口并重写compareTo方法。而Comparator接口可以在类外实现,并可以将其实现对象传入到Collections.sort或Arrays.sort方法中以实现排序。Comparator接口的使用体现了基于开闭原则的设计。 - 集合中的hashCode和equals? (面试未问)
hashCode和equals用来标识对象,两者协同工作来判断对象是否相等,当hashCode的值相同时,还需要调用equals进行一次值比较。任何时候在覆写equals时一定要同时覆写hashCode,因为Map、Set等集合都是同时使用两者来判断对象是否相同的。hashCode是根据对象地址进行相关计算得到的int类型数值。在做对象间的比较时,尽量使用Objects.equals方法来避免空指针。 - 集合的fail-fast机制? (面试未问)
fail-fast是一种在集合遍历时的错误检测机制,如果在遍历的过程中出现了意料之外的修改就会抛出ConcurrentModificationException异常,这种机制经常出现在多线程环境下。线程会维护一一个modCount用来记录集合已经修改的次数,在遍历集合时会时时检查modCount的数值是否发生变化,若发生变化则抛出异常。 - ConcurrentHashMap知识:
在java8以前ConcurrentHashMap通过锁分段的思想将整个hashMap分为16个segment,每个segment负责一部分数组元素,并通过reentrantLock锁来保证每个segment的数据安全。而在java8后ConcurrentHashMap取消了segment,大量的使用了volatile、cas等技术进一步减少了锁竞争造成的性能影响。java8后ConcurrentHashMap有三点主要的改动:(1)取消分段锁机制,进一步降低冲突概率、(2)引入红黑树、(3)使用了更加优秀的方式统计元素的数量。
get操作逻辑:计算出key的hash值并计算出槽位,然后通过getObjectVolatile获取数该槽位的元素,并比较hash值和key值,若相同则返回,如果槽位内节点的hash值小于0则说明正在进行扩容,则通过ForwardingNode的find函数去新的数组nextTable中进行查找。否则就遍历单链表查找相应节点。
put操作逻辑:首先检查核心的Node<K,V>[] table是否已经初始化,如果没有初始化,则利用CAS将sizeCtl的值置为-1进行初始化。查询key相应的槽位是否为 null,若为null直接通过CAS将键值对放入槽位。如果相应的槽位已经有节点,并且其hash值为-1,则表示正在进行扩容,则当前线程帮忙进行扩容。否则通过synchronized锁住槽位内的节点即链表的头结点,然后遍历链表,寻找是否有hash值及key值相同的节点,若有则将value设置进去,否者创建新的节点加入链表。通过addCount函数更新ConcurrentHashMap键值对的数量,并检查是否需要进行扩容。
扩容操作逻辑:首先新建一个两倍长度的数组nextTable。初始化ForwardingNode节点,其中保存了新数组nextTable的引用,在处理完每个槽位节点后当做占位节点,表示该槽位已经处理过了。通过倒序的方式为线程分配需要处理数组元素个数(默认每个线程16个元素)。每个线程处理自己负责的数组元素,具体逻辑和HashMap基本相应,处理完的元素的位置上会放入ForwardingNode节点。
计数方法:代码里的变量baseCount用于在无竞争环境下记录元素的个数,每当插入元素或删除元素时都会利用CAS更新键值对个数。当有线程竞争时,会使用CounterCell数组来计数,每个ConuterCell都是一个独立的计数单元。线程可以通过ThreadLocalRandom.getProbe() & m找到属于它的CounterCell进行计数。这种方法能够降低线程的竞争,相比所有线程对一个共享变量不停进行CAS操作性能上要好很多。这里的CounterCell数组初始容量为2,最大容量是机器的CPU数。
(二)java并发包
- 同步框架AQS介绍?(面试未问)
AQS是构建锁和其他同步组件基础框架,内部维护一个整形字段state来表示同步状态,同时维护了一个双向链表(FIFO的CLH同步队列),链表的每个节点都表示一个线程,包含了线程引用、节点状态、前驱节点引用、后驱节点引用。整数state在不同的同步组件中可以表示不同的状态,例如:reentrantLock可以用它来表示锁的重入次数、semaphore用它来表示剩余的许可数量,futureTask用来表示任务的状态(尚未开始、正在运行、已完成、已取消)。AQS是一个抽象模板类,内部实现了大量的构建同步器通用方法,通过继承实现AQS的相应接口就可以构建出很多同步组件,例如:reentrantLock、semaphore、futureTask等组件都通过了继承AQS的内部类来实现。除此之外AQS还实现了Condition接口,通过ConditionObject实现了条件队列(单向链表)。Condition条件队列需要配合ReentrantLock一起使用,当线程获取到ReentrantLock锁后,可以通过await进入条件队列并释放锁,或者通过singinal方法唤醒条件队列等待时间最久的线程并加入到等待队列中,条件队列使用和同步队列相同的节点类型。 - reetrantLock的实现方式?
ReetrantLock提供了nonfairSync和fairSyn两个实例,分别用于实现非公平锁和公平锁,两者都继承于AQS。ReetrantLock主要提供了lock和unlock两个方法。
(1)非公平锁的lock方式实现:(1)首先直接通过CAS尝试将state从0设置为1,若成功则说明获取到了锁,保存当前线程引用以实现可重入。(2)否则获取state若值为0,则再次尝试将state设置为1,成功则同样保存当前线程。(3)否则检查获取锁的是否就是当前线程,如果是就增加state值,记录重入次数。(4)如果都不满足,则将当前线程封装成Node放入同步队列中,并将线程挂起。公平锁的不同之处在于:没有(1)这一步,另外在执行(2)时需检测同步队列是否有等待中的线程,若没有才能执行。
(2)非公平锁和公平锁的unlock方法完全相同: 获取state值,通过cas减去释放锁定的个数,如果值为0,则释放锁,并将同步队列中的第一个节点内的线程唤醒(LockSupport的unpark方法)。 - reetrantLock和synchronize的差别?
reetrantLock和synchronize都是可重入的独占锁,且在加锁和内存上两者语义完全相同。reetrantLock还提供了一些高级的功能:可定时/可轮询/可中断的锁获取操作、公平队列、非块状结构的锁等,同时reetrantLock更加灵活且性能稍稍优于synchronize。但是reetrantLock的操作更加复杂,加锁和释放都需要手动操作,存在可能忘记释放锁的风险。reetrantLock的死锁检测更加复杂,在调试reetrantLock导致的死锁问题时会更加复杂。最后由于synchronize是jvm的内置属性字段,未来会随着版本持续优化,而reetrantLock的优化的可能性不大。所以在synchronize不能满足我们的高级需求时可以使用reetrantLock,否则最好还是使用synchronize。 - countDownLatch的实现方式?
CountDownLatch主要用来保障某批线程的执行需要等待另一批线程执行完成后方可执行,例如老板巡视工人完成工作情况,需要等待三个工人全部完成好工作以后才能巡视。首先会获取要求同时开始某个动作的线程数量,将数量值设置给state。当state的值大于0时,调用CountDownLatch的await方法,相应线程会被挂起并放入AQS的同步队列中,直到调用countdown()函数将相应的state的值减为0,被挂起的线程才会被重新唤醒。和它比较相似的是CyclicBarrier,CyclicBarrier会确保所有线程都执行到某个点后,才可以继续往下执行。 - futureTask的实现方式?(面试未问)
FutureTask利用了AQS的state来表示不同状态:主要有未开始、进行中、已完成。FutureTask继承了Runnable接口,对线程实际需要执行的逻辑进行了一层包装,当执行了线程实际逻辑获取到结果后,会更新state的值来标识任务已完成并将结果保存起来,同时会唤醒等待结果的线程。其他需要结果的线程通过get接口获取结果,如果任务已经执行完就直接获取相应结果,如果任务尚未执行完就挂起等待,知道执行任务的线程将其唤醒。 - volatile的实现方式?
volatile具备两个关键的特性:一个是保证变量对所有线程的可见性,另一个是禁止指令重排序(包括cpu层面的指令乱序)。volatile在抽象逻辑层面上通过“内存栅栏”实现,而实际字节码层面通过lock指令实现:这个命令会将变量数据立即刷到主内存中,并利用cpu总线嗅探机制使其他线程高速缓存内的cacheline失效(cacheline是cpu高速缓存cache的基本读写单位),使用时必须重新到主内存Memory读取。同时因为需要立刻刷数据到内存中,那么volatile变量操作前的所有操作都需要完全执行完成,这样进而也保证了volatile变量写操作前后不会出现重排序。通常volatile变量的读写效率和普通变量没有多大差别,但在volatile变量并发访问冲突非常频繁的情况下可能造成性能的下降,具体的例子及解决方案可以百度“伪共享”问题。 - synchronize的实现方式?
synchronized底层实现依赖于jvm用C++实现的管程(ObjectMonitor),管程是一种类似于信号量的程序结构,它封装了同步操作并对进程隐蔽了同步细节,其整体实现逻辑和ReentrantLock很相似。我们在使用synchronized时通常有两种方式:1.修饰方法、2.修饰代码块,其实两者差别不大,本质上都是同步代码块。在虚拟机层面上,当用synchronized修饰方法时,class文件中会在方法表中为相应方法增加ACC_SYNCHRONIZED访问标志,用以标识该方法为同步方法。而当用synchronized修饰代码块时,会在相应代码段字节码的前后分别插入monitorenter和monitorexit字节码指令,用以表示该段代码需要同步。
在代码即将进入同步代码块的时候,如果此时同步对象还没有被锁定,虚拟机先会在栈帧中建立一个名为锁记录的空间(Lock Record)用于存储对象目前的mark word拷贝数据。然后虚拟机会把对象的mark word数据拷贝到锁记录空间中,并尝试通过CAS操作将对象的mark word数据更新为指向锁记录空间的引用,如果成功了就说明获取到了锁。如果出现两个以上的线程争用同一个锁时,那么轻量级锁就会膨胀为重量级锁,后面等待锁的线程都会进入阻塞状态。
在使用synchronized时,我们可以把synchronized修饰的方法或代码段想象成一段不可以并发访问的临界区资源,这种资源必须独占使用。而如何实现独占访问呢?我们可以想象每个对象都有把独占锁,我们需要借助某个对象的独占锁来访问这种临界区资源,而同一个锁某个时刻只能被一个线程所获取,其他线程都得等待锁的释放。用synchronized修饰的实例方法(public synchronized void method())默认使用当前对象(this)的锁,而用synchronized修饰的静态方法(public synchronized static void method())默认使用当前对象对应的Class对象锁,他们分别对应于synchronized修饰代码块中的synchronized(object)和synchronized(Object.class)。Class对象存在于方法区中,具有全局唯一性,在一个jvm实例中一个Class对象只有一把锁,所有使用该Class对象作为锁的静态方法或代码块,执行前都必须先获得该Class对象锁。而同一个Class可以有很多实例对象,每个实例对象都有一个自己的锁,使用实例对象A锁的线程和使用实例对象B锁的线程间不存在竞争关系。 - 线程池的实现原理?(重要!面试常问)
ThreadPoolExecutor是线程池的主要实现类,它有以下几个构造参数:
(1)corePoolSize:常驻核心线程数,如果其值为0,则任务执行完成后,没有任何请求时会销毁线程池的线程。如果值大于0,没有任务时核心线程也不会被销毁。
(2)maximumPoolSize:线程池能够容纳的最大并行线程数,如果与corePoolSize相等,即为固定大小线程池。
(3)keepAliveTime:线程池中线程的空闲时间,当线程数量大于corePoolSize时才会起作用,当线程的空闲时间到达keepAliveTime时,线程会被销毁直到只剩下corePoolSize个线程为止。
(4)timeUnit:表示空闲时间单位。
(5)workQueue:需要使用的缓存队列,当核心线程无法处理新来的任务时,就会将任务放入到缓存队列workQueue中,当缓存队列存满后,如果还有需要处理,那么线程池就会继续创建新的线程,直到线程数量达到maximumPoolSize。
(6)threadFactory:用来生产线程的工厂。
(7)rejectedExecutionHandler:用来执行拒绝策略的对象。当缓存队列存满后,且线程数量已达到了maximumPoolSize时,就会执行拒绝策略。
线程池在创建后,当线程数量小于corePoolSize时,每来一个任务后就会创建一个线程来执行该任务,直到线程数达到corePoolSize。之后若新来的任务没有核心线程能够处理,就会将任务存入阻塞队列workQueue中,直到阻塞队列workQueue存满。若阻塞队列workQueue存满后,仍有大量的任务无法处理,就会继续增加线程处理任务,直到线程数量达到maximumPoolSize。若此时仍有无法处理的任务,就会执行任务的拒绝策略。ThreadPoolExecutor提供了4个公开的内部静态类:
(1)AbortPolicy(默认策略):丢弃任务并抛出RejectedExecutionException异常。
(2)DiscardPolicy:丢弃任务且不抛出异常。
(3)DiscardOldestPolicy:抛弃等待最久的任务,然后把当前任务加入队列中。
(4)CallerRunsPolicy:主线程直接执行任务。
在spring中对ThreadPoolExecutor进一步进行了封装,提供了ThreadPoolTaskExecutor,帮忙我们提供了默认的timeUnit、workQueue、threadFactory、rejectedExecutionHandler实现对象。同时也可以让我们自主设置:阻塞队列大小、线程名称前缀、线程池关闭等待任务执行完成时间等等。除了ThreadPoolExecutor外,java还提供了线程池静态工厂类Executors,该类提供了几种默认的工厂实现,但实际开发中不建议使用:
(1)Executors.newCachedThreadPool:使用的是无界线程池,线程最大数量可达Integer.MAX_VALUE(2的31次幂减1),具有高度的伸缩性,keepAliveTime的默认值为60s,一旦线程空闲时间超过keepAliveTime,线程就会被回收,当长时间没有任何任务时,线程池的线程数为0,新的任务来时会建立新的线程。该线程使用的阻塞队列是SynchronousQueue,不会缓存任何任务,每次任务过来后,都需要线程池内的线程连接处理,
(2)Executors.newSingleThreadExecutor:使用的是无界队列,线程池内只有一个线程,相当于单线程串行执行所有任务,保证任务能够按照提交顺序依次执行。
(3)Executors.newFixedThreadPool:同样使用的是无界队列,输入的参数即为固定线程数,不存在空闲线程。
线程池不应该通过Executors创建,而应该通过ThreadPoolExecutor创建,这样能够更加明确线程池的运行规则,规避资源耗尽的风险。以上线程都不推荐使用,因为这些队列要么使用的是无界队列,要么使用的就是无界线程池,都可能会将服务器资源耗尽导致OOM。 - 阻塞队列LinkedBlockingQueue的实现原理?(面试未问)
LinkedBlockingQueue主要使用单向链表实现,并且使用了两个reetrantLock,用来分别生成notEmpty条件队列和notFull条件队列,这两个条件队列分别用来控制存操作和取操作。之所以使用reetrantLock的同步队列而不是条件队列,主要是因为当队列为空时,所有线程都应该阻塞,而使用同步队列时,必定会有线程获得执行权。 - ThreadLocal的实现原理及可能存在的问题?(重要!面试常问)
ThreadLocal的实现并不算复杂,首先每个线程Thread对象都维护了一个ThreadLocalMap,这个Map是由ThreadLocal类实现的一个使用线性探测的自定义Map,Map的key是ThreadLocal对象的引用,而value就是我们需要存储的本地线程变量。值得注意的是ThreadLocalMap并没有使用拉链法,而是使用了线性探测法,这可能主要是因为ThreadLocalMap存储的数据量一般不会很大。另外还有一点非常重要:ThreadLocalMap的key被封装成了弱引用。当ThreadLocal对象threadLocalA没有其他强引用时,在下次GC来临时threadLocalA就会被回收,同时ThreadLocalMap相应槽位的key值会变为null,ThreadLocalMap在每次进行get/set操作时都会主动的去清空key为null的键值对。ThreadLocal的这种设计主要是为了防止出现内存泄露。假如key为强引用,那么当threadLocalA使用完后,ThreadLocalMap仍持有threadLocalA的强引用,将会导致threadLocalA无法回收。所以当我们使用ThreadLocal时一般都会定义一个static的threadLocal,并且在使用完相应的数据后,需要手动的执行ThreadLocal的remove移除相应的数据,防止出现内存泄露。
ThreadLocal主要存在两点可能的问题:(1)脏数据问题:因为线程池内的线程采取复用策略,如果线程执行上个任务时,没能显示的通过remove清理线程相关的ThreadLocal数据,那么在下个任务中便可能读到上个任务设置的ThreadLocal数据。(2)内存泄露:一般使用ThreadLocal时都会使用static,此时在使用完ThreadLocal后,若忘记通过remove清理数据,就可能导致内存泄露。 - 对线程安全的理解,什么样的类是线程安全的?(面试未问)
我们通常会将能够安全的被多个线程使用的对象称为线程安全对象,这个对象就是线程安全的。能够保证线程安全有以下几种情况:
(1)仅在单线程内可见的数据是线程安全的,比如使用ThreadLocal存储的数据。
(2)无状态对象总是线程安全的,即对象不包含任何属性以及对其他对象属性的引用,有的仅仅是纯代码方法。
(3)不可变的只读对象是线程安全的,这种对象只在构建时会被初始化,之后不允许进行任何修改和变更,通常会通过final修饰类或属性,例如String、Integer等等。
(4)java提供的线程安全类,比如concurrentHashMap等。
除了上面的这几种情况外,其他的并发情况就需要我们自己通过锁或者合适的同步工具保证数据安全。 - 线程的同步方式?(面试未问)
计算机的线程同步主要指的是:线程之间按照某种机制协调先后执行顺序,当有某个线程对内存进行操作时,其他线程都不可以对这块内存地址进行操作,直到该线程完成操作。实现线程同步的方式比较多,包括volatile、synchronized锁、reetrantLock锁、阻塞队列、及AQS实现的各种线程同步类等等。 - Thread的join()函数实现原理是怎样的?(面试未问)
Thread threadObject = new Thread(new Runnable() {});threadObject.join();join方法会阻塞当前执行的主线程,而不是threadObject线程,直到threadObject线程执行完毕后才会唤醒当前执行的主线程。实现原理也比较简单,主要就是调用join()时将当前线程阻塞,当threadObject执行结束后,将所有因为自己阻塞的线程唤醒。 - Thread.sleep、Object.wait、LockSupport.park区别?(面试未问)
三者都可以使线程阻塞挂起,Thread.sleep需要指定挂起时长,且不会释放持有的锁,相应的线程状态为TIMED_WAITING。Object.wait会让线程挂起但会释放掉持有的锁,且需要通过notify或notifyAll方法唤醒后才能继续运行,另外必须保证wait和notify的执行顺序,否则会出现问题,其线程状态为WAITING。LockSupport.park不会释放持有的锁,可以通过LockSupport.unpark唤醒,由于采用的是类似二元信号量的实现方式,所以unpark方法可以比park方法先执行,不会丢失唤醒信号,其线程状态同样为WAITING。 - 同步/异步&阻塞/非阻塞?(面试未问)
阻塞还是非阻塞,取决于线程所做的操作是否需要将线程挂起等待。同步还是非同步,取决于是否是当前线程亲自执行操作,若当前线程亲自执行操作则为同步,当前线程通过创建或利用其他线程执行操作则为异步。同步/异步&阻塞/非阻塞 - 高并发情况下pv、uv的统计?(面试未问)
pv:使用redis的incr原子命令进行统计,然后为key设置相应的过期时间即可。
uv:在uv量不大的情况下,可以使用redis的set类型数据,设置相应的过期时间,将用户id存入set集合中,通过scard就可获取某个时间点的uv数据。在uv量很大且数据并不要求十分精准时,可以使用hyperLogLog。 - 高并发情况下的限流?(重要!面试常问)
在高并发下保护系统的三大利器:缓存、降级、限流。缓存可以增加系统访问速度和增大系统处理容量,降级是当服务器压力剧增时,根据业务情况和流量对一些服务和页面降级,以保证核心业务功能的正常运行。限流的目的主要是通过对并发请求进行限速和限量来保护系统,请求速率或请求量一旦达到阈值就可以排队、等待或者拒绝服务。
常用的限流方法:有计数器方式、令牌桶和和漏桶。计数器法比较简单粗暴,主要用来限制并发请求数量,一旦并发请求数量达到阈值,就可以直接拒绝请求,可以通过semaphore来实现。令牌桶除了能够限制请求的平均速率,还能够允许一定程度的突发流量。漏桶算法能够使请求速率均匀,不会接受突发流量。 - 死锁的检查与排查?
死锁主要分为两种情况:(1)锁顺序死锁、(2)资源死锁。锁顺序死锁:主要是因为线程之间分别持有对方需要的锁,但互相不释放自己持有的锁资源,最终就导致了死锁,谁也无法继续往下推进。在mysql数据库中存在同样的情况,但是mysql会通过在表示等待关系的有向图中搜索是否有环,若存在环则说明发生了死锁,mysql会选择代价比较低的事务进行回滚,这样就能保证另一个事务可以正常的执行。而在java中一旦发生死锁,这些线程就永远不能在使用了,可能造成系统性能降低,甚至造成应用程序完全停止。当线程需要获取多个锁时,如果所有线程都以固定的顺序获取锁,那么程序中就不会出现锁顺序死锁问题。资源死锁:当我们在使用线程池或信号量来限制对资源的使用时,这些限制也可能会导致资源死锁。比如线程的“饥饿死锁”,如果线程池中只有有限个线程,并且线程执行的任务的完成依赖于其生成的子任务,子任务同样在该线程池中运行,那么当子任务无法获取线程执行,一直存储在阻塞队列中,就造成了线程的“饥饿死锁”。
java死锁的排查比较简单:首先通过jps或ps -ef | grep java命令获取java进程的pid,然后通过jstack命令查看当前虚拟机内所有线程的快照,jstack会展示所有线程的状态信息,同时也会帮我们统计展示存在死锁的线程信息,包括死锁线程持有的锁及需要的锁,及相关锁的地址,如下图所示。
(三)jvm
- java类加载过程?
类的加载流程主要分为:加载、验证、准备、解析、初始化、使用、卸载七个流程。(1)加载:通过ClassLoader去加载Class文件到jvm内存中,Class文件的来源并没有任何限制,既可以是我们的程序通过javac编译器生成的,也可以来源于网络等其他途径。(2)验证:这个过程主要就是对Class文件中的字节流进行检查,看看是否符合规范,是否存在安全问题等等。(3)准备:这个过程主要是为类变量(static修饰的变量)分配内存,此时的初始值指的是通常情况下的零值,例如private static int a = 3,类变量a在这个阶段会被赋值为0。唯一例外的类变量是使用final修饰的类变量,使用final修饰的类变量会在这个阶段就赋给程序中设定的值。(4)解析:这个阶段主要将Class文件中对常量池的引用转为直接内存引用,举个简单的例子:我们常会在代码中使用import xxx.xxx.xxx.class,这里面的xxx.xxx.xxx.class就是符号引用,解析阶段就是把这种符号引用转换为对xxx.class具体内存地址的引用。(5)这个阶段主要负责初始化类对象(Class对象),会按照在文件中出现的顺序为所有类变量赋值,同时执行静态代码块。千万注意这里初始化的是类对象而不是实例对象!(6)使用、(7)卸载:这两个阶段就是对象使用和类型卸载的过程。
上面就是整个类加载的流程,有一点需要强调的是,这个流程讲的主要是类的加载流程,它面向的是类对象(Class对象),初始化的也是类对象,并不是我们通常意义上的实例对象。当我们需要构建实例对象时,可以通过new Object()实例对象,jvm首先会检查相应的类型对象Class是否存在,若不存在则会通过上面的类加载机制生成对应的Class对象。之后会在堆内存中为对象分配内存空间,并为所有实例属性赋零值。在之后会设置对象头信息,包括Mark Word(对象hashCode、对象Gc年龄等)和对应Class类对象的引用。最后会按照文件中出现顺序为实例变量赋值,同时执行代码块,最后执行构造方法完成对象的构建。 - 类加载器介绍
java类加载器是在java虚拟机外部实现的,可以让用户程序自己决定如何获取所需的类。任意一个类,它的唯一性都由加载它的类加载器和这个类本身一起确定,两个类即使名称完全相同,但若由同一个类加载器加载的,那么它们也是两个不同的类。java程序主要由三层类加载器加载,按优先级它们分别为:(1)启动类加载器Bootstrap ClassLoader:该类加载器由C++实现,负责装载最核心的java类。(2)扩展类加载器Extension ClassLoader:用以加载一些扩展系统类,通过java实现。(3)应用程序类加载器Application ClassLoader:加载用户自定义的类,同样是通过java实现。当一个类加载器收到类加载的请求时,首先会将请求委派给父类加载器,直到父类无法加载,自己才会加载,这种类加载的方式被称为“双亲委派”,但加载器间并非继承关系,而是以组合的方式来复用父加载器的功能。双亲委派的加载模式主要是为了保证系统加载类的安全性,如果没有双亲委派,假如我们自定义一个Object对象,然后通过自定义的加载器加载,那么在系统中就存在多个Obect,导致系统的混乱。在日常的使用中也经常会破坏双亲委派的情况,比如jdbc的代码是由启动类加载器加载的,但是由于数据库的驱动通常都由不同的厂商提供,启动类加载器无法加载,这时需要通过线程中的contextClassLoader加载器加载相关的驱动类,这就破坏了双亲委派。除此之外,osgi等热部署技术也打破了双亲委派的模式,在osgi中不同模块都有不同的类加载器,在进行替换时会将组间和类加载器一同替换掉。 - jvm的内存布局?(重要!面试常问)
jvm的内存主体可以分为堆和栈两大块,每个线程都会分配相应的栈空间,主要包括虚拟机栈和本地方法栈,分别用来执行java方法和本地方法,同时每个线程还会为程序计数器分配相应的空间,这些空间都是线程私有的。在虚拟机栈中主要存储局部变量表、方法栈,方法返回地址等等。而堆主要分为新生代、老年代、永久代(方法区),新生代与老年代占用空间大小默认比例为1:2,可以通过-XX:Newratio设置。其中新生代主要用来存储新建对象,包括一个eden空间和两个survivor空间,默认按占用8:1:1的大小分配。而老年区根据对象经历的GC次数,将一些比较老的对象升级进入老年代,默认经历GC次数为15次。永久代(方法区)主要用来存储类型信息、静态变量、常量等,在java8后元空间取代了永久代(方法区),并且元空间在本地内存中分配。之所以放弃永久代(方法区),是因为永久代之前由jvm分配固定的空间大小,经常会出现OOM问题,同时FGC时经常需要移动永久代内的数据,同时为了和其他类型的虚拟机统一规范,就废弃了永久代,改为使用元空间,但是元空间内存溢出问题非常难以排查。 - GC类型有哪些?
一.部分收集(Partial GC):不是完整收集整个堆的垃圾回收,其包括:
(1)新生代收集(Minor GC/Young GC):指仅对新生代进行垃圾收集
(2)老年带收集(Major GC/Old GC):指仅对老年代进行垃圾收集
(3)混合收集(Mixed GC):收集整个新生代和部分老年代
二. 整堆收集(Full GC):对整个java堆和方法区进行的垃圾收集(java8后垃圾收集器实际上并不能直接回收metaspace,而是通过回收堆中的Class对象和String常量池间接回收metaspace,另外如果不指定metaspace的大小,默认情况下元空间最大是系统内存的大小) - 有哪些垃圾回收算法?
目前垃圾回收算法主要有三种:标记清除算法、标记复制算法、标记整理算法。
(1)标记清除算法:
标记清除法首先会标记存活的对象,之后会统一回收所有未被标记的对象。它存在两个问题:1.当需要回收的对象比较多时,回收效率会比较低,所以不适合回收新生代、2.存在内存碎片化的问题。
(2)标记复制算法:
标记复制算法是应用于新生代收集的主要回收算法,jvm会把新生代分为一块比较大的Eden空间和两块比较小的survivor空间,每次只使用eden空间和其中一块survivor空间,在进行回收时将存活的对象复制到剩余的一块survivor空间中,然后将使用过的Eden空间和survivor空间清理掉。一般eden空间和survivor空间的内存占用比例为8:1,当剩余的survivor空间不足以存储所有存活的对象时,就会启动分配担保机制,多余的对象会直接进入老年代。
(3)标记整理算法:
标记整理算法首先会标记存活的对象,之后将所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。标记整理算法由于需要移动对象,所以一般使用于老年代,同时对象移动操作必须暂停所有用户应用程序,所以会增大垃圾回收造成的延迟,但是却能够提高系统的吞吐量,关注吞吐量的Parallel Old收集使用的是标记整理算法,而关注延迟的CMS使用的是标记清除算法。 - 常用的GC收集器有哪些?
GC收集器主要分为两大类:新生代收集器和老年代收集器,其中新生代收集器主要有:serial收集器、parnew收集器、parallel scavenge收集器,而老年带收集器主要有:serial old收集器、parallel old收集器、cms收集器。其中parnew经常配合cms收集器收集器一起使用,适用于要求低系统停顿的应用。而parnew收集器配合parallel old收集器一起使用,适用于要求高吞吐量的应用。serial old收集器为单线程的老年代收集器,在CMS和G1收集器内存空间不足时,会降级为使用serial old收集器,暂停所有用户线程,单线程执行垃圾回收。最后就是新生代/老年代一体回收的大名鼎鼎的G1收集器。 - G1收集器执行流程?
G1收集器开创了面向局部收集的设计思路和基于region的内存布局。G1收集器将jvm堆分为了很多大小相等的独立区域region,每个region都可能是Eden、Survivor或者老年代空间,每块region的大小默认等于(最大堆空间 + 最小堆空间)/ 2 / 2048,最小空间大小为1M,同时还提供了特殊的humongous region用于存储大对象。G1收集器会记录每个region的回收价值,并且维护一个优先级列表,每次根据用户设定的停顿时间,优先回收收益大的region区域。其具体运作流程可分为以下四步:
(1)初始标记:
垃圾回收主要针对的是堆和方法区,首先会标记所有GC Roots关联的对象(GC Roots主要包括常量、类静态属性、栈帧中的局部变量表),在查找对象引用时会使用OopMap数据结构,该结构会记录对象引用的位置。但由于用户线程的执行可能实时变更对象引用关系,所以在初始标记阶段需要短暂的停顿用户线程,为了保证用户线程能够生成正确的OopMap数据,jvm设置了线程执行的安全点和安全区域,线程在执行到安全点(方法调用、循环跳转、异常跳转)或安全区域时,会主动挂起等待初始化标记的完成。在开始初始化标记时还会划出一块内存区域用于垃圾收集期间新对象的内存分配。
(2)并发标记:
从GC Roots开始对堆中数据进行可达性分析,这个阶段可以与用户线程并发执行。由于用户线程可能并发的修改对象引用,可能造成两种情况,一种是某些需要回收的对象被漏掉,这种对象被称为“浮动垃圾”,我们可以在下次垃圾回收时再次进行回收,另一种情况是某些仍然存活的对象可能会被标识为垃圾,并发标记阶段会记录对象引用分变更,以便于在第三阶段重新标识这部分对象,防止错误的回收不该回收的的对象。
(3)最终标记:
进行一个短暂的暂停,来处理并发阶段引用变更导致的对象标记错误。
(4)筛选回收:
更新region的统计数据,对各个region的回收价值和成本进行排序,根据用户指定的停顿时间制定执行计划。将需要回收的region区域内数据复制到空的region中,并清理掉旧的region空间,由于这一步需要移动对象,所以必须要暂停用户线程。 - G1收集器的优缺点?
优点: 1.采用了面向region的回收思想,可以指定GC收集的最大停顿时间、2.不会产生内存碎片,能够提供规整的可用空间。
缺点: 1.比其他收集器占用更大的内存空间(每个region设置一个RSet),消耗了更多的cpu资源。
所以一般内存空间小于8g的应用一般都会使用parNew + CMS,而大于8g的应用一般都会使用G1,随着计算机技术的发展,目前大部分应用的内都很大了,所以parNew + CMS的收集器组合已经逐渐被废弃了。 - 常用的JVM参数?(面试未问)
-Xms:设置最小堆内存空间
-Xmx:设置最大堆内存空间
-Xss:设置虚拟机栈内存空间
-XX:NewRatio:设置新生代和老年代占用空间比例
-XX:+UseG1GC:设置使用G1收集器
-XX:MaxTenuringThreshold:设置进入老年代需要经历的GC次数
-XX:+DisableExplicitGC:设置禁止使用System.gc()
-XX:+PrintGCDetails:打印GC日志详情
-XX:+HeapDumpOnOutOfMemoryError:出现OOM时保存dump文件 - 内存泄露与内存溢出问题?(面试未问)
在java中内存泄露主要指的是:本该被回收的内存空间没能被释放,大量的内存泄露会导致最终内存溢出,内存溢出指的内存空间不足,无法继续存储数据了。内存溢出主要有四种情况:
(1)java堆溢出
堆溢出是我们最常见的内存溢出,在内存溢出时通常会发生OOM异常,同时会生产相应的dump文件。我们可以通过MAT分析dump文件,查找占用内存空间比较大的对象,看该对象是够存在内存泄露问题,若不存在,可以考虑是否能够增加堆内存空间大小。
(2)虚拟机栈溢出
我们知道每个线程都会分配特定大小的虚拟机栈空间,如果栈帧中的局部变量表占用内存比较多,或者栈的深度非常大,那我们分配的栈空间很可能不够用,这时就会出现StackOverFlowError。出现这种异常时,基本都会有明确的错误堆栈信息可供分析,比较容易定位到特定的问题。
(3)方法区溢出
方法区溢出主要有两种:一种是常量池导致的溢出,比如程序使用了String.intern()方法产生了大量的字符串常量,就可能导致方法区的内存溢出,不过在java8后String的常量池被移到了java堆中,可能会导致java堆的溢出。另外一种比较常见的就是大量类型信息导致的方法区溢出,比如在程序中通过cglib等技术动态的生成了大量的类,就有可能导致方法区内存溢出,在java8后代表类型的Class对象同样在堆中存储,当出现元空间不足时,便会通过GC收集器进行类型卸载,间接回收元空间。
(4)本机直接内存溢出/堆外内存溢出
直接内存并不是虚拟机运行时数据区的一部分,不归jvm虚拟机管。java在使用NIO时会通过本地方法直接分配堆外内存,然后通过DirectByteBuffer对象作为这块内存的引用进行操作,这样可以避免java堆和native堆间的数据复制。虽然直接内存不受java堆大小的限制,但是会受到本机总内存大小的限制,同样可能导致OOM。直接内存导致的溢出,在dump文件中看不存什么异常,是最难处理的内存溢出问题。
(四)mysql
- B树和B+树的区别?(重要!面试常问)
B树全名平衡多路查找树,B树是能够自平衡的多路查找,树的非叶子节点也会存储记录数据。B+树的非叶子节点只存储相应的键值,而不存储实际记录数据,同时B+树中所有叶子节点都会通过双向链表连接。在mysql的实际使用中,mysql使用B+树的层高一般都是2-4层,这就保证了磁盘和内存不会有过多的IO。B+树每次能够读取的最小单位是页(页是innodb磁盘管理的最小单位),它的每个非叶子节点都是一页,页的默认大小为16KB。B+树在增删改数据时,为了保证树的平衡是需要进行类似二叉树的旋转的,同时也可能需要进行拆分页的操作,所以当B+内存储的数据很多的时候,每次的自平衡过程都需要移动很多数据,所以操作B+树的时间更久,这也是当mysql数据量大的时候增删改的效率降低的原因。另外还有一点值得说,我们mysql每次插入的主键基本都是自增的,这样的插入性能会好些,如果生成的主键不是递增的就会导致大量的拆分页和树的旋转操作,这将对mysql数据库的性能带来很大的影响。在mysql中主键索引和非主键索引都是由B+树实现的,辅助索引的叶子节点存储的是主键id,而主键索引的叶子节点存储的是数据记录,数据记录按照主键顺序逻辑有序的依次存储,所以主键索引也被称为“聚簇索引”,而辅助索引被称为“非聚簇索引”。 - 事务的ACID特性?(面试未问)
事务是数据库区别于文件系统的一大特点之一,Innodb存储引擎的事务完全符合ACID的特性。ACID分别代表着:原子性、一致性、隔离性、持久性,事务是访问并更新数据库的基本程序执行单元。原子性:数据库事务是不可分割的单元,要么事务中的所有操作都执行成功,要么都执行失败。一致性:指事务能够使数据库从一种一致性状态转化为另一种一致性状态。隔离性:某个事务在提交前,其他事务对该事务修改的内容都不可见。持久性:事务一旦提交,结果就能永久保存,除非发生的是硬件故障,持久性能够保障系统的高可靠性。事务的原子性和持久性通过redo日志实现,一致性通过undo日志实现,而隔离性通过数据库锁实现。 - mySQL事务的不同隔离级别及可能出现的问题?
数据库事务一般有4种隔离级别:read uncommitted、read committed、repeatable read、serializable。
(1)read uncommitted是最低的隔离级别,存在脏读的问题,即会读到尚未提交的数据,同时也存在不可重复读和幻读问题。
(2)read committed是很多数据库的默认隔离级别,这一级别解决了脏读问题,但是同样存在着不可重复读和幻读问题。
(3)repeatable read是mysql的默认级别,普通的数据库在这一级别中解决了不可重复读的问题,但是仍无法解决幻读问题,不过mysql通过next key lock解决了幻读问题,所以mysql在repeatable read级别就能提供满足ACID要求的隔离性。mysql数据repeatable read隔离级别下支持一致性非锁定读MVCC。
(4)serializable是最严格的隔离级别,在这一级别下所有的事务操作都必须依次顺序进行,同时将无法使用一致性非锁定读MVCC,所有的select语句都会自动加上lock in share mode共享锁。
不可重复读和幻读的区别重点在于针对不同的操作类型:不可重复读防止update和delete导致的数据错误,而幻读防止insert导致的数据错误。例如select count(*) from table where sex = "man"语句在事务A中,开始时能够读取的数据为10,此时事务B执行了delete操作将sex = "man"的记录删除了一条,那么事务A再次执行相同语句时,数据就会发生变化,这就是不可重复读问题。而当事务C进行insert操作时同样可能造成事务A读取的数据发生变化,这种情况导致的问题叫做幻读问题。 - mysql事务的实现原理?
mysql在repeatable read隔离级别下就可以完全满足事务的ACID原理,事务主要有几种类型:扁平事务、带保存点的扁平事务、嵌套事务、分布式事务。事务的隔离性由锁来实现,事务的原子性和持久性由redo日志来实现,而事务的一致性由undo日志来保证。
(1)redo日志:redo日志主要由重做日志缓存(redo log buffer)和重做日志文件(redo log file)两部分组成,innodb存储引擎会保证事务在提交时,事务的所有增删改操作日志能够写入到重做日志文件中持久化,这种机制被称为Force Log at Commit。redo日志会随着事务的执行,顺序的将数据页的物理修改写入到做重做日志缓存中,并且在事务提交前通过fsync操作,将缓存数据写入到文件中。因为fsync操作的效率取决于磁盘性能,所以一般我们会在mysql中进行相应的配置,事务提交时并不会立刻执行fsync操作,而是由master thread定时的去执行fsync操作以提升数据库性能,当然这样就可能存在当数据库宕机部分数据未能刷入磁盘文件而导致的数据丢失。redo日志和我们熟知的binlog日志是不同的,binlo日志是由mysql服务器层生成的,所有的mysql存储引擎都能够生成binlog日志,而redo日志是innodb引擎独有的,并且两者保存的内容也并不相同,redo日志保存是的事务对数据页的物理修改,而binlog日志保存的是事务执行的增删改操作mysql日志。
(2)undo日志:在mysql中专门有个特定的segment用来存储undo日志,undo日志用于将数据库恢复到执行事务之前的状态,mysql的一致性非锁定读也是通过undo日志实现的。每条mysql记录都会包含一个隐式的deleted标识位,用来标识记录是否被删除,同时记录还有两个系统列:DATA_TRX_ID:用于标识记录当前的事务版本,DATA_ROLL_PTR:用来指向当前记录项的undo日志链表。每次有事务操作记录时都会更新记录的版本,同时旧版本的记录数据就会被存入到undo日志中,同时放入DATA_ROLL_PTR指向的undo日志链表中。undo日志中除了保存旧版本记录的数据外,还会保存事务对记录进行的逻辑操作。在开启MVCC后通过select读取数据时,innodb会为当前事务创建一个read view用以记录当前事务能够使用的记录版本,假如我们需要读取的记录正在被其他事务操作,那么我们便可以通过read view去undo日志链表中找到我们可以使用的版本数据,这样就不会阻塞数据的读操作。在repeatable read隔离级别下,不论是否有新的事务提交,总是读取开始时的版本数据,而在read committed隔离级别下,一旦有新的事务提交就能都读取事务提交的数据。 - mysql中多列索引的优点?(面试未问)
在mysql中单列索引和多列索引的主要区别就是,多列索引同时使用多个字段作为索引的键值。在mysql中当使用的字段占用空间不是很大时,其实多列索引并不会造成索引性能的明显下降。同时多列索引保存了更多的列数据,能够适应更多的查询场景,可以使用覆盖索引等所以优化技术。除此之外多列索引的字段存在一定的有序性,合理的使用可以避免排序操作。 - mysql中count函数的实现原理?(面试未问)
在mysql中count(*)和count(1)的实现逻辑是完全相同的,当select语句的where条件能够确定特定辅助索引时会通过辅佐索引统计记录数。若没有任何where条件,则innodb引擎会选择一个占用空间比较小的辅助索引统计记录条数。 - explain中常用字段信息?(面试未问)
在explain信息中比较常用的字段有:
(1)key:用于表示本次查询使用的索引
(2)rows:预估本次查询需要扫描的行数
(3)type:本次查询的类型,主要的类型如下图所示
(4)extra:展示mysql查询的额外信息
- mysql使用的一些优化建议?(面试未问)
(1)尽量避免使用join操作。因为使用join操作会增加锁竞争的风险,同时会给日后的数据库拆分带来不便,另外性能不一定比拆分成多条语句高。
(2)需要谨慎的考虑在varchar字段上建立索引,适当的时候可以考虑使用前缀索引。
(3)合理的使用 order by,避免出现using filesort。
(4)利用覆盖索引来进行查询操作,避免回表。
(5)利用延迟关联优化超多分页场景。 - mysql中limit的优化方案?
在mysql中我们经常会遇到使用limit m,n进行分页的场景,当limit m,n中m越来越大时,分页查询的性能就会越来越差。一般我们会有两种方式解决这个问题,第一种是每次在查询时都将上次查询时的最后一条记录的主键id传入,然后利用主键id在去查下一批数据,但这种方式不是很好使用,因为它的限制比较多,面对需要跳转到某个特定页面的场景根本无法处理。其实还存在一种写法即mysql实例在实际使用中常按功能被划分为三层:(1)客户端层:负责处理连接、安全认证等等(2)服务器层:包含了mysql很多核心服务,包括sql语句的解析优化、执行计划的制定执行、存储过程的实现、触发器的实现、所有内置函数的实现、binlog的生产等等所有跨存储引擎的服务都在本层实现(3)储引擎层:负责mysql中数据的存储和提取。
limit m,n之所以性能比较差,是因为limit m,n的实现是由服务器层完成,存储引擎收到的命令只是获取满足where条件的m条记录,这一点和mysql5.6之前的“索引截断”问题是一样的,在idx_a_b_c的索引中若查询语句为where a = xxx and b > xxx and c = xxx,则服务器层在经过分析后只会把条件where a = xxx and b > xxx传递给存储引擎层,最后在服务器层根据c = xxx进行条件过滤,这样就会导致数据库读取了很多无用的数据,这个问题在mysql5.6后引入ICP技术得到了解决,但是limit m,n还是存在着相同的问题,每次都需要多读很多数据,随着m值的越来越大,语句的查询性能就越来越差。
上述语句之所以能够大幅度减少语句的执行时间,是因为子语句select id from table where XXXX limit m,n使用了覆盖索引,只会使用辅助索引查询m条主键id,然后从m条主键索引中取出n条主键id,最后实际只有n条记录会回表查询,这样就大大减少了数据库的IO操作,也就减少了语句的执行时间。 - mysql的加锁机制?(面试未问)
在mysql中表锁主要是在服务器层实现的,而innodb在存储引擎层实现了行级锁,innodb会在每个数据页用特定数据结构管理该页上的所有锁,并通过bit数组标识每条记录的锁定状态。mysql的行级锁主要有几种:(1)LOCK_REC_NOT_GAP:纯正意义上的行锁,只锁住特定的行记录(2)LOCK_GAP:间隙锁,锁两个记录之间的 GAP(3)LOCK_ORDINARY:即next-key lock,是间隙锁和行锁的结合体,既锁住记录也锁住行间的间隙(4)LOCK_INSERT_INTENSION:插入意向gap锁,一种特殊的gap lock,只会锁住要插入记录的位置。它们之前的兼容矩阵如下图:
上面的兼容性指的是不同事务间的兼容性,实际在同一事务中很多锁是可以同时持有的,并不会出现同一事务内部的兼容问题。另外值得注意的是间隙锁之间是兼容的,插入意向锁之间也都是兼容的。接下来介绍在read repeatable隔离级别下增删改的加锁流程:
(1)insert加锁流程
事务首先会尝试获取插入意向锁(Insert Intension Locks),若已有其他事务在同样的位置加了GAP锁或Next-Key锁,则当前事务加锁失败进入等待。否则会进行进行唯一性约束检查,若不存在相同键值记录则进行插入操作,如果存在相同的键值,事务会检测相应的记录是否已被标记为删除或是否有锁,若记录已被标记为删除或者被其他事务加了锁,那么当前事务会加record行锁(S共享锁)等待(其他事务可能在执行更新或删除操作,等待其他事务结束),否则就会抛出duplicate key异常。最后在相应记录加X锁后插入记录,直到事务提交后才会释放持有的所有锁。
mysql在官方文档到提到过insert这套加锁流程可能导致的死锁,举个例子:事务A、事务B、事务C同时插入某一有相同唯一索引键值的记录,当事务A在插入期间,事务B和事务C获取到插入意向锁后,发现事务A持有排他锁,两者就会自动获取S共享锁(实为record行锁)进入等待,之后事务A发生了回滚释放了排它锁,事务B和事务C都想要升级为排他锁进行插入,但是由于双方本身都持有一个共享锁,导致两者谁也无法获取记录的排他锁,这就产生了死锁。
在mysql5.7中,当我们使用insert on duplicate key update时,在并发插入数据并且存在特定列使用了唯一索引时,即使插入的不是相同记录,也可能出现死锁问题,这主要是因为insert on duplicate key update相对于正常insert操作前需要先获取间隙锁,据说是为了保障repeatable read隔离级别下的select的数据一致性,举个例子:三个事务A、事务B、事务C并发的在同一间隙内插入不同的数据,事务A首先获取了该间隙并加上了间隙锁,之后获取插入意图锁,然后进行插入操作。这时事务B、事务C同时到来并且都获取了该间隙锁,然后尝试获取插入意图锁,由于事务A的间隙锁存在,所以两者阻塞等待,在事务A执行完成后,后两者同时尝试获取插入意向锁,但是因为两者都有该间隙锁,所以两者互相不兼容导致死锁。不过这个问题在5.7以上的版本进行了修复,另外就是没必要非要使用insert on duplicate key update,我们完全可以在业务层实现相同的功能,在数据库层不应该有过多的复杂操作逻辑。
(2)delete加锁流程
当where条件使用的是唯一索引时:
1.未找到满足条件的记录时:在下一条记录前加间隙锁
2.在找到满足条件的记录且记录有效时:对记录加行锁,不用加间隙锁
3.在找到满足条件的记录但记录无效时:对记录加next key锁(锁住已经标记为删除状态的记录)
当where条件使用的是非唯一索引时:
仅2不同,在where条件使用的是非唯一索引时,2中情况下会直接加next key锁
相应的死锁问题参见:一个最不可思议的MySQL死锁分析
(3)update加锁流程
在mysql中的update操作,都是一条一条进行的,先对一条满足条件的记录加锁,返回给服务器层,做相应的操作,然后继续处理下一条满足条件的记录,直到所有的记录都处理完毕,最后会统一释放掉所有锁。
在实际加锁时,会首先在辅助索引上加锁,之后会对辅助索引指向的主键索引记录加锁。在Read Committed隔离级别下只会对满足条件的辅助索引键值和主键索引加锁,而在Repeatable Read隔离级别下在非唯一索引加锁时还会加间隙锁。当where语句走不到索引时,会在辅助索引上把所有记录都加上排他行锁,在Repeatable Read级别下还会加入间隙锁,所以可想而知,这种情况会导致mysql服务的性能急剧下降,虽然mysql针对这种情况作了优化,但这仍是非常影响数据库性能的问题,所以必须避免出现走不到索引的情况。 - mysql动态数据源实现方式?(面试未问,无需了解)
在Dao层通过注解标注每条语句需要使用的数据库主库或从库,然后利用spring提供的AbstractRoutingDataSource数据源类动态的进行数据库切换。在项目启动初期AbstractRoutingDataSource就会将所有的数据库配置项加载到targetDataSources属性中。用户程序通过determineCurrentLookupKey决定切换数据源的方式。 - mysql分库分表实现方式?(重要!面试常问)
分库分表通常有两种方案:垂直切分、水平切分。
(1)垂直切分
垂直切分可以分为垂直拆库和垂直拆表。1.垂直拆库就是根据业务场景将某个库中的部分表迁到其他库中,以减少数据库的请求量,垂直拆库的实现比较简单,分成多个库后能够将请求流量分摊到多个库上,这样单个库的请求量减少了,因为数据库的连接数是固定的,所以能够在某种程度上减轻数据库压力。2.垂直拆表就是根据业务场景将一张表拆成多张小表,可以将这些表放在同一个数据库中,也可以放在其他的库中,这种方式因为减少表中每行记录的长度,能够减少数据库查询IO次数进而提升数据库性能。这两种拆分方式都能够一定程度上缓解数据库的压力,但是并不能根本上解决数据量不断增加带来的数据库查询压力。
(2)水平切分
水平切分又分为库内分表和分库分表。水平切分会将一张大表按某种维度拆分成多张数据结构完全相同的小表,拆分后的小表如果仍放在原来的库中则称为库内分表,放在其他库中则称为分库分表。库内分表由于所有的表仍然使用同一数据库的固定连接数,所以仍会给数据库带来很大的压力,所以通常水平切分最常使用的方式是分库分表,将拆分的表分别存放在不同的库中。水平切分能够有效的解决数据库数据量过大的问题,提升数据库系统的稳定性和负载能力,但是却需要比较大的业务改动。
分库分表带来的问题:
1.单机的ACID被打破,查询同样的数据在进行数据库拆分后,需要到多个库中分别查询,在某些场景下需要使用分布式事务。
2.join操作会更加复杂,后端开发中应该尽量避免使用join操作。
3.单机情况下的主键自增逻辑无法再使用,需要生成全局唯一主键。
4.查询中的分页排序操作实现起来将会更加复杂。 - mysql分库分表后如何解决主键id问题?(重要!面试常问)
在分库分表后可以使用几种方案生成全局唯一的主键id:UUID方案、TDDL方案、Snowflake方案(雪花算法)。
(1)UUID方法:经由一定的算法机器生成UUID,为了保证UUID的唯一性,规范定义了包括网卡MAC地址、时间戳、名字空间(Namespace)、随机或伪随机数、时序等元素,以及从这些元素生成UUID的算法。UUID的复杂特性在保证了其唯一性的同时,意味着只能由计算机生成。由于UUID生成的主键是字符串占用空间很大,并且具有很大的随机性,生成的主键不能全局递增,非递增的主键会导致mysql大量的页裂和记录移动,所以性能非常差,所以该方案基本无法使用。
(2)TDDL方案:TDDL是阿里采用的全局主键生成方案,通过数据库存储主键id信息,应用服务器每次申请一批主键id在内存中,使用完后在申请下一批。该方案的缺点是强依赖数据库,当数据库异常时整个系统不可用,但主键id生成的速度比较快且效率比较高。
(3)Snowflake方案(雪花算法):snowflake算法的特性是有序、唯一,并且要求高性能,低延迟(每台机器每秒至少生成10k条数据,并且响应时间在2ms以内),适合在分布式环境(多集群,跨机房)下使用,snowflake算法由多段组成,占用空间并不多,共占8个字节64位。雪花算法的开头部分是时间戳(单位毫秒),第二部分是使用的机器id,第三部分是序列号,用于记录1ms内生成的不同id。雪花算法不依赖数据库及其他三方系统,并且生成主键id的速度和性能都非常好,但是强依赖于机器时钟,如果机器时钟发生回拨就会出现重复的主键id。一般公司在使用该方案时都会对雪花算法进行一定的优化以解决时钟回拨导致的重复主键问题。
- 数据库乐观锁实现方案?
乐观锁总是假设不会产生数据冲突,在执行具体操作时才会进行冲突检测。mysql数据库的乐观锁实现方案和java并发中CAS的思想十分相似,都是在进行数据更新时先检测待更新数据是否发生变化,由于可能存在ABA问题,所以一般还会使用版本号version,mysql乐观锁的使用一定要合理的设计索引,保证更新数据时走到索引。除了这种方式外还可以通过在where语句中增加一定的条件限制来实现乐观锁,这种方式适用于商品抢购的场景,举个例子如下:
(五)redis
- 与memcache有什么不同?(面试未问)
redis和memcache都是基于内存的数据存储系统。两者有以下区别:1.memcached仅支持key-value数据结构,而redis支持多种类型的数据结构:String、Hash、List、Set、Sorted Set。2.memcached所有数据都存储在内存中间,而redis会根据一定的策略将数据保存到磁盘中。3.memcached在客户端通过使用一致性hash等分布式算法来实现分布式存储,而redis更偏向于在服务器端构建分布式存储。 - redis有哪些数据类型及每种类型的实现方式?
redis共有5种基础数据结构,分别为:string(字符串)、hash(字典)、list(列表)、set(集合)、zset(有序集合)。
(1)string类型实现:
redis中的字符串是可修改的字符串,在内存中以字节数组的形式存在,这点和java是不同的,java采用的是char数组。redis使用的字符串数据结构被称为"SDS",即Simple Dynamic Strong,它是一个带长度信息的字节数组。其原理和java的StringBuilder类似,两者都是内部维护了一个数组,在进行append时,先判断数组长度是否足够,如果足够则直接存入该数组,否则新建一个空间更大的数组,然后将数据存入。
(2)hash类型实现:
hash类型在redis中经常会被称为“字典”,其主要通过hahstable实现,其结构和java中的hashmap几乎完全一样。因为在redis中有很多“大字典”,“大字典”的扩容是非常耗时的,对于单线程的redis来说是比较难以承受的,所以redis会采用渐进式hash。“字典”内部包含两个hashtable,通常只有一个hashtable有值,当字典需要进行扩容或缩容时,就会分配新的hashtable,然后进行渐进式搬迁。在每次进行增删改查时都会随便进行数据搬迁,同时redis还会定时进行主动搬迁。
(3)list类型实现:
list列表数据结构使用的是quickList,quickList是zipList和linkedList的混合体,它将linkedList按段切分,每一段使用zipList让存储紧凑,多个zipList之间使用双向指针串接起来。我们都知道链表linkedList,但zipList是什么呢,我们来介绍下:压缩列表是一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空隙,它有点类似于数组,但由于每个元素占用的空间不同,所以列表会记录最后一个元素的偏移量,每个元素会记录前一个元素的偏移量,使用的是偏移量而并不是指针,以节省内存空间。当zipList占用内存太大时,重新分配内存和拷贝内存都会有很大的消耗,所以不适合存储大型字符串,存储的元素也不宜过多。而如果使用linkedList,链表的附加空间相对太高,另外每个节点的内存都是单独分配的,会加剧内存的碎片化,影响内存的使用效率。所以redis将两者相结合推出了quickList。
(4)set类型实现:
redis集合对象set底层使用了intset和hashtable两种数据结构存储,intset类似于java数组而hashtable类似于java哈希表(key为set的值,value为null)。intset的使用是有一定条件的:1.保存的所有元素都是整数值、2.保存的元素数量不超过512个,若不满足该条件则会使用hashtable。intset内部其实是一个数组,而且存储的数据是有序的,因为在查找数据的时候是通过二分查找来实现的。
(5)zset类型实现:
redis的zset类型是一个复合类型,一方面需要hash结构来存储value和score的对应关系,另一方面还需要按照score进行排序,所以zset实际是hash字典加跳跃表实现的。跳跃表的最底层相当于一条有序链表,每一个节点都是key/value的数据节点,节点的key即我们需要存储的数据,value即对应的score。链表的每个节点都会通过随机算法确定层数,高层的节点在同一层通过单向链表连接。header节点记录了最高的层数,并且是每层链表的起始节点,通过这种方式可以使查询的效率从O(n)降为O(lg(n))。同时节点在每层都会记录其到前一个节点需要跨越的节点数(span),将“查询路径”经过的所有节点记录的span累加在一起就是当前元素的排名,这样就实现了排名的功能。 - redis如何实现限流?(面试未问)
redis可以通过zset来实现滑动窗口限流,即保证在指定时间内只允许发生N次请求。具体实现思路:zset的value和score值都使用时间戳,每次请求到来时都维护下时间窗口,利用Zremrangebyscore命令将指定窗口外的所有记录都清除,然后通过Zcard获取窗口内的记录条数,若未超过限制值,则通过Zadd命令加入记录,并且为相应的key设置过期时间防止浪费内存空间。除此之外,redis4.0提供了redis-cell组件,它是通过rust语言实现的,支持实现漏斗限流和令牌桶限流。 - redis单线程为什么还能这么快?
主要有以下几个原因:1:redis的数据存储在内存中,所有操作都是基于内存的、2:redis针对内存操作设计了简单的数据结构,使数据的存取速度更快、3:单线程避免的多线程上下文切换的成本,也不用考虑加锁和死锁问题、4:redis采用了“IO多路复用”,即使是单线程仍能够支持大量的客户端请求。
这里的“IO多路复用”实际就相当于java中的NIO,当我们使用BIO时,每当服务收到请求就需要新建一个线程用于和客户端保持连接,线程在进行I/O操作的时候,由于没有办法知道到底能不能写、能不能读,只能“傻等”,这就导致服务器需要使用大量的线程资源。当服务其的连接数达到十万甚至百万级时,BIO便无能为力了。而NIO是基于事件机制的,会提前注册所有事件和相应的事件处理器,在相应的事件到来时在回调相应的处理器。 - redis持久化的实现方式?
redis的数据都保存在内存里,如果系统宕机就会导致数据丢失,redis通过其持久化机制来保障数据不会丢失。redis的持久化机制有两种:RDB快照、AOF增量日志,快照是某个时间点数据的二进制全量备份,而AOF日志记录的是修改内存数据的指令记录,两者都存储在磁盘文件中。在重启redis恢复数据时,会先加载RBD快照文件,然后再重放本次快照后的AOF文件。因为redis是单线程的,为了防止阻塞对请求的响应,redis会fork子进程来进行快照持久化,而持久化期间客户端请求进行的内存数据的修改都采取COW(copy on write)的方式。redis在内存修改指令执行后会将指令记录存入AOF文件中,由于是文件的形式存储的,所以需要使用fsync命令,确保将数据刷入磁盘,为了保证性能,redis通常每1s执行一次fsync命令。 - redis如何实现分布式锁?(重要!面试常问)
最简单的方式就是使用setnx key value命令,获取特定key的锁,在使用完临界区资源后释放掉该锁。但假如获取锁的机器宕机了就会导致锁一直无法释放,所以在setnx后需要对锁加过期时间,但由于获取锁和设置过期时间不是原子操作,仍然可能存在风险,所以可以使用SET key value [EX seconds] [PX milliseconds] [NX|XX] 命令,在原子的获取锁并设置过期时间。但是这种方式仍存在问题,就是当程序执行时间超过了锁的过期时间,就会导致其他应用也能够获取锁使用临界区资源,同时当程序执行完成后进行解锁时有可能错误的释放其他应用获得的锁,数据的安全性就得不到保障了。此时我们可以使用一个守护线程去为当前应用的锁“续命”,就是增加它的过期时间。
除了上面提到的各种问题外,还有一点需要强调,就是一般redis实例都会进行主从同步,如果主库挂了,在切换到从库时,锁可能会存在丢失的情况,因为redis的主从同步是异步的,不过这种功能情况是比较少,如果想要解决这个问题,那么可以使用redLock,redLock需要提供多个redis实例,这些实例间相互独立且没有主从关系,在申请锁时需要向所有实例发出请求,只有超过半数节点实例通过后才能成功,所以使用redLock的读写性能会相对差些。 - redis的过期策略?
redis的过期策略主要有两种:1.定时扫描策略、2.惰性策略。
定时扫描策略:redis会将所有设置了过期时间的key都放入到一个独立的字典中,之后会定时遍历这个字典来删除到期的key。默认每秒进行10次扫描,过期扫描并不会遍历过期字典中的所有key,而是采用一种贪心策略,每次随机取20个key并删除其中已过期的key,直到过期key的比例不超过1/4。因为redis是单线程的,所以我们应该避免大量key同时过期,这会导致线上的读写请求出现明显的卡顿,因为一方面redis需要持续的扫描过期字典,另一方面内存管理器也需要频繁的回收内存页。
惰性策略:所谓的惰性删除就是在客户端访问这个key时,redis对key的过期时间进行检测,如果过期了就删除。redis并不是纯粹的单线程,他还有几个异步线程专门处理一些耗时任务,比如当惰性删除过期key时,如果被删除的key是一个非常大的对象,此时就会将删除任务放入一个专门的异步队列中,由特定的线程来处理删除任务。 - redis的LRU实现?
当redis使用的内存超出了服务器的物理内存限制后,内存数据会和磁盘进行swap,这会让redis的性能急剧下降,对于redis来说这样的存取效率几乎等于不可用,所以在生产环境中我们经常会禁止内存磁盘swap。在实际内存过大时,redis提供了几种策略来让用户决定如何腾出内存空间,其中默认的策略是禁止服务的写请求,除此之外我们最常用的就是,对设置了过期时间的key根据LRU进行删除。我们都知道可以通过单链表来实现LRU算法,每当某个节点的数据被访问时,就将该节点移到链表的头部,链表尾部的节点就是不常用的可以踢除的数据。但是使用链表结构是需要浪费很大内存空间,所以redis并没有采用这种策略,而是为每个key增加了额外的小字段,用于标识key最近访问时间,在redis执行写操作时,发现内存不足就会在设置了过期时间的key中随意选出5个,并将最近最少使用的key删除掉,直到内存空间足够进行写操作,这个过程只会发生在redis执行写操作的过程中。 - 缓存穿透、缓存雪崩、缓存击穿的解决方案?(重要!面试常问)
【缓存穿透】指的是请求访问了数据库中不存在的数据,缓存中同样不可能存在相应数据。这种情况比较简单的解决方案是对数据库查询出的空数据也设置短时间的缓存。除此之外也可以通过布隆过滤器过滤掉访问不存在数据的请求,也可以在逻辑层对非法数据访问进行控制。
【缓存雪崩】指的是大量key在同一时间过期,这种情况会给数据库带来很大压力,同时redis也需要耗费系统资源处理过期缓存数据。所以我们应该尽量避免出现大量key同时失效的情况,一个比较简单的方案就是在设置过期时间时在固定过期时长后面加上一段随机时长。
【缓存击穿】指的是某些请求量非常大的热点key在过期时,会导致大量查询请求打入数据库。为了解决这个问题:可以在缓存过期后,从数据库读取数据时加一个分布式锁,获得锁的线程查询数据并将数据设置进缓存,其他线程可以在阻塞或睡眠后重新从缓存中读取数据。
(六)spring
- Spring Bean的加载过程?
个人习惯将spring的启动过程分为三个阶段:容器初始化阶段、bean实例化阶段、bean初始化阶段。
(1)容器初始化阶段:该阶段主要是初始化容器上下文环境,检验系统属性和环境变量。这一阶段会注册所有的beanPostProcessor,同时会执行所有的beanFactoryPostProcessor,根据scanning-path扫描所有@Component、@Service等注解标识的类,并将其封装成BeanDefinition存储起来以便后续使用。
(2)bean实例化阶段:该阶段主要是选定合适的构造器构造对象实例,构造出的对象实例仍然需要进行属性的依赖注入。此阶段还会进行循环依赖的检查和处理,还会检查是否@DependsOn依赖的必须构建的实例并进行构建,最终会根据合适的构造函数通过反射或者cglib来构造一个实例对象。
(3)bean初始化阶段:该阶段主要是进行依赖的注入和执行自定义的初始化逻辑,同时也会注册对象实例的销毁逻辑。首先会处理所有的Aware接口,然后执行@PostConstruct定义的方法,之后执行InitializingBean接口的afterPropertiesSet方法,再之后执行自定义的init-method,再然后为对象实例生成动态代理,最后检查是否存在循环引用问题及注册bean销毁的回调接口。
- Spring Bean的循环依赖问题?
在spring中使用三个HashMap处理循环依赖问题(也有人称之为"三级缓存"),这三个HashMap分别为:singletonObjects、singletonFactories、earlySingletonObjects。在每次bean实例化好后都会将获取该bean的工厂对象存入singletonFactories中,如果其他bean在实例化时若依赖该bean就会首先从singletonObjects中获取,获取不到就会去earlySingletonObjects中获取,在获取不到就会从singletonFactories中获取该bean的工厂对象,通过工厂对象获取该bean。之所以使用一个工厂对象是为了通过beanPostProcessor在不同场景下能够返回不同的”半成品“bean。目前spring中循环依赖主要有三种情况:
(1)prototype类型的bean不支持循环依赖
(2)singleton类型的bean在使用构造器注入时不支持循环依赖。在使用构造器注入时,会先通过getBean()方法实例化构造器中的参数对象,如果参数对象又通过构造器注入当前bean,就会产生循环依赖,甚至可能产生死循环,spring在bean构建时会将其放入正在构建中的set集合中,如果出现重复构建就会抛出异常,spring就是通过这种方式解决构造器的循环依赖问题的。
(3)singleton类型bean使用field注入(即@Autowired或@Resource注入)时,在没有动态代理时支持循环依赖。当进行动态代理时分为两种情况:(1)使用普通切面生成代理支持循环依赖、(2)使用@Async等注解生成代理则不支持循坏依赖。这主要是因为使用普通代理时,在当前bean构建中若有其他bean想要注入该bean,那么普通代理会检查当前bean是否有代理切面,若存在的或就为该bean生成代理,并将代理对象返回。而使用@Async等注解在遇到这种情况时,就会直接将当前bean实例返回,最后在初始化bean后生成代理对象,这时其他bean获取的该bean引用是原生对象,而不是该bean的代理,这时spring就会抛出异常。
- Spring Bean的生命周期?(面试未问)
目前spring容器中的bean主要有两种类型:singleton类型、prototype类型。其中singleton类型的bean几乎与spring容器拥有相同的生命周期,singleton类型的bean在容器中只有一个实例,所有对该对象的引用都共享这个实例,该实例在容器启动被第一次初始化后,将一直存活到容器退出。而prototype类型的bean,容器在收到请求时,每次都会返回一个新的对象实例给请求方,和new有点像,但是会进行依赖注入和代理操,对象实例在返回给请求方后,容器就不在拥有该对象的引用,它的生命周期取决于应用方,在应用方使用完成后就有可能被jvm回收掉。 - Spring IoC实现原理?
目前我们常用的spring容器是ApplicationContext,由容器来帮我实现具体的依赖注入。常用的依赖注入方式有两种:构造器注入、setter方法注入,目前spring官方推荐的是使用构造器注入,这主要是因为构造器注入能够保证对象在实例化后就能够正常的使用,而当我们没有使用构造器注入时,如果我们使用new操作去构建对象,构建出的对象不会进行依赖注入,使用时就有可能导致npe异常,而构造器注入就能够避免这个问题,但是构造器注入的写法很麻烦,在依赖的对象很多时代码就会很臃肿,同时使用构造器注入是不能够支持循环依赖的,同时在spring项目中直接new对象的逻辑也不多,所以还是可以直接使用setter注入的。setter注入实际就是我们常用的@Autowired、@Resource注解的注入方式,容器在对象进行依赖注入前就会在对象信息类BeanDefination中扫描出所有@Autowired、@Resource注解标识的属性字段,然后通过getBean()方法获取相应的实例,再通过反射将实例引用set给对应的属性字段。通过构造器注入的方式,在对象实例化时会选择合适的构造器,然后通过getBean获取所有的构造器参数对象,最后利用构造器通过反射或者cglib来生成对象实例,在利用对象实例进行后续的初始化和动态代理等操作。 - Spring AOP实现原理?
spring实现了自己的AOP框架(spring AOP),同时在spring2.0版本中也集成了AspectJ,spring的动态代理主要由两种实现方式:JDK动态代理和cglib动态代理。动态代理能够动态的为目标对象成代理对象,其中JDK动态代理需要目标对象实现特定的接口,而cglib动态代理主要是通过动态的生成目标对象的子类来覆写目标对象的方法。jdk动态代理需要实现InvocationHandler接口,同时通过Proxy类生成相应的代理对象,而cglib动态代理需要实现MethodInterceptor接口,并通过Enhancer类生成特定代理对象。由于是通过继承来实现代理对象,所以cglib动态代理的代理方法不能够使用final、private修饰,否则将导致代理时效,而一旦代理时效,接口的请求将会直接进入代理对象的方法中,因为生成的代理对象是不会进行依赖注入的,所以如果该方法内需要使用其他对象,那就会出现NPE异常,所以在使用cglib动态代理时一定要注意代理方法不能使用final修饰(private修饰的方法只在类内可见,外部对象无法使用,所以这里没提private)。在spring实现的cglib动态代理中,实际上对代理对象的所有接口调用,无论该接口是否有代理,都会先进入MethodInterceptor接口的intercept方法中,spring在intercept方法中会判断相应的接口是否有切面逻辑,如果有的话就执行相应的逻辑,否则就会调用目标对象的原生方法执行。实际上spring的动态代理存在一点的瑕疵,就是在我们对同一个类的接口A和接口B都进行代理时,若接口A中调用了接口B,那么当外部对象调用接口A时,只有接口A会执行切面逻辑,而接口A内部的接口B不会执行切面逻辑,这时因为接口A内保存的是原生对象的引用,所以在接口A内调用接口B,调用的是原生对象的接口B。如果想要解决这种问题需要让原生对象持有它的代理对象的引用,可以通过AopContext.currentProxy()方法获取原生对象当前的代理对象。
Spring Aop主要由几大组件构成,其中主要包括:Joinpoint、Pointcut、Advice、Advisor。其中Joinpoint指的是切面逻辑需要切入的点,在Spring Aop中目前只支持方法调用类型的切入点,而Pointcut就是切入点的具体表达式,我们通过Pointcut可以匹配到具体的Joinpoint。Advice指的是需要切入的具体逻辑,有多种类型的Advice,包括Before Advice、After Advice以及全能的Around Advice。Advisor是Spring Aop切面的概念实体,一般包含一个Pointcut和多个Advice。在同一个Advisor中,若存在多个Advice,则会按照在文件中的出现顺序先后执行,若在不同Advisor中的Advice都匹配到了同一个Joinpoint,那么此时需要通过@Order来标识相应切面类的优先级来指定执行顺序,否则Advice的执行顺序是不确定的。所以一定要注意:如果程序对切面的执行顺序有要求,一定要使用@Order注解标识切面的执行顺序。
Spring在对第一个对象实例化前就会解析所有使用@Aspect修饰的类,并通过反射获取Pointcut和Advice,最后生成Advisor存储起来,在对象初始化完成后,会检测是该对象是否匹配特定的Pointcut,并对需要代理的对象通过cglib或反射生成动态代理。
- Spring事务实现原理?
在一般的事务模型中通常有三个组件:
(1)Application Program(应用程序):应用程序通常用来定义事务的边界及事务的隔离性等各种事务属性。
(2)Resource Manager(资源管理器):一般指数据库实例,提供了事务的具体实现,并且实现了XA协议的所有接口。
(3)Transaction Manager(事务管理器):负责协调各管理事务,提供给应用程序事务管理的接口,一般只有在分布式事务中才会使用事务管理器。
对于Spring而言,事务的实现是由具体使用的数据库实现的,而Spring需要做的一般是界定事务的边界(即事务的开始和结束),定义事务的传播性,定义事务的回滚规则等,然后在合适的场景下调用具体数据库提供的相应事务接口进行事务操作。最简单的事务操作逻辑如下:
由于存在很多不同的数据库访问技术,且这些数据库访问技术都有自己的数据库访问API,为了统一各种数据访问技术的操作流程,spring推出了PlatformTransactionManager接口,其抽象实现类AbstractPlatformTransactionManager使用模板方法,实现了大多数事务操作的通用逻辑,而具体数据访问操作接口需要由子类实现相应的接口,它的子类有:JpaTransactionManager、DataSourceTransactionManager、HibernateTransactionManager等,其中我们最常用的是mybatis提供的DataSourceTransactionManager事务管理器,通常配合hikariCp数据库连接池一起使用。除了统一不同数据访问技术的事务操作逻辑外,spring还通过ThreadLocal存储相应的connection来实现事务的跨方法操作,在事务开始时获取相应的connection,然后将connection绑定到当前线程,数据访问对象在进行数据访问就从线程中获取该connection,最后使用这个connection进行事务的提交或回滚,最后解除它和当前线程的绑定。并且要求不同的数据访问技术处理各自的自定义异常,抛出Spring制定的特定事务异常,这就由统一异常的处理逻辑。
spring为我们提供了很多类型的事务传播行为,其中REQUIRED是我们最常用的事务传播行为,同时也是Spring默认的事务传播行为,在没有事务时创建事务,若已存在事务就加入当前事务。除此之外,对于一些查询方法可以使用SUPPORTS,如果当前存在事务就加入事务,保证事务能够读到实时的数据,而当没有事务时,就正常的使用一致性非锁定读来读取数据。当方法内调用了某个方法,而不希望影响当前的事务,可以使用REQUIRES_NEW标识被调用方法来保证不影响当前事务,例如当前事务执行过程中,需要执行某个方法来记录日志,那么此时就可以使用REQUIRES_NEW标识记录日志的方法,无论日志记录是否保存成功,都不会影响当前事务的进行,REQUIRES_NEW不管当前是否存在事务都会创建新的事务。最后一个是NESTED,就是大名鼎鼎的嵌套事务,在嵌套事务中由一个顶层事务控制各个层次的事务,顶层事务下的嵌套事务被称为子事务,子事务的提交和回滚都需要在父事务提交后生效。在mysql中只能通过SavePoint来模拟实现嵌套事务,用户无法选择哪些锁被子事务继承,事务执行期间子事务可以获得和使用所有父事务持有的锁,这个真正意义上的嵌套事务优有所不同,真正意义上的嵌套事务允许父事务传递特定的锁给子事务,同时SavePoint模拟的嵌套事务也无法实现子事务的并发执行。SavePoint实现的嵌套事务,本质上还是只有一个事务,只不过在事务中使用了很多SavePoint保存点,在出现回滚时,事务可以回滚到特定的保存点。嵌套事务解决的是类似换乘飞机的问题,当我想要从沈阳旅行去大理时,由于某些特殊原因,我们可能需要先坐飞机从沈阳到上海,在从上海坐飞机到大理,在普通事务中当上海到大理的飞机出现延误等异常时,就会使事务回滚到初始点,也就我们需要回到沈阳,这显然是我们无法接受,同时也没有必要的,在这种情景下就出现了嵌套事务。最后需要说的一点是:在通过@Transactional标注方法时,若该方法调用了类内另外一个方法,若该方法也使用了@Transactional,此时@Transactional是没有效果的,这是Spring事务的一个小小的问题。
最后Spring通过AOP解耦了事务逻辑和业务逻辑,使用@Transactional标识的方法或类会生成特定的代理对象,Spring实现了方法拦截MethodInterceptor接口,并在invoke接口中通过PlatformTransactionManager实现了事务的操作逻辑,而具体的事务属性也会利用反射从@Transactional注解中获取,并生成特定的TransactionDefination对象,在实际方法执行前根据定义好的事务传播行为,生成相应的TransactionStatus执行事务,在实际方法执行出现异常时,根据相应的规则进行回滚,在实际方法正常完成时,进行事务的提交并清理ThreadLocal中特定的connection及清理其他事务使用的资源。 - 分布式事务的两阶段提交?三阶段提交?
CAP理论:在存在网络分区的情况下,无法同时保证一致性和可用性。分布式系统的节点往往都是分布在不同的机器上进行网络隔离的,这意味着必然存在网络断开的风险,这种网络断开的场景被称为网络分区,目前大部分的公司基本都会出现网络分区的场景,所以CAP理论一般都是在一致性和可用性间进行取舍,在基于BASE模型的基础上,基本都是不保证数据变更后的强一致性,而是保证数据的最终一致性。redis的主从模式实际上能够保证的是最终一致性,因为redis的主从是异步复制的,而mysql更加多元化,支持同步、异步、半异步的主从复制。
分布式事务相对于单机事务,需要引入事务管理器进行事务的协调,事务管理器控制着全局事务,管理事务的生命周期并协调相应的资源。事务管理器要求数据库实现XA协议的所有接口,同时会提供特定的分布式接口给应用程序。分布式事务通过事务管理器实现了两阶段提交,在两阶段提交的过程中,首先第一阶段会通过事务管理器要求所有的数据库准备相关的资源,如果所有数据库都准备好了,第二阶段就会要求所有的数据库执行commit操作,如果第一阶段执行过程中出现了异常,那么第二阶段就会执行回滚操作,将锁定的资源都释放掉。
在两阶段提交中,如果第二阶段提交过程中,在通知所有数据库commit后,如果某个数据库宕机了,就有可能出现数据库不一致的情况,为了解决这个问题,还出现了三阶段提交,在两阶段提交的基础上增加了preCommit的过程。除了两阶段、三阶段提交外,还可以使用更轻量级的Paxos协议,ZooKeeper就是使用了这种协议。在实际的使用场景中,由于事务管理器的自身稳定性、可用性的影响以及网络通信中可能产生的问题,事务管理器需要处理的情况比较多,同时事务管理器需要记录很多日志,同时执行期间需要锁定很多数据库资源,两阶段提交及三阶段提交的使用会给系统带来很大的开销,所以需要慎重的考虑是否一定需要使用。
除了最终一致性方案、两阶段提交方案外、Paxos协议外,还可以使用TCC分布式事务处理方案。TCC实际上相当于业务层的XA协议,通过在业务代码中控制资源的锁定,来减少数据库资源的锁定,TCC比起两阶段提交性能会更好,但是会增加业务层代码的复杂度会,增加事务的执行时长。一般都是大公司才会使用这种方案,大部分中小型公司使用的还是最终一致性方案。 - 事务型消息队列的实现方式?
在日常的开发中,我们经常会使用到MQ消息队列,消息队列的引入有以下几个优点:(1)可以减少服务的响应时间,提高核心链路的吞吐量。(2)降低应用程序之间的耦合度。(3)高峰期缓存部分消息,提高服务的可用性。日常使用的普通消息都比较简单,实现的成本也比较低,只要将需要发送的消息成功发出即可,消息队列可以实现一些消息持久化或者消息消费确认等功能,但是在一些金融级分布式架构中需要使用事务消息来保障消息的一致性,不过这里的一致性仅仅是最终一致性,因为队列消息本身就是异步的根本无法保证强一致性。事务消息最简单的一个场景就是付款的场景:某个用户进行付款操作,付好款后需要向用户发送短信。付款功能和短信发送在不同的服务中,为了提高付款操作的响应时间,可以使用MQ消息进行两个业务功能的解耦。此时就存在两个操作不一致的问题,如果付款操做更新数据库后,而发短信操作出现了问题,那就出现了操作不一致的问题。为了解决这个问题我们可以在付款操作更新数据库时使用数据库事务,在事务提交前先确认消息是否已经成功发送,成功后再进行commit,但是这种方式又依赖MQ消息的发送,如果MQ消息的发送超时了,那么我们无法确认短信是否已经成功发送了,超时可能是短信发送成功了,也可能发送失败了,那么此时便无法决定是否提交数据库事务了。所以一般我们都会先发出MQ消息的请求,在转账的数据库操作commit后再实际MQ发送消息,这样就能保证两个操作的原子性。但是仍存在一点问题就是在退款操作执行完成后需要通知消息队列发送消息,但是如果这步操作失败了,那么消息队列就无法决定是否能够发送MQ消息了,为了解决这个问题,需要消息队列可以进行特定的回调,主动询问数据库操作是否成功。这就是一般事务消息的实现方案,事务消息的实现会导致消息队列性能下降,在rabbitMq中甚至可能会“吸干”rabbitMq的性能,所以在非必要的场景下一般还是使用普通的MQ消息,如果一定要使用事务消息也需要选择合适的MQ以及进行合适的优化和测试。
(七)中间件相关
- 负载均衡和反向代理(面试未问)
当客户端发送请求到服务端时,请求首先会通过DNS进行域名解析,将相应的域名解析为具体的IP地址,然后与相应的机器通过三次握手建立socket连接并发送http请求。在分布式系统中为了避免单点问题,通常会对服务器进行集群部署,此时为了能够均衡的将请求发送到集群中,通常会引入负载均衡器,DNS返回的永远是负载均衡器器的地址,请求首先会被发送到负载均衡器上,由负载均衡器决定具体转发给哪台服务器。负载均衡技术通常可以分为硬件负载均衡和软件负载均衡,由于软件负载均衡的代价更低且可控性更强,所以使用的更为普遍。为了避免负载均衡器自己成为单点,通常可以使用两台机器构成,其中一台处于服务状态,而另一台处于standby状态,当出现问题时standby可以实现自动接管。可以在负载均衡器内直接配置业务处理机器的IP地址列表,也可以通过consul、zk等中间件获取特定业务处理机器的IP地址列表,之后负载均衡器会通过特定的选择方式转发请求给特定业务处理机器,主要方式包括:1.Hash选择:对应用层的请求信息做hash,从而将请求分配到特定的机器上。这种方式可以应用于静态图片的加载,通过hash保证每次请求都能够访问相同的机器,进而提升服务器性能。2.Round-Robin选择:根据地址列表按顺序选择,这是目前使用最多的方式。除了上面两种比较常用的选择方式外,还有按权重选择、按负载选择、按连接数选择等方式。负载均衡器通过NAT技术修改报文的目标地址和端口号来实现消息的转发,由于请求包和响应包都要经过负载均衡器,随着请求量的上涨,负载均衡器的压力就会迅速上升,尤其是对于响应包非常大的应用,这种方式不但会导致网络流量的增加,也会导致响应的延迟,也会给负载均衡器带来很大的压力,为了解决这个问题可以使用IP Tunneling技术将响应包的数据直接返回给客户端。一般的分布式架构中都会使用反向代理服务器,并通过反向代理服务器实现负载均衡,使用反向代理能够保护内网服务器的安全,节约IP地址资源并降低内部服务器压力,我们常用的反向代理技术主要是Nginx及Nginx扩展技术OpenResty等。 - Http服务器和Application服务器(面试未问)
在经过反向代理服务器后,请求会被直接转交给具体处理请求的业务机器,所有部署在Web上的服务器都被称为Web服务器,而Web服务器按功能又可以分为Http服务器和Application服务器,Http服务器主要用来做静态内容服务、代理服务器、负载均衡等,而Application服务器支持开发语言的运行环境,能够动态的生成资源返回给客户端。最常用的Application服务器是Tomcat,Tomcat运行在JVM之上,用来管理Servlet,主要由两部分组成:connector和container,前者负责接收请求,后者负责处理请求,采用责任链的设计模式,把请求和响应封装好后传给servlet进行处理。Application服务器除了Tomcat外还有Apache和Weblogic,Tomcat能够支持的并发连接数是1000左右,Apache能够支持的并发连接数是200-300左右,而Weblogic的并发连接数平均能达到3000左右。 - 服务注册与发现(面试未问)
在微服务中,一个用户的请求往往涉及多个内部服务调用,每个服务单元为了避免单点问题,通常都是采取集群部署的方式,部署在不同的机器上,在进行服务调用时,需要获取被调用服务的所有机器IP地址列表,此时便需要引入服务的自动注册与发现工具。首先需要部署服务发现服务,各个应用服务在启动时自动将自己的信息注册到服务发现服务上,应用服务启动后能够实时的从服务发现服务上获取被调用服务的地址列表。服务发现服务也会定期检查应用服务的健康状态,去掉不健康的实例地址,这样新增实例时只需要部署新实例,实例下线时直接关停服务即可,服务发现会自动检查服务实例的增减。客户端在获取到被调用服务的所有地址后,可以自己决定负载策略,甚至可以在服务注册时增加些元数据,之后在客户端根据这些元数据进行流量控制、A/B测试、蓝绿发布等。
Consul服务注册中心:Consul用于实现分布式系统的服务注册与发现,Consul 是分布式的、高可用的、 可横向扩展的。Consul可以通过DNS/HTTP接口来进行服务的注册和发现,同时提供了健康监测机制来监测服务机器是否可用,同时支持开箱即用的多数据中心的支持。Consul分为Client和Server两种节点,其中Server节点负责保存数据,Client节点负责健康检查及转发数据请求到Server节点。一般Server节点部署在单独的不同的机器上,来维护Consul Server的稳定性。而一般Client节点和具体的应用(例如Tomcat)部署在同一台机器上,这样能够避免通过网络调用进行健康检查带来的不确定性,防止因为网络环境不好造成的误判,同时也能够避免给业务服务器带来额外的请求压力。Consul使用Gossip协议进行数据通信,同时使用raft算法保证数据一致性。
ZooKeeper简介:Zookeeper是一个开源的分布式协调服务,功能十分强大,除了支持服务注册和发现外,还可以用于负载均衡、集群管理、分布式锁等等。Zookeeper致力于为那些高吞吐的大型分布式系统提供一个高性能、高可用、且具有严格顺序访问控制能力的分布式协调服务。Zookeeper通过树形结构来存储数据,它由一系列被称为ZNode的数据节点组成,类似文件系统的组织结构,但是数据确是存储在内存中的。Zookeeper内的数据节点ZNode可以分为持久节点和临时节点,同时Zookeeper支持通过Watcher对特定的数据节点进行事件监听,当特定事件发生时,监听器会被触发,并将事件信息推送到客户端,该机制是Zookeeper实现分布式协调服务的重要特性。Zookeeper使用ZAB协议进行崩溃恢复和数据广播,同时使用Paxos算法保证数据的一致性:所有的事务请求必须由唯一的Leader服务来处理,Leader服务将事务请求转换为事务Proposal,并将该Proposal分发给集群中所有的Follower服务。如果有半数的Follower服务进行了正确的反馈,那么Leader就会再次向所有的Follower发出Commit消息,要求将前一个Proposal进行提交。Zookeeper通过Watcher机制可以实现服务注册和发现:分布式系统的所有的服务节点可以对某个ZNode注册监听,之后只要有新的服务上线或者下线都会更改该ZNode,所有服务节点都会收到该事件。
不同服务注册中心对比:ZooKeeper因为采用ZNode事件监听机制,能够实时获取服务节点上下线状态,但是需要在服务中使用ZK的SDK,同时原生并不支持数据中心,dubbo内部通过ZooKeeper实现服务注册中心。Consul简单易用,可以使用http/dns进行交互,不需要集成SDK,支持多数据中心,同时还提供web管理界面,但是使用基于接口的健康检查,不能实时获取服务的上线状态。Eureka明显的不同点是:保证了CAP中的AP,而不是CP,也就是只能保证最终一致性,进行数据访问时可能出现数据错误。
- 服务框架/RPC客户端
在没有服务化之前,应用都是通过本地调用的方式来使用其他组件的,服务化会使得原来的本地调用变为远程调用。在服务比较简单时,大家通常会选择实现简单的RPC客户端,但当提供服务的集群非常多的时候,通用的服务框架就非常重要了。简单的RPC客户端实现,可以通过HttpClient或者OkHttp,两者都是Http客户端,通过Http调用其他服务。相对于HttpClient,OkHttp的SDK更容易使用,且性能稍微更好些,目前在Android中得到了广泛的使用。这种RPC客户端通常作为应用的依赖包,与应用一起打包,随应用一起启动,但是如果RPC客户端需要升级,就需要更新应用版本。在微服务架构中,通过这种RPC客户端直接进行服务间调用可能存在一些隐患,在介绍完服务框架后会详细说明,不过因为它的实现简单,所以有很多公司使用该种方式。
服务框架的实现主要包括客户端实现和服务端实现:1.客户端:首先需要根据调用服务名来获取提供服务的机器地址列表,根据特定的负载均衡策略选择要调用的机器。然后将请求数据序列化为二进制数据,并根据特定的协议封装成数据包,选择特定的通信方式进行数据传输。2.服务端:在启动后持续地接受请求,对收到的数据进行反序列化得到请求对象。根据请求接口名和参数定位具体实例,然后通过反射来进行服务调用,最后将执行结果序列化为二进制数据返回给客户端。
服务框架可以通过Consul、ZooKeeper或Eureka作为服务注册中心,客户端通过服务注册中心拿到可用的服务提供者列表后,可以使用特定的负载均衡策略,包括随机、轮询、权重等。除此之外还可以根据业务需求制定特定的路由策略,对客户端请求进行分组,不同组内的客户端请求会打到不同的机器上,这样就能将不同业务的请求隔离开,保护核心业务不被非核心业务影响。在多机房的场景中,也可以通过客户端路由策略,尽量将请求分配到同机房的服务端上,减少请求的响应时间。同时客户端也可以进行特定的流量控制,在使用一些基础服务时,根据请求来源的不同级别进行不同的流量控制,来保证基础服务的稳定。在对请求进行序列化和反序列化时,可以使用java自带的序列化工具,也可以使用jackson、fastjson等工具,在服务层可以使用xml或json等作为序列化数据格式,在通信层可以使用Http协议或者自定义的协议,比如Dubbo使用的就是自定义的Dubbo协议。具体的通信方式通常有BIO、NIO、AIO,其中BIO采用的是阻塞IO的方式,每个请求使用一个线程并需要建立一个连接,这种方式比较简单,但是会消耗很多线程且需要建立很多连接,消耗比较大。服务框架通常采用NIO方式,NIO使用专门的IO线程,由IO线程处理实际的IO操作,同时也不再需要建立大量的连接,在一个连接上便可以处理大量的IO操作。服务框架可以利用NIO提供同步方式进行远程调用,同时也可以支持几种异步方式进行远程调用。服务端通信部分同样不能使用BIO,也要使用NIO来实现,接受到请求后需要进行协议解析及反序列化,之后根据服务名称、接口名称、版本号等找到提供服务的具体对象,然后根据传过来的参数进行方法调用。在服务器端同样也可以设置特定的路由策略,服务端内有很多工作线程池,将请求路由到不同的工作线程池,便能够在服务端进行资源隔离,保证服务端的稳定。同样服务端也可以进行流量控制,对不同的服务调用者进行分级,保证能够对优先级高的服务者优先提供服务。
在微服务架构中直接使用简单的RPC客户端可能存在一些不足:(1)简单RPC客户端不能对请求进行路由分组,当核心服务和非核心服务共用某些基础服务时,可能出现非核心服务将基础服务拖垮,导致基础服务不可用,进而影响核心服务功能的问题。(2)大部分RPC客户端仅支持同步方式进行远程接口调用,甚至很多RPC客户端使用的是BIO通信方式,这将会浪费大量的数据连接,同步请求方式下线程需要阻塞等待RPC客户端返回结果,在一些业务场景中这会浪费宝贵的线程资源,而服务框架可以支持CallBack、Future等异步通信方式,能够提高线程资源的利用率,其中Future能够主动控制超时、获取结果的方式,并且它的执行仍然在原请求线程中,在很多业务场景下能够提升系统单位时间的处理能力。(3)简单RPC客户端无法进行流量控制,没有相应的限流机制,在某些业务高峰期,巨大的请求流量可能会导致系统崩溃。(4)简单RPC客户端没有数据监控,需要自己在应用中通过切面记录请求参数和响应结果,同时也无法支持全链路的日志监控,会导致查询线上问题的难度更大。 - 消息中间件(重要!面试常问)
在微服务架构内,不同服务间除了可以通过服务框架进行RPC调用外,还可以使用消息队列在应用间传输数据。消息中间件提供了有保证的消息发送功能,开发人员无需了解远程调用过程(RPC)和网络通信协议的细节,消息中间件适用于需要可靠数据传输的分布式环境,消息中间件有非常多的优点:(1)解耦:消息队列屏蔽了生产者和消费者间的平台差异,同时允许独立的扩展和修改两边的处理过程,只需确保它们遵守同样的MQ接口规范。(2)异步:消息中间件提供了消息的持久化功能,可以暂时存储MQ消息,生产者和消费者都只需要将消息存入消息队列或者从消息队列中获取消息,即可继续执行其他业务逻辑。(3)削峰:消息中间件在系统访问量剧增时,可以暂时存储大量MQ消息,消费者可以慢慢消费MQ消息。对一台普通的MQ服务器来说,一个队列中堆积1万至10万条消息丝毫不会有什么影响,但是当队列内的消息超过1千万乃至一亿消息时,可能会导致内存或磁盘告警,进而造成Connection阻塞。为了解决问题,可以根据业务特点,选择:清空队列、增加消费者机器或将消息转到其他集群等方案。目前市面上比较主流的消息队列主要有:Kafka、ActiveMQ、RabbitMQ、RocketMQ等,接下来详细介绍RabbitMQ和Kafka,最后介绍各种MQ队列的特点与区别。
RabbitMQ简介:RabbitMQ是采用Erlang语言实现AMQP协议的消息中间件,用在分布式系统中存储转发消息。在RabbitMQ内部包含几大组件:(1)Producer:负责创建消息的生产者,创建的消息一般包括标签和消息体,消息体被称为payload,而标签主要包括交换器Exchange的名称、路由键RoutingKey等等。(2)Broker:消息中间件的服务节点,一般一个RabbitMQ Broker可以简单的看作一个RabbitMQ Broker服务节点,或者RabbitMQ Broker服务实例,且大多数情况下可以将一个RabbitMQ Broker看作一台RabbitMQ服务器。(3)Vhost:Vhost是建立在Broker上的虚拟主机,提供在实例上的逻辑分离,Vhost是RabbitMQ分配权限的最小细粒度,每个Vhost都可以对自己的队列、绑定、交换器进行权限控制。(4)Queue:队列,是RabbitMQ的内部对象,用于存储消息,RabbitMQ中的消息都只能存储在队列中,这一点和Kafka完全不同,Kafka将消息存储在Topic中,而实际的队列逻辑只是Topic实际存储文件中的位移标识。(5)Exchange、RoutingKey、BindingKey:Queue和Exchange绑定时会指定特定的BindingKey,而生产者发送消息到Exchange时,一般会指定一个RoutingKey,Exchange会根据RoutingKey及Queue绑定的BindingKey,将消息投递到特定的Queue中。
RabbitMQ在生产者/消费者与RabbitMQ Broker建立TCP连接时,使用了类似NIO的作法,对TCP连接进行复用,不仅可以减少性能开销,同时也更加便于管理连接。交换器Exchange主要有fanout、direct、topic、headers四种,(1)fanout:将交换器消息路由到所有与该交换器绑定的队列中。(2)direct:将交换器消息路由到RoutingKey和BindingKey完全相同的队列中。(3)topic:提供了类似正则表达式的规则匹配,将消息路由到RoutingKey和BindingKey相匹配的队列中。(4)headers:headers类型的交换器性能很差,也不实用,所以基本很少有人使用。
在分布式系统传输中,消息可靠传输主要分为三个级别:At most once、At least once、Exactly once。在进行数据传输时通常会采用超时策略:在消息超时时,可能是消息已经到达服务器,因故障或其他原因未能返回确认消息,也可能是消息未能到达服务器。在这种情况下At most once不会再进行消息重传,所以服务器最多只能接收到一次消息,存在丢失消息的可能性。而At least once会选择消息重传,这样能保证消息不丢失,但是可能出现重复消息。如果服务器使用了幂等策略,且进行消息重传,这样既能保证消息不会丢失,同时服务器的幂等去重机制能够保证消息的唯一性,这种场景被称为Exactly once。 目前RabbitMQ仅支持At most once和At least once,Exactly once需要在业务客户端中实现,在实际生产环境中,可以根据自身的业务特点进行去重,比如业务消息本身具备幂等性,或者借助redis等其他产品进行去重处理。RabbitMQ为了实现At least once需要确保消息传输过程的可靠性,保证在无网络异常或故障时,消息能够安全正确地传输给消费者。首先需要确保生产者的消息能够成功到达RabbitMQ服务器,可以通过事务机制或生产者确认机制,事务机制的性能比较差,一般比较少使用,通常采用的都是生产者确认机制。在生产者客户端会注册消息确认ACK回调接口和消息异常NACK回调接口,生产者发送消息后无需等待结果,RabbitMQ服务器会在收到消息后会回调生产者客户端ACK接口或NACK接口。在RabbitMQ服务器收到消息后,通过Exchange进行路由时,可能由于没有绑定队列或者没有匹配的绑定导致消息无法路由,此时可以将无法路由的消息返回给生产者或存储到备份交互器(Alternate Exchange)。在将消息路由到相应的队列后,需要对消息和队列进行持久化,来保证RabbitMQ服务器遇到异常时不会造成消息的丢失,不过这样也不是完全可靠的,和mysql持久化redo日志一样,MQ消息会先缓存在操作系统缓存中,定时通过fsync将数据刷到磁盘中,若这个过程中出现故障就可能导致消息丢失,为了进一步确保消息安全可以使用镜像队列。最后消费者消费消息时,需要将autoAck设置为false,通过手动确认的方式保证消费者已经成功地消费消息。
RabbitMQ这款消息队列中间件产品本身是基于Erlang编写,Erlang语言天生具备分布式特性(通过同步Erlang集群各节点的magic cookie来实现)。因此,RabbitMQ天然支持Clustering。这使得RabbitMQ本身不需要像ActiveMQ、Kafka那样通过ZooKeeper分别来实现HA方案和保存集群的元数据。集群是保证可靠性的一种方式,同时可以通过水平扩展以达到增加消息吞吐量能力的目的。 下面先来看下RabbitMQ集群的整体方案(RabbitMQ集群方案):
RabbitMQ集群只会同步元数据,包括:(1)队列元数据:队列名称和它的属性;(2)交换器元数据:交换器名称、类型和属性;(3)绑定元数据:交换器与队列或者交换器与交换器之间的绑定关系;(4)vhost元数据:为vhost内的队列、交换器和绑定提供命名空间和安全属性。RabbitMQ集群中的所有节点都会备份所有元数据信息,但却不会备份MQ消息,即每个节点都存在相同的Exchange、Queue、绑定关系等元数据,但是每个Broker节点Queue内存储的MQ消息都是完全不同的。RabbitMQ要想备份MQ消息数据,需要使用镜像队列,可以将队列镜像到集群中其他Broker节点上,如果集群中的一个节点失效了,队列能够自动切换到另一个节点的镜像队列上。镜像队列的所有请求都是发送或转发到master队列,由master队列进行处理,最后广播请求执行结果给所有slave队列。RabbitMQ并没有采用类似mysql的主主模式,没有在所有的节点中备份MQ消息,主要是考虑到集群的吞吐性能及集群间大量数据同步的性能压力。
RabbitMQ集群一般还会使用HAProxy进行负载均衡,HAProxy支持从4层至7层的网络交换,即覆盖所有的TCP协议,甚至还支持Mysql的均衡负载。为了确保负载均衡服务HAProxy的可靠性,通常还会引入Keepalived工具,它能够通过自身健康检查、资源接管功能做双机热备,在出现问题时,实现关故障转移。Keepalived会将两台Linux机器组成一个热备组,同一时间内热备组只有一台机器Master提供服务,同时Master会虚拟出一个公用的虚拟IP地址,简称VIP,当Keepalived检测到Master宕机或故障时,备份服务器Backup会自动接管VIP并成为Master。
Kafka简介:Kafka虽然也可以作为消息队列中间件,但是定位更偏向于分布式流平台,Kafka主要包含以下组件:
(1)Producer:消息的产生者。
(2)Broker:Kafka的实例,每台服务器上可以有一个或多个Kafka实例,但通常一台服务器只会部署一个Kafka实例。Kafka集群内的Broker都有一个不重复的编号,如图中的Broker-0、Broker-1等。
(3)Topic:消息的主题,可以理解为消息的分类,Kafka的数据就保存在Topic中,在每个Broker上都可以创建多个Topic。
(4)Partition:Topic的分区,每个Topic可以有多个分区,分区的作用是做负载,提高Kafka的吞吐量。 同一个Topic在不同分区的数据是不重复的,Partition的表现形式就是一个一个的文件夹。
(5)Replication:每一个分区都有多个副本,副本的作用是做备胎。当主分区(Leader)故障的时候会选择一个备胎(Follower)上位,成为 Leader。 在Kafka中默认副本的最大数量是10个,且副本的数量不能大于Broker的数量,Follower和Leader绝对是在不同的机器,同一机器对同一个分区也只可能存放一个副本(包括自己)。Kafka采用分区的方式,便于通过增加机器应对日益增长的数据量,同时也可以大幅度的提升系统的吞吐量。
(6)Message:消息主体。
(7)Consumer:即消息的消费方。
(8)Consumer Group:我们可以将多个消费者组成一个消费者组,在Kafka的设计中同一个分区的数据只能被消费者组中的某一个消费者消费。 同一个消费者组的消费者可以消费同一个Topic不同分区的数据,这也是为了提高 Kafka 的吞吐量。
Kafka的Producer采用Push模式将消息发布到Broker,Producer生产的消息写入时都需要经过leader分区处理,每条消息都是被追加到分区中,顺序地写入磁盘,所以可以保证同一分区内的数据是有序的,这点和RabbitMQ是不同的,RabbitMQ无法严格保证消息的有序性。Kafka同样通过ACK应答机制来确保Producer生产的消息不丢失,消息的ACK机制主要有三种模式,可以通过参数设置:
0:代表Producer发送数据,不需要等集群的返回,不确保消息发送成功,可能丢失消息,安全性最低但是效率最高。
1:代表只要Leader分区成功落盘就可以返回ACK消息,若Follower分区同步前Leader分区发生故障,则可能丢失消息。
-1:代表所有Leader分区和Follower分区全部落盘成功后才返回ACK消息,若在发送ACK消息时发生故障,则可能造成数据重复。
当参数设置为-1时就可以达到At least once级别,而当参数设置为0时即可保证At most once级别。0.11版本的Kafka引入了幂等性,当开启幂等功能且参数设置为-1时,即可以保证Exactly once级别。
不同于RabbitMQ,Kafka的Consumer通常采用Pull模式从Broker中读取数据,Pull模式可以根据Consumer的消费能力拉取适量的消息,但当没有消息时可能一直循环获取空数据。Kafka内的消息在被消费后并不会像RabbitMQ一样立刻删除,而是会继续存储很长一段时间。在Consumer进行消费时会维护一个Offset,记录自己消费到的位置,以保证故障恢复后可以继续正常消费。在0.9版本之前,Consumer默认将 Offset保存在ZooKeeper中,从0.9版本开始Consumer默认将Offset保存在Kafka一个内置的Topic中。
各种消息中间件的差异与特点:
目前市面上比较主流的消息队列中间件主要有:Kafka、ActiveMQ、RabbitMQ、RocketMQ等这几种。ActiveMQ和RabbitMQ因为吞吐量不够高且社区活跃度不高,大型互联公司使用的比较少,业务体量一般的公司还有在用的,但是越来越多的公司更青睐RocketMQ这样的消息中间件了。同时在部署方式上,ActiveMQ和RabbitMQ更为复杂,不如本身就是分布式架构的Kafka和RocketMQ,在分布式架构中后两者的扩展伸缩能力更好,同时可以保证消息不丢失。而其中RabbitMQ是通过Erlang语言开发的,很难再进行二次开发和包装。Kafka与其他的消息队列有些不同,Kafka无法像其他消息队列一样提供灵活的消息路由能力,不能设置消息的TTL过期时间,同时也没有死信队列、延迟队列、优先级队列等高级队列功能。另外在进行技术选型时,还需要考虑到各种消息中间件对业务开发语言的支持,虽然RocketMQ更好用,但是却仅仅支持java语言,对很多非java开发的公司而言是没办法使用的。 - mysql和redis集群方案
redis是目前比较常用的分布式缓存存储系统,而mysql也是互联网公司比较流行的关系型数据库管理系统。之前已经介绍过两者的基础知识,这里简单介绍两者的集群方案。
mysql集群:(面试未问)
随着业务的不停发展,通常需要对系统进行水平伸缩,这会导致数据库连接数大量增加,会给数据库连接池带来很大的压力,而大部分数据库对水平伸缩支持都不是很好,可以通过分库分表来减轻数据库的压力。数据库分库分表会使简单的sql语句变得复杂,甚至可能需要引入分布式事务,通常需要在业务系统内增加一个DAL(Data Access Layer)数据库访问层,来透明化分库分表对业务服务器带来的影响。DAL可以根据分库分表规则改变业务请求的sql及要连接的目标数据库,DAL通常会对请求的sql进行解析或提供一种专门的定制sql语言。使用专门的定制sql语言,好处是无需解析sql语句,可以更加简单的获取分表规则所需的信息,但会导致开发的难度增大,同时DBA审核sql的难度也会增大。
数据量增长会导致读写性能下降,除了分库分表外,还可以通过读写分离和多Master方案来提升数据读写性能。读写分离适用于读多写少,并允许一定延时的业务。对于读写比例基本相等的业务而言,如采用读写分离反而会带来大幅度复制,造成系统运行缓慢。实现读写分离后,可以降低数据库读压力及提升数据库读速度。mysql的读写分离通常会配合主从复制一起使用,主库负责写操作而从库负责读操作。
在mysql数据库通过主从复制进行了读写分离后,实际上仍然不能很好地解决单点问题,当主库发生故障时,需要进行一定的改动才能把从库切换为主库。为了保证高可用,可以建立多Master并保证多Master数据强一致:一种方式是进行双写,需要采用两阶段提交、三阶段提交或Paxos算法,保证写数据时多个Master数据一致,在某个Master发生故障时,通过负载均衡器将请求切换到其他Master服务器。第二种方式是引入Keepalived组件实现双机热备,只使用一台MasterA负责数据的写入,另一台MasterB作为backup机器,并使用半同步复制同步MasterA数据,同时MasterB也可以负责处理一部分读请求。
另外目前大部分数据库访问采用的仍然是同步方式,每进行一次数据库操作就需要占用一个数据库连接,并且需要等到数据库操作执行完成后才会将连接释放,这对于高并发系统而言,很容易出现数据库连接不够或数据库竞争激烈的现象。采用异步数据库访问是解决这种问题的一种方式,异步数据库访问能够支持连接的复用,可以做到用很少的连接来支持大量的数据库访问,同时也能够避免连接资源竞争,减轻数据库的压力。
各种类型数据库的优缺点:(1)SQL(关系型数据库):随着数据量的增长,数据库的性能会越来越差,且水平伸缩性很差,只能通过分库分表来提供水平伸缩能力。除此之外,mysql单点问题的通用解决方案是主从模式,但是这种方式在出现故障时,需要手动切换到从库,并且很难保障强一致性。也可以使用多Master,但是需要通过两阶段、三阶段或者Paxos协议来保证数据的一致性,性能就会很差。(2)NoSQL:它建立在分布式系统上,更擅长实现水平伸缩和数据分片,并且具有非常好的性能,但却不能够保证完整的ACID属性,它采用的是最终一致性原则。(3)NewSQL:NewSQL的目标是将SQL的ACID保证与NoSQL的可扩展性和高性能相结合。
redis集群:
很多企业没有使用redis集群,但一般都会做主从,有了主从,当主节点挂掉时,运维可以让从节点来接管。redis同时支持:主节点与从节点数据同步模式、从节点与从节点数据同步模式,redis数据同步是异步的,也就意味着redis只能保证最终一致性,它实现了CAP中的AP。
redis主要两种数据同步方式:(1)增量同步:redis主节点会将数据修改指令记录在本地内存buffer中,然后异步将buffer中的指令同步到从节点。因为内存的buffer是有限的,所以redis只会存储部分指令,redis用来存储指令记录的内存buffer是一个定长的环形数组,如果数组内容满了,就会从头开始覆盖前面的内容。当网络很差的时候,从节点长时间没和主节点进行数据同步,就可能导致从节点落后太多而无法在进行增量同步,这时需要使用快照同步。(2)快照同步:主节点会将当前内存的所有数据全部快照到磁盘文件,然后将快照文件发送给从节点,从节点接受完毕后执行全量加载。
Sentinel哨兵:虽然redis使用了主从数据同步,但是如果主节点宕机了,运维仍然需要手工进行主从切换,同时程序猿也需要修改相应地址并上线,这无疑是很大的问题。为了解决这个问题redis推出Sentinel哨兵,redis哨兵支持在发生故障时自动进行主从切换,程序可以不用重启,这一点是优于mysql的,很多公司的mysql目前没有类似的工具,每次mysql节点发生问题时,都需要手动修改地址并重启服务。redis哨兵可以看成一个zookeeper集群,通常由3-5个节点组成。redis哨兵会持续监控主从节点的健康,当主节点挂掉时自动选择最优从节点进行切换。redis客户端在连接集群时,会首先连接Redis哨兵,通过Redis哨兵查询主节点地址,然后在连接主节点进行数据交互。因为redis采用异步复制,当主节点挂掉时,从节点可能没有收到所有同步消息,这部分未同步消息就丢失了。redis哨兵无法保证消息不丢失,只能尽量减少消息的丢失,在这一点上mysql做的更好,mysql提供了同步复制、半同步复制、异复制方式,当使用同步复制和半同步复制时能够保证消息不丢失。
Codis集群方案:
Codis是国内团队开发的Redis集群方案之,他的创始人同时也开发了分布式数据库TiDB。Codis是一个通过Go语言开发的代理中间件,和Redis一样也使用Redis协议对外提供服务,当客户端向Codis节点发送指令时,Codis负责将指令转发到后面的Redis实例来执行,并将返回结果在转回客户端。客户端操作Codis和直接操作Redis几乎没有区别,Codis是无状态的,它只是一个转发代理中间件,我们可以启动多个Codis节点来提升集群吞吐量。Codis节点主要负责将特定key转发到特定的Redis实例,默认情况下Codis将所有key划分为1024个槽位,它首先对客户端传过来的key通过crc32算法进行hash,再将hash后的整数值对1024取模,得到的余数就是key的槽位。每个槽位都会被映射到一个redis实例中,而这份映射关系就是由Codis维护的,为了保证映射关系数据在多个Codis节点中的一致性,Codis使用zookeeper来管理映射关系数据。
Codis在扩容时,会遍历所有key,然后对需要迁移的key数据逐个迁移到新节点。在扩容过程中,如果刚好有请求打到正在迁移的槽位上,此时Codis无法确定相应的key到底在哪个实例中,此时Codis会立刻强制对当前的key进行迁移,然后再将请求发到新的redis实例上。当增加新的redis实例时,Codis提供了自动均衡功能。
Codis在设计上比Redis Cluster简单很多,他将分布式问题交给了Zookeeper处理,省去了分布式一致性代码的编写维护工作。不过因为增加了Proxy作为中转层,所以会稍微增加一定的网络开销,同时因为不是Redis官方集群方案,所以对Redis新功能的支持都比较慢。
Redis Cluster集群方案:
Redis Cluster采取去中心的集群方案,每个节点负责集群中的一部分数据,节点间通过流言协议交换信息。Redis Cluster将所有数据划分为16384个槽位,每个Redis节点负责一部分槽位,每个redis节点都会保存所有槽位和节点的映射关系,并通过流言协议实时更新映射关系。Redis Cluster会对key值通过crc16算法进行hash,然后将得到的整数值和16384进行取模,得到的余数就是具体的槽位。Redis客户端内同样会保存槽位和节点的映射关系,但可能存在槽位信息不一致的情况,此时需要使用纠正机制来进行槽位信息的调整,当客户端向一个错误的节点发出了指令后,节点发现指令key所在槽位不归自己管理,就会向客户端发送特殊的跳转指令,告诉客户端去连接其他节点,同时客户端也会更新自己的映射关系数据。
Redis Cluster对迁移中的槽位进行请求时,相应的指令会先被发送到旧槽位节点,如果旧节点存在数据就直接返回,如果不存在数据,节点会通知客户端去新槽位节点尝试获取数据。Redis Cluster可以为每个主节点设置从节点来保证高可用,如果某个主节点没有从节点,那么当它发生故障时,集群将会处于完全不可用的状态。
Redis Cluster无需像Codis一样设置中间代理层,能够减少网络开销,但是Redis Cluster内部实现非常复杂,为了实现去中心化,它混合使用了Raft协议和流言Cossip协议,同时还需要进行大量配置参数的调优,如果对Redis Cluster内部实现没有充分了解,发生故障时将会非常难以处理。
- mybatis的二级缓存?(面试未问)
在mybatis中提供了二级缓存,这个二级缓存的实现原理和旧版本mysql内部缓存的实现原理很相近,不过在当前的开发中基本都不被使用,实际开发中缓存功能由业务逻辑通过redis来实现更合适更高效。
(1)一级缓存:在mybatis中一级缓存主要是sqlSession会话级别,它仅仅对一次会话中的数据进行缓存。在某个会话内执行SQL语句时,首次执行它会将从数据库获取的数据存储在一段高速缓存中,今后执行这条语句时就会从高速缓存中读取结果,而不是再次查询数据库,不过随着会话的结束,相应的缓存也会失效。当存在多个sqlSession时,sqlSessionA进行的update操作只会更新sqlSessionA的缓存,而sqlSessionB内的缓存无法被更新,会导致脏数据的产生。mybatis的一级缓存默认是开启的,不过在Spring中使用的mybatis一般每次会话都执行一条语句,所以即使开启了一级缓存实际也用不到相应的缓存数据,同样不存在脏数据问题。
(2)二级缓存:mybatis二级缓存的开启需要在SQL映射文件中添加一行<cache/>,二级缓存是Mapper级别的缓存,多个SqlSession去操作同一个Mapper的sql语句,多个SqlSession可以共用二级缓存,二级缓存是跨SqlSession的。同时二级缓存是事务性的,这意味着增删改查的数据修改在SqlSession完成并提交时才会生效。另外myBatis的二级缓存不适应用于映射文件中存在联表操作的情况,当在其他的Mapper中通过联表操作更新了当前Mapper对应表中的数据时并不会更新缓存,这样就会造成脏数据。另外myBatis的缓存的都是基于本地的,在分布式架构中可能会有多台机器操作同一数据库,这样同样会出现脏数据。 - 分布式session的实现方案?
由于日常使用过程中,浏览器经常需要与web服务器进行多次交互,而Http协议本身是无状态的,为了提高访问的效率和用户的体验,一些与用户状态相关的信息会被保存在服务器端的Session中,而相应的会话标识SessionId则会保存在Cookie中,每次浏览器请求时都会带上这个SessionId来告诉web服务器当前请求属于哪个会话,在web服务器上每个会话都会有独立的存储来保存不同的会话信息。如果遇到禁用Cookie的情况,相应的SessionId会被放到url参数中。而随着服务的发展,当web服务器从单台演变为多台的集群方案,那么Session数据的存放就成了问题:Http请求无法确认相应的Session数据具体存放在哪台机器上?为了解决这个问题出现了以下几种方案。
(1)Session Sticky:通过负载均衡器将特定的请求转发到固定的机器上来保证访问到正确的Session。采用这种方案在某台机器重启或者宕机时就会导致部分用户的Session数据丢失,如果Session数据中保存了用户的登录信息,那么用户就需要重新登录了。另外负载均衡器需要解析应用层的请求信息,需要耗费一定的性能,同时也需要保存特定请求到特定机器的映射关系,会损耗一定的内存,同时在容灾设计等反面会带来一定的麻烦。
(2)Session Replication:web服务器间进行Session数据的主动同步,保证集群中的每台机器上都有相应的Session数据。这种方案明显会增加集群中网络带宽的压力,另外当需要存储的Session特别多,同时集群中的机器数也特别多时,就会造成大量内存空间和网络带宽的浪费。
(3)Session数据集中存储:通过特定的分布式存储系统统一保存Session数据,在需要使用时由web服务请在分布式存储系统中获取。例如可以用Memcache或者redis进行Session数据数据的保存,不过这种方式会增加一些网络请求,所以存在一定的时延和不稳定性,但目前是最通用的Session数据问题解决方案。
(4)Cookie Based:在Cookie中保存特定的Session数据,然后在请求时将Session数据传递给web服务器,这种方案会导致请求数据包增大很多,进而增大带宽消耗,同时也会存在数据安全的风险问题。 - Hystrix熔断组件的使用?(面试未问)
在分布式系统中,服务间通常会有很多依赖,在高并发访问下,这些依赖的稳定性对系统影响非常大,但是依赖有很多不可控问题:如网络连接缓慢、资源繁忙、暂时不可用、服务脱机等。例如:当某个服务的某个接口依赖的数据库出了问题,数据库查询时间从原来的100ms变为5s,在接口Qps维持不变的情况下,很快大部分的Tomcat线程就会被故障接口阻塞占用,这会导致其他接口的请求无法处理,进而导致其他接口响应时间变长,大量的请求堆积无法处理,同时也会造成大量系统资源的浪费,最后故障蔓延致使整个服务不可用。
Hystrix使用命令模式将依赖调用逻辑(请求)封装为HystrixCommand,每个命令在单独线程中/信号量授权下执行。可以配置超时时间,超时时间一般设为比TP99平均时间略高即可,当调用超时时,可以直接返回或执行fallback逻辑。每个依赖提供一个小的线程池(或信号量),如果线程池已满,调用将被立即拒绝,默认使用同步队列(即不存储请求),加速失败判定时间,使用同步队列能够避免线程上线文切换导致的资源消耗。请求执行失败(异常,拒绝,超时,短路)时可以执行特定的fallback(降级)逻辑。Hystrix提供了断路器组件,可以自动运行或手动调用,Hystrix通过滑动窗口监控HystrixCommand执行情况,当HystrixCommand数量达到阈值(默认20),且失败比率超过阈值(50%)时,就会开启断路器,停止服务一段时间(10秒),服务暂停期间,所有请求直接执行特定的fallback(降级)逻辑,之后会允许部分请求进入,如果能够正常处理,就会关闭断路器。
Hystrix使用两种隔离方式:线程池隔离和信号量隔离,来限制依赖的并发量和防止阻塞扩散。(1)线程池隔离:把执行依赖代码的线程与请求线程(如:jetty线程)分离,请求线程可以自由控制离开的时间(异步过程)。 通过线程池大小可以控制并发量,当线程池饱和时可以提前拒绝服务,防止依赖问题扩散。(2)信号量隔离:信号量隔离也可以用于限制并发访问,防止阻塞扩散,与线程隔离最大不同在于执行依赖代码的线程依然是请求线程(该线程需要通过信号量申请)。
Hystrix使用问题:(1)熔断使用位置比较难确定:有人会将熔断逻辑加在RPC客户端层,但需要根据依赖接口设置熔断超时时间,由于无法感知依赖接口变更,可能会出现依赖接口修改导致响应时间大幅增加,进而导致大量接口超时触发断路器开启。大部分情况下,大家喜欢将熔断逻辑加在Controller层,但是很多人会将熔断逻辑加在整个Controller上,一个Controller下一般会有很多接口,当某个接口发生问题时,会导致Controller下所有的接口都不可用,隔离的效果很不好。个人觉得最好的方式是将熔断逻辑加在接口上,为接口设置小线程池实现接口间的隔离,将故障隔离在单个接口维度。(2)熔断超时时间不好设置:需要根据接口的TP99设置熔断超时间,如果监控的TP99数据不准确,可能会导致断路器频繁触发。另外每次修改接口内部逻辑,都可能会导致接口响应时间变化,需要及时更改熔断超时时间。除此之外很多服务的集群只有两台机器,在发布期间,其中一台机器在重启时,请求量全部都打在另一台机器上,可能会导致服务器响应时间变长,同样可能导致断路器开启。(3)隔离线程池的线程数量很难设置:为了防止出现大量上下文切换,Hystrix在隔离线程池中使用的是同步队列,这样在设置线程池内线程数量时,必须要根据接口Qps设置足够的线程数,才能够保证请求被正常处理,需要研发根据监控数据手动计算满足要求的线程池线程数量。
(八)其他知识点
- http和https的区别?https如何保证数据安全?
HTTPS全称HTTP over SSL,简单的说就是在之前的HTTP传输上增加了SSL协议的加密能力,SSL全称安全套接字层,工作于传输层和应用层之间,为应用提供数据的加密传输。在普通的数据传输中,我们通常会在客户端和服务器端使用对称协议进行加密,即两端使用一个相同的秘钥来对传输的数据进行加密和解密。但是由于秘钥同样需要在网络上进行传输,我们仍然需要对秘钥进行保护,为了解决这个问题,我们可以使用非对称加密,就是我们常见的RSA算法,该算法能够生成公钥和私钥,公钥任何人都可见,用来对信息加密,而私钥用来对加密信息进行解密,这种非对称加密算法,加密和解密的耗时都比较长,所以只适合对少量的数据进行加密传输,就刚好可以用来进行对称加密算法的秘钥传输。有了这种加密方式后,整个传输流程看起来安全多了,但是如果在端对端的传输过程中,某一端的传输数据被黑客劫持,那么黑客同样可以自己生成公钥,并和另一端进行数据传输,为了解决这个问题,在使用HTTPS进行数据传输时,需要提供CA办法的数字证书来证明传输者的身份。 - TCP与UDP的区别?
UDP只是在IP数据包上增加了端口等部分信息,是面向无连接的,是不可靠传输,多用于视频通信,电话会议等。与之相反,TCP是面向连接的,是一种端到端间通过失败重试机制建立的可靠数据传输方式,就像是一条一条固定的道路承载着数据的可靠传输。 - Java泛型原理?(面试未问)
泛型的本质是类型参数化,可以解决不确定具体对象类型的问题,如果不使用泛型而使用Object,则在使用不当的情况下可能出现类型转换异常,泛型实际上主要是为了在编译器进行类型检查,保证程序员使用泛型时能够安全的存储数据和使用数据,在完成编译泛型类型就会被擦除,使用泛型的好处:保证类型安全、提升代码的可读性。泛型使用时,约定俗成的符号包括:E代表Element,用于集合中的元素;T代表the Type of object,表示某个类;K代表Key,V代表Value,用于键值对元素。当List和泛型相结合时,可以把泛型的功能发挥到极致。List完全没有类型限制和赋值限制,如果随意使用,可能会造成类型转换异常。List<Object>并不完全等同于List,List<Object>不能接受其他泛型赋值,比如List<Object>不能接受List<Integer>类型变量的赋值,而List却可以。类型List<?>表示接受任何类型的集合赋值,但是赋值之后就不能在随意添加元素了,但仍可以执行remove和clear操作。<? extends T>表示T及T的子类集合,<? super T>表示T及T的父类集合。<? extends T>除了null外不能add任何元素,这主要是因为T及其子类类型很多,无法匹配添加元素的类型,<? extends T>可以进行get操作,但是get的元素类型都是T及其父类。<? super T>可以添加元素,但是只能添加T及T子类的对象,这主要是因为满足了上转型,<? super T>在进行get操作时,虽然能够返回对象,但是只能返回Object类型对象。 - java异常分类?(面试未问)
在java中Throwable是所有异常类的父类,它有两种类型分别为: Error(错误)和Exception(异常)。其中Error为程序无法处理的异常,一般都是严重故障,例如OOM异常、死锁异常等等。而Exception又可分为unchecked exception(RuntimeException)和checked exception。其中checked exception,系统在编译期间就会检查此类异常,并且要求必须进行显示的处理,而unchecked exception不会进行编译器检查。我认为checked exception和unchecked exception的区别在于异常的可预测性,程序中无法预测非预期的异常即为unchecked exception,例如程序中的代码错误导致的数组越界异常以及服务调用超时等异常,并不是我们预期的程序执行结果,这些我们无法预测,也不是我们预期的,出现这种异常需要我们根据错误信息去处理修正,否则程序的运行结果也不是我们预期的。而checked exception是可预测的,我们预期会出现的情况,例如我们查询骑士信息,而骑士信息可能不存在,这种异常是预期内的异常,是可预测的,这种异常即为checked exception,我们可以定义特定的错误码和错误信息传递给调用方,以保证程序的后续流程的顺利执行。
我们通常会使用try catch finally来进行异常的处理,finally中的代码即使发生OOM这种Error错误也会执行,通常用于处理善后清理工作,如果finally代码没有执行,那么可能是程序没执行到代码块,或者try中代码进入了死循环,或者在try中执行了System.exit()操作。在无异常的操作流程中,一般生成的finally字节码会直接在try内代码生成的字节码后面执行。但是当finally中进行了变量操作时,try操作内的字节码执行后,系统会将return结果暂时缓存起来,在finally执行完成后再取出返回,所以即使finally中对返回结果变量进行了操作也不会对最终结果造成影响。但是假如在finally中执行了return操作,那么方法就会直接结束,但是try内的操作已经执行了,在finally中执行return操作和执行边变量操作都是不规范的代码行为。 - java中的反射和内省?(面试未问)
java反射指的是在运行状态中,对于任何一个类都能够知道这个类的属性和方法,对于任何一个对象都能够调用它的任意方法和获取它的任意属性。java反射主要提供以下功能:(1)在运行时判断任意一个对象所属的类。(2)在运行时构造任意一个类的对象。(3)运行时判断任意一个类所具有的成员变量和方法。(4)在运行时调用任意一个对象的方法生成动态代理。
java内省主要针对的是Bean类属性的,主要是利用Bean的getXXX方法和setXXX方法,设置属性值或者获取属性值。一般的做法是通过类Introspector的getBeanInfo方法来获取某个对象的BeanInfo信息,然后通过BeanInfo来获取属性的描述器(PropertyDescriptor),通过这个属性描述器就可以获取某个属性对应的getter/setter方法,然后我们就可以通过反射机制来调用这些方法,使用内省会比反射的性能更好。 - Linux常用命令及CPU消耗分析方法?
(1)cat命令:可以显示整个文件、可以创建文件、可以将几个文件合并。
(2)tail命令:用于显示指定文件末尾内容。
(3)grep命令:是强大的文本搜索命令,支持全局正则表达式搜索。grep 的工作方式是这样的:它在一个或多个文件中搜索字符串模板。如果模板包括空格,则必须被引用,模板后的所有字符串被看作文件名。搜索的结果被送到标准输出,不影响原文件内容。
(4)ps命令:一次性查看当前进程的运行状况,ps -ef:用于显示当前所有进程环境变量及进程间关系,ps -aux | grep apache:与grep联用查找某进程。
(5)free命令:显示系统内存使用情况,包括物理内存、交互区内存(swap)和内核缓冲区内存。
(6)top命令:显示当前系统正在执行进程的相关信息,包括进程ID、内存利用率、cpu占用率等。top命令与cpu相关的主要有几个指标:1.us:表示用户进程执行占用cpu的百分比,造成us高的可能原因是线程正在执行无阻塞的循环、正则、或纯粹的计算等操作,另外频繁的GC也会造成us的飚高。可以首先通过ps命令查询到目标进程id,然后通过top命令查看进程的cpu利用率,通过shift+h可以看出全部线程的cpu利用率,之后利用相应的id就可以转化出十六进制的线程nid,在之后通过jstack导出进程内所有线程的堆栈信息,最后可以通过线程的nid找到特定的线程查看堆栈信息。当程序中存在一些循环扫描任务集的线程时,应该增加一定的sleep操作来避免消耗过多的cpu,还有一种经典的场景是状态的扫描,例如某线程要等其他线程执行完成后才能继续执行,这种情况可以使用wait/notify策略。
2.sy:表示内核状态占用百分比,sy的飚高主要原因是线程频繁的上线文切换,造成这种情况主要有三个原因:(1)线程的数量过多,并不是系统中的线程数越多,系统的吞吐量就越大,应该为系统设置合理的线程。(2)线程间锁的竞争非常激烈,导致频繁的上线文切换。这种情况需要我们合理的处理锁的使用,尽量减少锁的范围,保证锁的快进快出。除此之外可以根据锁内不同功能变量对锁进行锁分解,根据读和写的场景分别使用读写锁。另外可以根据锁内的独立变量对锁进行锁分段,比如java8前的concurrentHashMap就会将锁分段为16个来减少锁的冲突。(3)还有一种情况就是系统中存在大量的同步阻塞操作会导致大量的线程资源浪费,例如同步文件IO、同步网络IO、sleep操作等,这些操作会导致线程挂起,但是线程并不会释放资源,进而就导致系统需要生成更多的线程去处理其他事务,可以通过协程来解决这种问题。
3.id:表示cpu空闲所占的百分比。
4.wa:表示执行过程中等待IO所占的百分比。
5.hi:表示硬件中断所占的百分比,例如网卡接受数据频繁的状况。 - 线程间的通信方式?(面试未问)
(1)基于共享容器协同:比如在生产消费模型中,我们便可以使用阻塞队列来实现线程间的协同。
(2)基于事件协同:当线程间存在状态依赖时,就需要相应的工具来完成线程间同步,比如LockSupport.park、Thread.join、countDownLatch、CyclicBarrier等等。
(3)基于进程内的共享内存:可以通过共享的进程内的内存空间进行数据传输,但是需要特定的锁或同步工具保证数据的线程安全。 - 进程间的通信方式?(面试未问)
(1)管道:它是半双工的,数据只能在一个方向上移动,具有固定的读端和写端,它只能用于具有亲缘关系的进程间通信(父子进程或兄弟进程),它可以使用read、write函数,但是它不是普通的文件,且只存在于内存中。
(2)命名管道:它是一种文件系统,可以在无关的进程间交换数据,但是速度比较慢,这种文件系统也具有管道的特性,数据先进先出,数据被读取后也会被清除。
(3)消息队列:消息队列是放在内核中的消息链表,一个消息队列由一个标识符来标识。消息队列内的记录有特定格式和优先级,消息可以独立于发送进程和接受进程,除了支持先进先出的查询,同样支持随机查询,但是能够存储的数据量比较少。
(4)信号量:信号量的实现基于操作系统的PV操作,程序对信号量的操作都是原子操作,它主要实现的是进程间的互斥和同步,并不能实现进程间的数据传输。
(5)共享内存:多个进程同时共享某块内存区域,是最快的进程间交互方式,但是需要配合信号量一起使用以保证数据的安全,但因为进程是资源独立的,多进程内存共享的代价比多线程大的多,会涉及序列化和反序列化的开销。 - 单例模式Singleton介绍?
Spring Bean的生命周期有Singleton类型,虽然和单例模式名字相同,但两者语义是不同的:Singleton类型的bean是由容器来保证这种类型bean在容器中只存在一个,而单例模式则是保证在同一个ClassLoader中只存在一个这种类型的实例。单例模式主要有以下几种实现方式:
(1)懒汉模式:
(2)饿汉模式:
(3)双重检测锁定(Double Check Lock)
(4)静态内部类单例模式
(5)枚举单例模式
- 工厂模式介绍?
工厂模式可以把对象的创建和使用过程解耦开来,同时可以减少重复代码,并且降低代码的维护成本。工厂模式主要分为:简单工厂模式、工厂方法模式、抽象工厂模式。
(1)简单工厂模式:
(2)工厂方法模式:
(3)抽象工厂模式:
- 使用python和java进行后端开发的优缺点?
java是半编译半解释型的强类型静态语言,而python是解释型的强类型动态语言。java需要事先给变量进行数据类型定义,而python在运行时才会进行数据类型检查,所以python是动态语言而java是静态语言,不过动态语言无法通过编译器检查变量类型,会增加代码开发复杂度,在使用python开发时就经常会出现类型错误的问题。强类型语言在变量类型确定后,就不能够再随意改变变量类型(除了强转外),java和python都是强类型语言。
使用python作后端开发语言,编码难度不高,功能实现速度快。但是相对于java而言,使用python进行后端开发却存在两个大问题:(1)性能不佳:单机python后端应用能够支撑的Qps比较低。一个比较重要的原因是python默认实现是单线程,对多核CPU资源利用不够充分,通过使用异步tornado等框架能够提升些性能,但是效果依然不佳。部署同样功能的后端服务,python应用使用的机器数可能比java应用使用的机器数多很多。(2)跨平台性比较差:一般使用python需要很多扩展组件,但是很多组件平台兼容性不好。当使用windows电脑进行python开发时就会非常麻烦,需要在电脑内安装linux虚拟机,在虚拟机中进行开发,跨平台性完全没法和java比。
相关文章
暂无评论...