JVM
内存模型
线程共享隔离
- 线程共享:堆,方法区(包括运行区常量池)
- 线程隔离:方法栈,本地方法栈,程序计数器
解释
- 堆:在虚拟机启动的时候创建,被所有线程共享。对象存在的地方,GC 的主要场所。
- 方法区:线程共享的区域。存放被加载的类信息,常量,静态变量,即时编译后的代码等数据。
- 运行时常量池:存储在类加载后生成的各种字面量和符号引用。
- 方法栈:线程私有,与线程的生命周期相同。描述 java 执行的内存模型:每个方法执行是都会创建一个栈帧用来存储局部变量表,方法出口,操作数栈,动态链接等,方法开始时入栈,结束时出栈
- 本地方法栈:与虚拟机栈类似,只不过执行的是 Native 方法
- 程序计数器:线程私有的,是当前执行的字节码行号指示器
- 直接内存:并不是运行时数据区的一部分,也不是 jvm 规范中定义的内存区域,但被频繁使用。NIO 类引入了一种基于 channel 和 buffer 的 IO 方式,可以直接使用 Native 函数库分配堆外内存,然后通过
DirectByteBuffer
对象作为这块区域的直接引用进行操作。
对象创建时内存分配方式
- 指针碰撞,规整。Serial、ParNew 等带压缩整理过程的收集器时采用
- 空闲列表,不规整,已使用和未使用交错。CMS 这种基于清除算法的收集器是理论上(实际上有一块较大的缓存,缓存中用指针碰撞)采用
- 分配在并发的情况下也不是线程安全的,解决有两种方式:
- 同步,实际上采用 CAS 配上重试方式保证原子性
- 把内存分配的动作按照线程划分在不同的空间,即使用本地线程分配缓冲(TLAB),只有当缓冲用完后才需要同步分配新的缓冲空间
GC
回收哪里的对象:
堆,方法区,本地方法栈,方法栈
哪些对象需要被回收:
当对象访问不到时会被回收,不再被任何存活的对象继续引用。
- 计数法
- 根搜索法:以根对象集合作为起点,从上到下的方式搜索被根对象集合所连接的对象是否可达。根对象集合包括:栈中对象引用,本地方法栈对象引用,常量池对象引用,方法区类静态属性对象引用,与类唯一对应的 Class 对象
何时回收
- 对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC
- Full GC 触发条件
- 调用 System.gc()。只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
- 老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
- 空间分配担保失败。使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC
- JDK 1.7 及以前的永久代空间不足。在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。
- Concurrent Mode Failure。执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。
方法
- 标记清除:标记后直接回收
- 复制算法:用一半内存,空一半,直接把还有用的复制到空的一半
- 标记-整理:标记后,把存活对象复制到内存一端
- 分代回收算法:把堆分为新生代和老年代,新生代用复制算法,老年代用标记清除/整理
类的生命周期
加载–》验证–》准备–》解析–》初始化–》使用–》卸载
synchronized 原理
- 可重入、互斥锁
- 三种使用方式:
- 1)修饰方法,锁定当前对象
- 2)修饰静态方法,锁定的当前类的 Class 实例
- 3)修饰代码块,锁定指定的对象
- synchronized 用的锁是存在 Java 对象头里的。JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步。代码块同步是使用 monitorenter 和 monitorexit 指令实现的,monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处。任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态。根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加 1;相应地,在执行 monitorexit 指令时会将锁计数器减 1,当计数器被减到 0 时,锁就释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
synchronized 锁优化
- 锁消除:代码要求同步,但被检测无竞争的锁进行消除。
- 锁粗化:粗化对于一个锁的请求和释放
- 轻量级锁:轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要 CPU 从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放
- 自旋锁:锁被其他线程持有,当前等待一定时间次数的忙循环,不放弃运行时间
- 自适应自旋锁:旋转时间不固定,由上一次同一个锁的自旋时间和当前锁状态决定
- 偏向锁:大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。偏向锁不主动释放,在产生竞争时判断是否退出同步块,若退出则释放,未退出则升级为轻量级锁
内存溢出问题排查
- 配置 jvm 参数,生成内存溢出是的堆存储快照,
-XX:+HeapDumOnOutOfMemoryError
- 使用 Memory Analyzer 查看 dump 出的文件
Jvm 监控工具
工具 |
作用 |
jconsole |
监控当前执行中的 java 进程 |
jps: |
查看所有的 java 进程 |
常用参数
参数 |
描述 |
-Xms |
设置 Java 堆大小的初始值/最小值。例如:-Xms512m (请注意这里没有”=”). |
-Xmx |
设置 Java 堆大小的最大值 |
-Xmn |
设置年轻代对空间的初始值,最小值和最大值。请注意,年老代堆空间大小是依赖于年轻代堆空间大小的 |
-XX:PermSize=n [g/m/k] |
设置持久代堆空间的初始值和最小值 |
-XX:MaxPermSize=n[g/m/k] |
设置持久代堆空间的最大值 |
JDK 源码
JDK 线程池实现
CurrentHashMap 实现
concurrent 包中的类
CyclicBarrier
字面意思回环栅栏,通过它可以实现让一组线程等待至某个状态之后再全部同时执行。叫做回环是因为当所有等待线程都被释放以后,CyclicBarrier 可以被重用。我们暂且把这个状态就叫做 barrier,当调用 await()方法之后,线程就处于 barrier 了。
CountDownLatch
利用它可以实现类似计数器的功能。比如有一个任务 A,它要等待其他 4 个任务执行完毕之后才能执行,此时就可以利用 CountDownLatch 来实现这种功能了。
Semaphore
Semaphore 翻译成字面意思为 信号量,Semaphore 可以控同时访问的线程个数,通过 acquire() 获取一个许可,如果没有就等待,而 release() 释放一个许可。
总结
- CountDownLatch 和 CyclicBarrier 都能够实现线程之间的等待,只不过它们侧重点不同:
- CountDownLatch 一般用于某个线程 A 等待若干个其他线程执行完任务之后,它才执行;
- 而 CyclicBarrier 一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行;
- CountDownLatch 是不能够重用的,而 CyclicBarrier 是可以重用的。
- Semaphore 其实和锁有点类似,它一般用于控制对某组资源的访问权限。
Java 基础
Object 类中的方法
jdk8 ~ 11 升级
java 8:
-
lambda 表达式:匿名内部类的特殊化,函数式接口对象的实现。但与匿名内部类不同,每个内部类都有独立的类文件,类加载增加了相当打的运行时开销,lambda 没有使用独立的类文件,而是使用了 invokedynamic 字节码指令,避免了独立类文件的空间开销以及加载类的大量运行时开销。
- hotSpots 取消了永久代,增加了元空间。元空间存在于本地内存,是 jvm 规范中的方法区的实现。原因是这样元空间的大小仅受本地内存的限制。
- Metaspace
- 什么是 Metaspace :用来存放 class metadata(记录一个 java 类在 jvm 中的信息),如 Klass 结构(java 类在虚拟机内部的表示)、method metadata(方法的字节码、局部变量表、异常表、参数等信息)、常量池、注解、方法计数器(记录方法调用次数,辅助 JIT 决策)
- 什么时候分配 Metaspace :当一个类加载时,他的类加载器会负责在 Metaspace 中分配空间
- 什么时候回收 Metaspace :分配给一个类的空间,归属与这个类的类加载器,只有当这个类加载器卸载的时候,这个空间才会被释放。所以,只有当这个类加载器加载的所有类都没有存活的对象,并且没有到达这些类和类加载器的引用时,相应的 Metaspace 才会被释放。一个例外是匿名内部类,他们拥有自己独立的 ClassLoaderData,它的生命周期是跟随这个匿名类的,而不是类加载器。
- Metaspace GC:
- 分配空间时:当已分配的空间超过阈值时,虚拟机会在新的空间分配申请时收集可以卸载的类加载器,从而达到空间复用的目的,而不是扩大空间,这时会出发 GC。这个阈值会上下调整,和 Metaspace 已经占用的操作系统内存保持一个距离
- 碰到 Metaspace OOM 时:Metaspace 的总使用空间达到了 MaxMetaspaceSize 设置的阈值,或者 Compressed Class Space 被使用光了,如果这次 GC 真的通过卸载类加载器腾出了很多的空间,这很好,否则的话,我们会进入一个糟糕的 GC 周期,即使我们有足够的堆内存。所以千万不要将 MaxMetaspaceSize 设置得太小
String s = new String(“abc”) 产生了几个对象?分别放在哪里?JDK1.8 前后存放的区域有什么不同?
2 个对象:堆中new String()
;字符串常量池中abc
JDK1.8 后,字符串常量池从永久代移动到了元数据区。为什么呢?
- 字符串在永久代中,容易出现性能问题和内存溢出
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低
ArrayList
和LinkedList
ArrayList
基于数组。使用索引在数组中搜索和读取是很快的,并且内存也是相邻的所以大部分是顺序读。但在增删数据时性能不好,因为涉及移动和扩容
LinkedList
基于双向链表。随机访问性能差,但增删操作更快,同时需要更多的内存。
线程的生命周期
通用的五个状态:
- 初始:指的是线程已经被创建,但是还不允许分配 CPU 执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建
- 可运行:指的是线程可以分配 CPU 执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配 CPU 执行
- 运行:当有空闲的 CPU 时,操作系统会将其分配给一个处于可运行状态的线程,被分配到 CPU 的线程的状态就转换成了运行状态
- 休眠:运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态
- 终止:线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了
Java 中的生命周期
- NEW(初始化)
- RUNNABLE(可运行/运行)
- BLOCKED(阻塞)
- WAITING(无时限等待)
- TIMED_WAITING(有时限等待)
- TERMINATED(终止)

状态转换:
- RUNNABLE 与 BLOCKED 的状态转换:只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁。synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态
-
RUNNABLE 与 WAITING 的状态转换:
- 获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法
- 调用无参数的 Thread.join() 方法
- 调用 LockSupport.park() 方法。其中的 LockSupport 对象,也许你有点陌生,其实 Java 并发包中的锁,都是基于它实现的。调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE
-
RUNNABLE 与 TIMED_WAITING 的状态转换
- 调用带超时参数的 Thread.sleep(long millis) 方法
- 获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法
- 调用带超时参数的 Thread.join(long millis) 方法
- 调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法
- 调用带超时参数的 LockSupport.parkUntil(long deadline) 方法
- 从 NEW 到 RUNNABLE 状态
- 从 RUNNABLE 到 TERMINATED 状态
- 运行完成后 或 抛出异常
- 强制终止:
stop()
interrupt()
。被 interrupt 的线程可以通过异常或主动检测的方式收到通知。
悲观锁和乐观锁的区别
优点:
无锁方案相对互斥锁方案,最大的好处就是性能
- 互斥锁方案为了保证互斥性,需要执行加锁、解锁操作,而加锁、解锁操作本身就消耗性能
- 同时拿不到锁的线程还会进入阻塞状态,进而触发线程切换,线程切换对性能的消耗也很大
问题:
synchronized
和ReentrantLock
的区别
synchronized 是 Java 内建的同步机制,它提供了互斥的语义和可见性,当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那里。在代码中, synchronized 可以用来修饰方法,也可以使用在特定的代码块儿上,本质上 synchronized 方法等同于把方法全部语句用 synchronized 块包起来。
ReentrantLock 它的语义和 synchronized 基本相同。再入锁通过代码直接调用 lock() 方法获取,代码书写也更加灵活。ReentrantLock 提供了很多实用的方法,能够实现很多 synchronized 无法做到的细节控制,比如可以控制 fairness,也就是公平性,或者利用定义条件等。但是,编码中也需要注意,必须要明确调用 unlock() 方法释放,不然就会一直持有该锁。
ThreadPoolExecutor 的工作流程
-
每次提交任务时,如果线程数还没达到 coreSize 就创建新线程并绑定该任务。所以第 coreSize 次提交任务后线程总数必达到 coreSize,不会重用之前的空闲线程。在生产环境,为了避免首次调用超时,可以调用 executor.prestartCoreThread()预创建所有 core 线程,避免来一个创一个带来首次调用慢的问题。
-
线程数达到 coreSize 后,新增的任务就放到工作队列里,而线程池里的线程则努力的使用 take()阻塞地从工作队列里拉活来干。
-
如果队列是个有界队列,又如果线程池里的线程不能及时将任务取走,工作队列可能会满掉,插入任务就会失败,此时线程池就会紧急的再创建新的临时线程来补救。
-
临时线程使用 poll(keepAliveTime,timeUnit)来从工作队列拉活,如果时候到了仍然两手空空没拉到活,表明它太闲了,就会被解雇掉。
-
如果 core 线程数+临时线程数 > maxSize,则不能再创建新的临时线程了,转头执行 RejectExecutionHanlder。默认的 AbortPolicy 抛 RejectedExecutionException 异常,其他选择包括静默放弃当前任务(Discard),放弃工作队列里最老的任务(DisacardOldest),或由主线程来直接执行(CallerRuns),或你自己发挥想象力写的一个。
创建多少线程才是合适的
在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升 I/O 的利用率和 CPU 的利用率。单核时代多线程用来平衡 I/O 和 CPU。多核时代用来充分利用 CPU。
- 对于 CPU 密集型计算,多线程本质上是提升多核 CPU 的利用率。对于 CPU 密集型的计算场景,理论上“线程的数量 =CPU 核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率
- 对于 I/O 密集型的计算场景,遵循这个公式
最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]
Spring
Spring IOC
控制反转,是依赖倒置的一种实现形式。
流程
初始化
容器初始化的过程就是将我们定义在 xml 或者使用注解的 bean 信息注册到 ioc 容器的过程。首先会做的是解析元信息,然后将解析到的信息封装成 BeanDefinition 对象,最后将对象保存在 BeanDefinition 容器(一个 hashmap)中。
依赖注入
容器注册完成 BeanDefinition 后,如果 bean 没有设置 lazyInit 会对 bean 进行实例化,如果设置了会在第一次调用时对 bean 实例化。首先取得 BeanDefinition,然后根据里面的信息循环调用得到依赖的 Bean,这里会触发一个第归调用 getBean 方法,直到当前初始化 bean 的所有依赖都得到,将他们注册到当前 bean 的依赖关系中。然后进行当前 bean 的创建,并根据 BeanDefinition 设置它的依赖和属性。
bean 生命周期
- Bean 实例的创建
- 设置实例的属性
- 调用 Bean 初始化方法
- 应用可以通过 IOC 容器使用 Bean
- 当容器关闭时,调用 Bean 销毁的方法
FactoryBean
FactoryBean 是一个类似于 AbstractFactory,在获取 Bean 的时候如果发现是 FactoryBean 将调用 getObject 返回其生成的对象。
Spring AOP
AOP 面向切面编程,生成代理类
spring 事务传播机制如何实现的
spring aop 使用注意点是什么
- 只有 public 方法才能使用 aop 的方式增强实现。因为 spring aop 是通过动态代理实现的
- 在类内部的方法互相调用不会被增强实现。因为类内部通过 this 互相调用,而不是 spring 生成的代理对象
Spring Bean 创建过程
分布式缓存设计
问题与解决
MySQL
锁
粒度:服务器,表,行
- MyISAM:支持服务器和表级锁
- InnoDB:都支持。行级锁锁定的是索引,所以没有索引会直接锁表
表级锁与后续操作关系
关系 |
A 读锁表 |
A 读其他 |
A 写 |
B 读 |
B 写 |
B 加读 |
B 加写 |
A 加读表 |
可以 |
报错 |
报错 |
可以 |
阻塞 |
可以 |
阻塞 |
A 加写表 |
可以 |
报错 |
可以 |
阻塞 |
阻塞 |
阻塞 |
阻塞 |
行级锁与后续操作关系
关系 |
B 加行共享锁(隐式添加共享意向锁) |
B 加行排他锁(隐式添加排他意向锁) |
B 加表共享锁 |
B 加表排他锁 |
A 加行共享锁(隐式添加共享意向锁) |
可以 |
可以 |
可以 |
阻塞 |
A 加行排他锁(隐式添加排他意向锁) |
可以 |
可以 |
阻塞 |
阻塞 |
意象锁:简化加行级别锁后,再加表锁时的 check
隔离级别
ACID:原子(atomicity),一致性(consistency),隔离性(isolation),持久性(durability)
|
脏读 |
不可重复读 |
幻读 |
read uncommitted |
Y |
Y |
Y |
read committed |
N |
Y |
Y |
repeatable read(default) |
N |
N |
Y |
serialized |
N |
N |
N |
并发处理事务可能的问题:
- 更新丢失
- 脏读:读到其他事务未提交的数据
- 不可重复读:同一事务中两次读,数据值不同
- 幻读:同一事务中两次读,数据数量不同
索引
类型
- 主键:一种特殊的唯一索引,不允许有空值。
- 唯一键:索引列的值必须唯一,但允许有空值。
- 普通
- 组合(最左前缀):为了更多的提高 mysql 效率可建立组合索引,遵循”最左前缀“原则。创建复合索引时应该将最常用(频率)作限制条件的列放在最左边,依次递减。组合索引最左字段用 in 是可以用到索引的。
聚集索引
- 聚集索引:索引中键值的逻辑顺序决定了表中相应行的物理顺序(索引中的数据物理存放地址和索引的顺序是一致的);索引的叶子节点放置的是表列数据。
- 非聚集索引:索引的逻辑顺序与磁盘上的物理存储顺序不同。
Inno DB 的聚集索引规则:
- 如果一个主键被定义了,那么这个主键就是作为聚集索引
- 如果没有主键被定义,那么该表的第一个唯一非空索引被作为聚集索引
- 如果没有主键也没有合适的唯一索引,那么 innodb 内部会生成一个隐藏的主键作为聚集索引,这个隐藏的主键是一个 6 个字节的列,改列的值会随着数据的插入自增。
执行过程
- 链接器(登录用户认证)
- 查询缓存(若缓存中存在,则直接返回)
- 查询分析器(是否有语法错误)
- 优化器(优化查询语句,制定执行计划)
- 执行器(操作引擎,返回结果)
- 存储器(存储数据,提供读写接口)
ElasticSearch
设计优点:
- API 设计的好,简洁易用
- 分布式存储,每个索引可以设置分区和备份,防止数据的丢失
- 写操作会被转发到主分区,但备份可以进行读操作的计算,增加效率
- 一个搜索会在多台机器上分布式的进行,提升搜索效率
- 删除和更新,都是先标记为逻辑删除,再新增数据接在后面,可以保证一定的顺序存储,提升读取效率
- ES 的倒排索引是不可变得
- 好处是:不需加锁,可以一直放在缓存中,也可以整块压缩节约 io 和 cpu
- 坏处是:修改需要重新构建索引
建议
- 不要返回很大的结果集。es 被设计为搜索引擎,非常擅长返回满足查询的前几个 document。es 并不擅长返回所有数据。如果一定要这么做需要使用
scroll api
- 避免很大的 document。默认最大限制是 100MB,可以修改但底层的 lucene 限制为 2GB。大 document 会给网络、磁盘和缓存带来巨大压力。当文章真的很长时,可以考虑改变 doc 的单位,如分段。
Recipes
- 混合精确查询和提取词干:对于搜索应用,提取词干(stemming)都是必须的。例如查询 skiing 时,ski 和 skis 都是期望的结果。解决方法是:使用 multi-field。同一份内容,以两种不同的方式来索引存储
- 获取一致性的打分。原因就是标注为“已删除”的文档。如你所知,doc 更新或删除时,旧 doc 并不删除,而是标注为“已删除”,只有等到 旧 doc 所在的 segment 被 merge 时,“已删除”的 doc 才会从磁盘删除掉。索引统计(index statistic)是打分时非常重要的一部分,但由于 deleted doc 的存在,在同一个 shard 的不同 copy(即:各个 replica)上 计算出的 索引统计 并不一致。
- 使用
preference
参数查询,例如 user session 为xyzabc123
,查询为GET /_search?preference=xyzabc123
- 如果数据很少,可以将所有数据放到一个 shards 中
- 使用
dfs_query_then_fetch
搜索类型
但,如果用户就是要查询 skiing 呢?
Redis
Redis 为什么是单线程,如何利用多核 cpu 机器
因为 CPU 不是 Redis 的瓶颈。Redis 的瓶颈最有可能是机器内存或者网络带宽。而且 Redis 操作内存中的数据,采用多线程可能有加锁解锁的性能消耗。既然单线程容易实现,而且 CPU 不会成为瓶颈,那就顺理成章地采用单线程的方案了。
如果万一 CPU 成为你的 Redis 瓶颈了,或者,你就是不想让服务器其他核闲置,那怎么办?
那也很简单,你多起几个 Redis 进程就好了。Redis 是 keyvalue 数据库,又不是关系数据库,数据之间没有约束。只要客户端分清哪些 key 放在哪个 Redis 进程上就可以了。redis-cluster 可以帮你做的更好。
如何用 zk 实现分布式锁,与 redis 分布式锁有和优缺点
Redis 设置值和超时时间,设置成功表示加锁成功
Zookeeper 创建临时节点,创建成功就代表加锁成功;失败的监听这个节点,当节点删除后,再次尝试
Tips
Error
和Exception
Error 和 Exception 都是继承于 Throwable,Error 和 RuntimeException 及其子类称为未检查异常,其它异常成为受检查异常(Checked Exception)。
Error 类一般是指与虚拟机相关的问题,如系统崩溃,虚拟机错误,内存空间不足,方法调用栈溢出等。对于这类错误的导致的应用程序中断,仅靠程序本身无法恢复和预防,遇到这样的错误,建议让程序终止。如StackOverFlowError
,outOfMemoryError
Exception 类表示程序可以处理的异常,可以捕获且可能恢复。遇到这类异常,应该尽可能处理异常,使程序恢复运行,而不应该随意终止异常
RuntimeException 其特点是 Java 编译器不去检查它,处理 RuntimeException 的原则是:如果出现 RuntimeException,那么一定是程序员的错误。如IndexOutOfBoundsException
,RuntimeException
Checked Exception,继承自Exception
,必须被显式地捕获或者传递。如IOException
,NoSuchFieldException
覆盖equals()
必须满足的约定:自反性,对称性,传递性,一致性,x.equals(null)
必须为false
注意:
- 覆盖 equals 时总要覆盖 hashCode:如果不这样做的话,就会违反 Object.hashcode 的通用约定,如果两个对象根据 equals(Object)方法比较是相等的,那么调用这两个对象中任意一个对象的 hashCode 方法都必须产生同样的整数结果
- 不要将 equals 声明中的 Object 对象替换为其他的类型
重写(overloading)与重载(overwrite)
重写是在同一个类中定义的方法名相同但参数不同的一组函数;重载是子类中重写父类中方法,必须与父类中方法同名且参数一致,子类的访问性不能小于父类的定义
final, finally, finalize
- final 可修饰类(不能被继承),方法(不能被重载),变量(只能赋值一次)
- finally 用于异常处理,表示这段语句最终一定会被执行(不管有没有抛出异常)
- finalize()是在 java.lang.Object 里定义的,也就是说每一个对象都有这么个方法。这个方法在 gc 启动,该对象被回收的时候被调用。
note
Object 类方法
getClass()
:final 方法,获得运行时类型。
hashCode()
:返回该对象的哈希码值,若a.equals(b)==true
则a.hashCode()=b.hashCode()
,反之不一定
equals()
:比较对象是否相等
notify()
:用于唤醒正在等待当前对象监视器的线程,唤醒的线程是随机的
notifyAll()
:用于唤醒所有等待对象监视器锁的线程,notify 只唤醒所有等待线程中的一个
wait()
,wait(long timeout)
,wait(long timeout, int nanos)
:一般和上面说的 notify 方法搭配使用。一个线程调用一个对象的 wait 方法后,线程将进入 WAITING 状态或者 TIMED_WAITING 状态。直到其他线程唤醒这个线程。
finalize
:垃圾回收器在回收一个无用的对象的时候,会调用对象的 finalize 方法,我们可以覆写对象的 finalize 方法来做一些清除工作
toString()
:默认 class 的名称+对象的哈希码。一般在子类中我们可以对这个方法进行覆写
clone()
:用于克隆一个对象,被克隆的对象需要 implements Cloneable 接口,否则调用这个对象的 clone 方法,将会抛出 CloneNotSupportedException 异常
微服务
什么是
微服务是一系列实现不同功能的服务。多个实现特定功能的服务组成一个系统,互相之间通过轻量级的传输协议协作完成功能任务。不同的微服务运行在自己的进程中,互相不干扰。
优点
- 高内聚,易于开发和维护
- 体积小,启动速度快
- 可以部分更新
- 一般采用 DevOps 方式实现自动化部署,简化部署的流程
- 每个微服务对运行的需求不同,可以按需分配资源
- 相对独立,采用接口的形式暴露数据,所以可以使用不同的技术开发
挑战
- 基础设施建设,增加开发成本
- 运维要求高
- 接口调整需要较高的开发成本
- 若使用不同技术开发,有重复工作
- 分布式系统的复杂性
设计原则
- 单一职责
- 轻量级,通用性通信协议
- 服务自治
- 接口明确
消息中间件
为什么使用
- 解耦:多应用间通过消息队列对同一消息进行处理,避免调用接口失败导致整个过程失败;
- 限流削峰:广泛应用于秒杀或抢购活动中,避免流量过大导致应用系统挂掉的情况;
- 异步:多应用对消息队列中同一消息进行处理,应用间并发处理消息,相比串行处理,减少处理时间;
- 消息驱动的系统:系统分为消息队列、消息生产者、消息消费者,生产者负责产生消息,消费者(可能有多个)负责对消息进行处理;
消息重复问题
避免重复消费,需要保证消息的生产、消费过程的幂等性:
- 生产幂等性有多种 MQ 都支持,MQ 做法是给每一个生产者一个唯一的 ID,并且为生产的每一条消息赋予一个唯一 ID,消息队列的服务端会存储 < 生产者 ID,最后一条消息 ID> 的映射。当某一个生产者产生新的消息时,消息队列服务端会比对消息 ID 是否与存储的最后一条 ID 一致,如果一致就认为是重复的消息,服务端会自动丢弃
- 消费者通过鉴定唯一 ID 是否已经消费过保证。可以用数据库保证,需要整个处理过程在同一个事务中。也可以用乐观锁保证,比如给数据添加版本号。
实际经验:监听以太坊交易的处理。
Kafka 数据丢失
情况:
- 生产者发送消息时网络错误,以为发送给了 MQ,实际 MQ 没有接收到
- 设置副本数大于 1
- 设置活跃 follower 大于 1
- 设置 leader 需要把消息同步到最小活跃同步 follower 才算写入成功
- 重试次数和处理设置
- MQ 仅接收到消息,还未来得及写入磁盘保存;只写入到 leader 没有同步到 follower 时,leader 挂了
- 设置副本数大于 1
- 设置活跃 follower 大于 1
- 设置 leader 需要把消息同步到最小活跃同步 follower 才算写入成功
- 重试次数和处理设置
- 消费者已经提交了确认,但消费途中没有处理完就 down 了 –> 关闭自动提交确认,手动保证处理完成后提交
Kafka 设计优点
- Kafka 使用硬盘存储,但做了很多优化的设计使存储性能很高
- 顺序存储,所有的消息在文件后面追加
- 通过维护一个 offset 来实现顺序访问
- IO 采用 0 拷贝技术,减少了从内核空间读取到用户缓存,再从用户缓存输出到网络流的时间。直接从页缓存写入网络流
- 网络带宽上的设计考虑,会对消息做压缩,减少带宽消耗。也可以设置消息批量发送,减少网络请求次数
- 分布式存储设计,有备份和主分区,保证消息不丢失。用户通过 offset 也能从宕机事故中快速恢复
Docker
docker 与 虚拟机的区别
Docker 和传统虚拟化方式的不同之处。传统虚拟机技术是虚拟出一套硬件后,在其上运行一个完整操作系统,在该系统上再运行所需应用进程;而容器内的应用进程直接运行于宿主的内核,容器内没有自己的内核,而且也没有进行硬件虚拟。因此容器要比传统虚拟机更为轻便。更多
网络协议
TCP
HTTP
HTTP 是应用层协议
HTTP 1.0
多线程
wait()
和sleep
的区别
wait()
方法与sleep()
方法的不同之处在于:
-
wait()
会释放资源,sleep()
不会
-
wait()
方法会释放对象的“锁标志”。当调用某一对象的wait()
方法后,会使当前线程暂停执行,并将当前线程放入对象等待池中,直到调用了notify()
方法后,将从对象等待池中移出任意一个线程并放入锁标志等待池中,只有锁标志等待池中的线程可以获取锁标志,它们随时准备争夺锁的拥有权。当调用了某个对象的notifyAll()
方法,会将对象等待池中的所有线程都移动到该对象的锁标志等待池。
-
sleep()
方法需要指定等待的时间,它可以让当前正在执行的线程在指定的时间内暂停执行,进入阻塞状态,该方法既可以让其他同优先级或者高优先级的线程得到执行的机会,也可以让低优先级的线程得到执行机会。但是sleep()
方法不会释放“锁标志”,也就是说如果有 synchronized 同步块,其他线程仍然不能访问共享数据。
wait()
需要在同步代码块中调用,因为要获取监视器,否则会抛出IllegalMonitorStateException
异常
wait()
是Object
的方法,sleep
是Thread
的静态方法
wait()
需要被唤醒,sleep()
不需要
项目
如何确保项目的质量
- 开发阶段:单元测试,code review,静态代码扫描(sonar)
- 测试阶段:功能测试,非功能测试,兼容性测试,压力测试
- 部署上线阶段:预上线,回滚方案,线上监控-
设计模式
SOLID
工厂模式
comments powered by