暂时未有相关云产品技术能力~
本文是学习 Spring 源码的第一篇,下载 Spring 源码及编译运行并测试。环境准备JDK11、Gradle、Maven、SpringFramework 5.2.0.RELEASE下载源码及编译进入 github :https://github.com/spring-pro...在 Tags 中选择需要的版本,随后右侧下载即可。下载完成解压后,进入spring-framework-5.2.0.RELEASE文件中,通过终端执行以下命令:./gradlew :spring-oxm:compileTestJava如果下载过慢可以使用阿里云镜像。随后通过 IDEA 导入项目,gradle 会自动编译。在编译中可能会报如下错误:POM relocation to an other version number is not fully supported in Gradle : xml-apis:xml-apis:2.0.2 relocated to xml-apis:xml-apis:1.0.b2.修改引入方式,修改 bulid.gradle,搜索 configurations.all,添加如下内容:force 'xml-apis:xml-apis:1.4.01'configurations.all { resolutionStrategy { cacheChangingModulesFor 0, "seconds" cacheDynamicVersionsFor 0, "seconds" force 'xml-apis:xml-apis:1.4.01' } }随后我们排除掉spring-aspects模块,右键该模块选择 Load/UnLoad Modules... 即可。测试我们新建一个 gradle 模块项目 springdemo 进行测试。目录结构如下:build.gradle 加入依赖,这里只加入 context 是因为 context 中已经引入了 code、aop、beans 等核心模块。dependencies { compile(project(":spring-context")) testCompile group: 'junit', name: 'junit', version: '4.12' }先创建一个接口和实现类。public interface WelcomeService { String sayHello(String name); }@Service public class WelcomeServiceImpl implements WelcomeService { @Override public String sayHello(String name) { System.out.println("欢迎你:" + name); return "success"; } }创建 spring 的配置文件,然后注册 bean。<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> <bean id="welcomeService" class="cn.jack.service.impl.WelcomeServiceImpl"/> </beans>最后我们创建启动类进行测试。/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2022/3/14 * @Description 启动类 */ public class Entrance { public static void main(String[] args) { ApplicationContext applicationContext = new ClassPathXmlApplicationContext("spring/spring-config.xml"); WelcomeService welcomeService = (WelcomeService) applicationContext.getBean("welcomeService"); welcomeService.sayHello("Spring框架!"); } }运行结果:> Task :springdemo:Entrance.main() 欢迎你:Spring框架! BUILD SUCCESSFUL in 9sOK,到这里就完成了 Spring 源码的下载编译及测试。
CopyOnWriteArrayList 原理解析介绍在 Java 并发包中的并发 List 只有 CopyOnWriteArrayList,CopyOnWriteArrayList 是一个线程安全的 ArrayList,对其进行的修改操作都是在底层的一个复制的数组(快照)上进行的,也就是使用了写时复制策略。在 CopyOnWriteArrayList 的类图中,每个 CopyOnWriteArrayList 对象里面有一个 array 数组用来存放具体的元素,ReentrantLock独占锁来保证同时只有一个线程对 array 进行修改。如果让我们自己做一个写时复制的线程安全的 list 我们会怎么做,有哪些点需要考虑?何时初始化 list,初始化的 list 元素个数为多少,list 是有限大小吗?如何保证线程安全,比如多个线程进行读写时如何保证是线程安全的?如何保证使用迭代器遍历 list 时的数据一致性?下面我们看一下 CopyOnWriteArrayList 是如何实现的。主要方法解析初始化在无参构造函数中,默认创建大小为 0 的 Object 数组作为初始值。public CopyOnWriteArrayList() { setArray(new Object[0]); }有参构造函数://传入的toCopyIn的副本 public CopyOnWriteArrayList(E[] toCopyIn) { setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class)); }//入参为集合,复制到list中 public CopyOnWriteArrayList(Collection<? extends E> c) { Object[] elements; if (c.getClass() == CopyOnWriteArrayList.class) elements = ((CopyOnWriteArrayList<?>)c).getArray(); else { elements = c.toArray(); // c.toArray might (incorrectly) not return Object[] (see 6260652) if (elements.getClass() != Object[].class) elements = Arrays.copyOf(elements, elements.length, Object[].class); } setArray(elements); }添加元素CopyOnWriteArrayList 中用来添加元素的函数有:add(E e)add(int index,E e)addIfAbsent(E e)addAllAbsent(Collection<? extents E> c)等这些函数原理类似,我们以 add(E e)为例来解析。public boolean add(E e) { // 获取独占锁 final ReentrantLock lock = this.lock; lock.lock(); try { // 获取array Object[] elements = getArray(); int len = elements.length; //复制array到新数组并且添加新元素到新数组 Object[] newElements = Arrays.copyOf(elements, len + 1); newElements[len] = e; // 使用新数组替换旧的数组 setArray(newElements); return true; } finally { //释放独占锁 lock.unlock(); } }在上述代码中,首先会获取独占锁,如果有多个线程同时调用 add 方法则只有一个线程能获取到该锁,其它线程会被阻塞直到锁被释放。之后使用新数组替换原数组,并释放锁,需要注意的就是在添加元素时,首先复制了一个快照,然后在快照上进行添加,而不是直接在原来数组上进行。获取指定位置元素使用 get(int index)方法获取下标为 index 的元素,如果元素不存在则抛出 IndexOutOfBoundsException 异常。public E get(int index) { return get(getArray(), index); }final Object[] getArray() { return array; }private E get(Object[] a, int index) { return (E) a[index]; }上述代码中,当某个线程调用 get 方法获取指定位置元素时,首先获取 array 数组,然后通过下标获取指定位置元素,这是两步操作,但是在整个过程中没有进行加锁同步。假设 array 里面有元素 1,2,3。由于第一步获取 array 和第二步根据下标访问指定位置元素没有枷锁,这就可能导致线程 x 在执行第一步后第二步前,另外一个线程 y 进行了 remove 操作,假设删除1,remove 操作首先会获取独占锁,进行写时复制,也就是复制一份当前 array 数组然后在复制后的数组里删除线程 x 通过 get 方法访问的元素1,之后让 array 指向新的数组。而这时候 array 之前指向的数组的引用计数为 1 而不是 0,因为线程 x 还在使用它,这时线程 x 开始执行第二步,操作的数组是线程 y 删除元素之前的数组。总结:虽然线程 y 已经删除了 index 处的元素,但是线程 x 的第二步还是会返回 index 处的元素,这其实就是写时复制策略产生的弱一致性问题。修改指定元素使用 set(int index,E element)修改 list 中指定元素的值,如果指定元素的元素不存在则抛出 IndexOutOfBoundsException 异常。public E set(int index, E element) { final ReentrantLock lock = this.lock; lock.lock(); try { Object[] elements = getArray(); E oldValue = get(elements, index); if (oldValue != element) { int len = elements.length; Object[] newElements = Arrays.copyOf(elements, len); newElements[index] = element; setArray(newElements); } else { // Not quite a no-op; ensures volatile write semantics setArray(elements); } return oldValue; } finally { lock.unlock(); } }该方法也是先获取独占锁,随后获取当前数组,并调用 get 方法后去指定位置元素,如果指定位置元素不等于新值则创建新数组并复制元素到新的数组中。如果指定位置元素和新值一样,则为了保证 volatile 语义,还是需要重新设置 array,虽然 array 内容并没有变化。该目的就是刷新一下缓存,通知其他线程,也就是所谓的操作结果可见。删除元素删除 list 中指定元素,可以使用如下方法。E remove(int index)boolean remove(Object o)Boolean remove(Object o,Object[] snapshot,int index)等原理大致类似,这里讲解 remove(int index)方法。public E remove(int index) { //获取独占锁 final ReentrantLock lock = this.lock; lock.lock(); try { Object[] elements = getArray(); int len = elements.length; E oldValue = get(elements, index); int numMoved = len - index - 1; //如果要删除的是最后一个元素 if (numMoved == 0) setArray(Arrays.copyOf(elements, len - 1)); else { //分两次复制删除后剩余的元素到新数组 Object[] newElements = new Object[len - 1]; System.arraycopy(elements, 0, newElements, 0, index); System.arraycopy(elements, index + 1, newElements, index,numMoved); setArray(newElements); } return oldValue; } finally { lock.unlock(); } }首先获取独占锁以保证删除数据期间其他线程不能对 array 进行修改,然后获取数组中要被删除的元素,并把剩余的元素复制到新数组,之后使用新数组替换原来的数组,最后在返回前释放锁。迭代器下面来看 CopyOnWriteArrayList 中迭代器的弱一致性是怎么回事,所谓弱一致性是指返回迭代器后,其他线程对 list 的增删改对迭代器是不可见的,下面看看这是如何做到的。public Iterator<E> iterator() { return new COWIterator<E>(getArray(), 0); }static final class COWIterator<E> implements ListIterator<E> { //array的快照 private final Object[] snapshot; //数组下标 private int cursor; private COWIterator(Object[] elements, int initialCursor) { cursor = initialCursor; snapshot = elements; } //是否遍历结束 public boolean hasNext() { return cursor < snapshot.length; } //获取元素 public E next() { if (! hasNext()) throw new NoSuchElementException(); return (E) snapshot[cursor++]; } }当调用 iterator 方法获取迭代器时实际上会返回一个COWIterator对象,COWIterator 对象的 snapshot 变量保存了当前 list 的内容,cursor 是遍历 list 时数据的下标。为什么说 snapshot 是 list 的快照呢?明明是指针传递的引用,而不是副本。如果在该线程使用返回的迭代器遍历元素的过程中,其他线程没有对 list 进行增删改,那么 snapshot 本身就是 list 的 array,因为它们是引用关系。但是如果在遍历期间其他线程对该 list 进行了增删改,那么 snapshot 就是快照了,因为增删改后 list 里面的数组被新数组替换了,这时候老数组被snapshot引用。这也说明获取迭代器后,使用该迭代器元素时,其他线程对该 list 进行的增删改不可见,因为它们操作的是两个不同的数组,这就是弱一致性。总结CopyOnWriteArrayList 使用写时复制的策略来保证 list 的一致性,而获取—修改—写入三步操作并不是原子性的,所以在增删改的过程中都使用了独占锁,来保证在某个时间只有一个线程能对 list 数组进行修改。另外 CopyOnWriteArrayList 提供了弱一致性的迭代器,从而保证在获取迭代器后,其他线程对 list 的修改是不可见的,迭代器遍历的数组是一个快照。CopyOnWrite 并发容器用于读多写少的并发场景,缺点:内存占用问题、数据一致性问题(只能保证数据的最终一致性,不能保证数据的实时一致性)。
Java 并发包原子操作类解析前言JUC 包中提供了一些列原子操作类,这些类都是使用非阻塞算法CAS实现的,相比使用锁实现原子性操作在性能上有较大提高。由于原子性操作的原理都大致相同,本文只讲解简单的 AtomicLong 类的原理以及在JDK8中新增的 LongAdder 类原理。原子变量操作类JUC 并发包中包含 AtomicInteger、AtomicLong 和 AtomicBoolean 等原子性操作类,原理大致类似,接下来我们看一下 AtomicLong 类。AtomicLong 是原子性递增或者递减类,内部使用Unsafe来实现,我们看下面的代码。public class AtomicLong extends Number implements java.io.Serializable { private static final long serialVersionUID = 1927816293512124184L; //1. 获取Unsafe实例 private static final Unsafe unsafe = Unsafe.getUnsafe(); //2. 存放变量value的偏移量 private static final long valueOffset; //3. 判断JVM是否支持Long类型无锁CAS static final boolean VM_SUPPORTS_LONG_CAS = VMSupportsCS8(); private static native boolean VMSupportsCS8(); static { try { //4. 获取value在AtomicLong中的偏移量 valueOffset = unsafe.objectFieldOffset (AtomicLong.class.getDeclaredField("value")); } catch (Exception ex) { throw new Error(ex); } } //5. 实际变量值 private volatile long value; public AtomicLong(long initialValue) { value = initialValue; } ...... }首先通过Unsafe.getUnsafe()方法获取到 Unsafe 类的实例,为什么可以获取到 Unsafe 类的实例?因为 AtomicLong 类也在 rt.jar 包下,所以可以通过 BootStrap 类加载器进行加载。第二步、第四步获取 value 变量在 AtomicLong 类中的偏移量。第五步的 value 变量被声明为了volatile,这是为了在多线程下保证内存可见性,而 value 存储的就是具体计数器的值。递增和递减操作代码接下来我们看一下 AtomicLong 中的主要函数。//调用unsafe方法,原子性设置value值为原始值+1,返回递增后的值 public final long incrementAndGet() { return unsafe.getAndAddLong(this, valueOffset, 1L) + 1L; } //调用unsafe方法,原子性设置value值为原始值-1,返回值递减后的值 public final long decrementAndGet() { return unsafe.getAndAddLong(this, valueOffset, -1L) - 1L; } //调用unsafe方法,原子性设置value值为原始值+1,返回原始值 public final long getAndIncrement() { return unsafe.getAndAddLong(this, valueOffset, 1L); } //调用unsafe方法,原子性设置value值为原始值-1,返回原始值 public final long getAndDecrement() { return unsafe.getAndAddLong(this, valueOffset, -1L); }上述代码都是通过调用 Unsafe 的getAndAddLong()方法来实现操作,这个函数是一个原子性操作,第一个参数为 AtomicLong 实例的引用,第二个参数是 value 变量在 AtomicLong 中的偏移值,第三个参数是要设置的第二个变量的值。其中,getAndIncrement()方法在JDK1.7中的实现逻辑如下。public final long getAndIncrement() { while (true) { long current = get(); long next = current + 1; if (compareAndSet(current,next)) return current; } }这段代码中,每个线程都是拿到变量的当前值(因为 value 是 volatile 变量,所以拿到的都是最新的值),然后在工作内存中进行增加 1 操作,之后使用CAS修改变量的值。如果设置失败,则一直循环尝试,直到设置成功。而JDK8中的逻辑为:public final long getAndAddLong(Object var1, long var2, long var4) { long var6; do { var6 = this.getLongVolatile(var1, var2); } while(!this.compareAndSwapLong(var1, var2, var6, var6 + var4)); return var6; }可以看到,JDK1.7的 AtomicLong 中的循环逻辑已经被JDK8中的原子操作类 Unsafe 内置了,之所以内置应该是考虑到这个函数在其他地方也会用到,而内置可以提高复用性。compareAndSet(long expect, long update)方法public final boolean compareAndSet(long expect, long update) { return unsafe.compareAndSwapLong(this, valueOffset, expect, update); }如上代码我们可以知道,在内部还是调用了unsafe.compareAndSwapLong方法。如果原子变量中的 value 值等于 expect,则使用 update 值更新该值并返回 true,否则返回 false。下面我们通过一个多线程使用 AtomicLong 统计 0 的个数的例子来加深理解。/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2022/1/4 * @Description 统计0的个数 */ public class AtomicTest { private static AtomicLong atomicLong = new AtomicLong(); private static Integer[] arrayOne = new Integer[]{0, 1, 2, 3, 0, 5, 6, 0, 56, 0}; private static Integer[] arrayTwo = new Integer[]{10, 1, 2, 3, 0, 5, 6, 0, 56, 0}; public static void main(String[] args) throws InterruptedException { final Thread threadOne = new Thread(() -> { final int size = arrayOne.length; for (int i = 0; i < size; ++i) { if (arrayOne[i].intValue() == 0) { atomicLong.incrementAndGet(); } } }); final Thread threadTwo = new Thread(() -> { final int size = arrayTwo.length; for (int i = 0; i < size; ++i) { if (arrayTwo[i].intValue() == 0) { atomicLong.incrementAndGet(); } } }); threadOne.start(); threadTwo.start(); //等待线程执行完毕 threadOne.join(); threadTwo.join(); System.out.println("count总数为: " + atomicLong.get()); //count总数为: 7 } }这段代码很简单,就是每找到一个 0 就会调用 AtomicLong 的原子性递增方法。在没有原子类的时候,实现计数器需要一定的同步措施,例如 synchronized 关键字等,但这些都是阻塞算法,对性能有一定的影响,而我们使用的 AtomicLong 使用的是CAS 非阻塞算法,性能更好。但是在高并发下,AtomicLong 还会存在性能问题,JDK8 提供了一个在高并发下性能更好的 LongAdder 类。LongAdder 介绍前面说过,在高并发下使用 AtomicLong 时,大量线程会同时竞争同一个原子变量,但是由于同时只有一个线程的 CAS 操作会成功,所以会造成大量线程竞争失败后,会无限循环不断的自旋尝试 CAS 操作,白白浪费 CPU 资源。所以在 JDK8 中新增了一个原子性递增或者递减类 LongAdder 用来克服高并发 AtomicLong 的缺点。既然 AtomicLong 的性能瓶颈是多个线程竞争一个变量的更新产生的,那如果把一个变量分成多个变量,让多个线程竞争多个资源,是不是就解决性能问题了?是的,LongAdder 就是这个思路。如上图,在使用 LongAdder 时,则是在内部维护多个 Cell 变量,每个 Cell 里面有一个初始值为 0 的 long 型变量,这样的话在同等并发量的情况下,争夺单个线程更新操作的线程会减少,也就变相的减少争夺共享资源的并发量。另外,如果多个线程在争夺同一个 Cell 原子变量时失败了,它并不会一直自旋重试,而是去尝试其它 Cell 变量进行 CAS 尝试,这样就增加了当前线程重试 CAS 成功的可能性,最后,在获取 LongAdder 当前值时,是把所有的Cell变量的value值累加后再加上base返回的。LongAdder 维护了一个延迟初始化的原子性更新数组(默认情况下 Cell 数组是 null)和一个基值变量 base,在一开始时并不创建 Cells 数组,而是在使用时创建,也就是惰性加载。在一开始判断 Cell 数组是 null 并且并发线程减少时,所有的累加都是在 base 变量上进行的,保持 Cell 数组的大小为 2 的 N 次方,在初始化时 Cell 数组中的 Cell 元素个数为 2,数组里面的变量实体是 Cell 类型。Cell 类型是 AtomicLong 的一个改进,用来减少缓存的争用,也就是解决伪共享问题。在多个线程并发修改一个缓存行中的多个变量时,由于只能同时有一个线程去操作缓存行,将会导致性能的下降,这个问题就称之为伪共享。一般而言,缓存行有 64 字节,我们知道一个 long 是 8 个字节,填充 5 个 long 之后,一共就是 48 个字节。而 Java 中对象头在 32 位系统下占用 8 个字节,64 位系统下占用 16 个字节,这样填充 5 个 long 型即可填满 64 字节,也就是一个缓存行。JDK8 以及之后的版本 Java 提供了sun.misc.Contended 注解,通过@Contented 注解就可以解决伪共享的问题。使用@Contented 注解后会增加 128 字节的 padding,并且需要开启-XX:-RestrictContended选项后才能生效。在 LongAdder 中解决伪共享的真正的核心就在Cell数组,Cell数组使用了@Contented注解。对于大多数孤立的多个原子操作进行字节填充是浪费的,因为原子性操作都是无规律地分散在内存中的(也就是说多个原子性变量的内存地址是不连续的),多个原子变量被放入同一个缓存行的可能性很小。但是原子性数组元素的内存地址是连续的,所以数组内的多个元素能经常共享缓存行,因此这里使用@Contented注解对 Cell 类进行字节填充,这防止了数组中多个元素共享一个缓存行,在性能上是一个提升。LongAdder 源码分析问题:LongAdder 的结构是怎样的?当前线程应该访问 Cell 数组里面的哪一个 Cell 元素?如何初始化 Cell 数组?Cell 数组如何扩容?线程访问分配的 Cell 元素有冲突后如何处理?如何保证线程操作被分配的 Cell 元素的原子性?接下来我们看一下 LongAdder 的结构:LongAdder 类继承自 Striped64 类,在 Striped64 内部维护这三个变量。LongAdder 的真实值其实是 base 的值与 Cell 数组里面所有 Cell 元素中的 value 值的累加,base 是个基础值,默认为 0。cellsBusy 用来实现自旋锁,状态值只有 0 和 1,当创建 Cell 元素,扩容 Cell 数组或者初始化 Cell 数组时,使用 CAS 操作该变量来保证同时只有一个线程可以进行其中之一的操作。transient volatile Cell[] cells; transient volatile long base; transient volatile int cellsBusy;public class LongAdder extends Striped64 implements Serializable {Cell 的构造下面我们看一下 Cell 的构造。@sun.misc.Contended static final class Cell { volatile long value; Cell(long x) { value = x; } final boolean cas(long cmp, long val) { return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val); } // Unsafe mechanics private static final sun.misc.Unsafe UNSAFE; private static final long valueOffset; static { try { UNSAFE = sun.misc.Unsafe.getUnsafe(); Class<?> ak = Cell.class; valueOffset = UNSAFE.objectFieldOffset (ak.getDeclaredField("value")); } catch (Exception e) { throw new Error(e); } } } 可以看到,内部维护一个被声明为 volatile 的变量,这里声明 volatile 是为了保证内存可见性。另外 cas 函数通过 CAS 操作,保证了当前线程更新时被分配的 Cell 元素中 value 值的原子性。并且可以看到 Cell 类是被@Contended 修饰的,避免伪共享。 至此我们已经知道了问题 1、6 的答案了。 sum() sum()方法返回当前的值,内部操作是累加所有 Cell 内部的 value 值然后在累加 base。 由于计算总合时没有对 Cell 数组进行加锁,所以在累加过程中可能有其它线程对 Cell 值进行修改,也可能扩容,所以 sum 返回的值并不是非常准确的,其返回值并不是一个调用 sum()方法时原子快照值。 public long sum() { Cell[] as = cells; Cell a; long sum = base; if (as != null) { for (int i = 0; i < as.length; ++i) { if ((a = as[i]) != null) sum += a.value; } } return sum; }reset()reset()方法为重置操作,将 base 设置为 0,如果 Cell 数组有元素,则元素被重置为 0。public void reset() { Cell[] as = cells; Cell a; base = 0L; if (as != null) { for (int i = 0; i < as.length; ++i) { if ((a = as[i]) != null) a.value = 0L; } } }sumThenReset()sumThenReset()方法是 sum()方法的改造版本,该方法在使用 sum 累加对应的 Cell 值后,把当前的 Cell 和 base 重置为 0。该方法存在在线程安全问题,比如第一个调用线程清空 Cell 的值,则后一个线程调用时累加的都是 0 值。public long sumThenReset() { Cell[] as = cells; Cell a; long sum = base; base = 0L; if (as != null) { for (int i = 0; i < as.length; ++i) { if ((a = as[i]) != null) { sum += a.value; a.value = 0L; } } } return sum; }add(long x)接下来我们主要看 add()方法,这个方法里面可以回答刚才其他的问题。public void add(long x) { Cell[] as; long b, v; int m; Cell a; //(1) if ((as = cells) != null || !casBase(b = base, b + x)) { boolean uncontended = true; //(2) if (as == null || (m = as.length - 1) < 0 || //(3) (a = as[getProbe() & m]) == null || //(4) !(uncontended = a.cas(v = a.value, v + x))) //(5) longAccumulate(x, null, uncontended); } } final boolean casBase(long cmp, long val) { return UNSAFE.compareAndSwapLong(this, BASE, cmp, val); }该方法首先判断 cells 是否 null,如果为 null 则在 base 上进行累加。如果 cells 不为 null,或者线程执行代码 cas 失败,则去执行第二步。代码第二步第三步决定当前线程应该访问 cells 数组中哪一个 Cell 元素,如果当前线程映射的元素存在的话则执行代码四。第四步主要使用 CAS 操作去更新分配的 Cell 元素的 value 值,如果当前线程映射的元素不存在或者存在但是 CAS 操作失败则执行代码五。线程应该访问 cells 数组的哪一个 Cell 元素是通过 getProbe() & m 进行计算的,其中 m 是当前 cells 数组元素个数-1,getProbe()则用于获取当前线程中变量 threadLocalRandomProbe 的值,这个值一开始为 0,在代码第五步里面会对其进行初始化。并且当前线程通过分配的 Cell 元素的 cas 函数来保证对 Cell 元素 value 值更新的原子性。现在我们已经明白了第二个问题。下面我们看一下 longAccumulate(x,null,uncontended)方法,该方法主要是 cells 数组初始化和扩容的地方。final void longAccumulate(long x, LongBinaryOperator fn, boolean wasUncontended) { //6. 初始化当前线程变量ThreadLocalRandomProbe的值 int h; if ((h = getProbe()) == 0) { ThreadLocalRandom.current(); // force initialization h = getProbe(); wasUncontended = true; } boolean collide = false; // True if last slot nonempty for (;;) { Cell[] as; Cell a; int n; long v; //7. if ((as = cells) != null && (n = as.length) > 0) { //8. if ((a = as[(n - 1) & h]) == null) { if (cellsBusy == 0) { // Try to attach new Cell Cell r = new Cell(x); // Optimistically create if (cellsBusy == 0 && casCellsBusy()) { boolean created = false; try { // Recheck under lock Cell[] rs; int m, j; if ((rs = cells) != null && (m = rs.length) > 0 && rs[j = (m - 1) & h] == null) { rs[j] = r; created = true; } } finally { cellsBusy = 0; } if (created) break; continue; // Slot is now non-empty } } collide = false; } else if (!wasUncontended) // CAS already known to fail wasUncontended = true; // Continue after rehash //9. 当前Cell存在,则执行CAS设置 else if (a.cas(v = a.value, ((fn == null) ? v + x : fn.applyAsLong(v, x)))) break; //10. 当前Cell元素个数大于CPU个数 else if (n >= NCPU || cells != as) collide = false; // At max size or stale //11. 是否有冲突 else if (!collide) collide = true; //12. 如果当前元素个数没有达到CPU个数,并且存在冲突则扩容 else if (cellsBusy == 0 && casCellsBusy()) { try { if (cells == as) { // Expand table unless stale //12.1 Cell[] rs = new Cell[n << 1]; for (int i = 0; i < n; ++i) rs[i] = as[i]; cells = rs; } } finally { //12.2 cellsBusy = 0; } //12.3 collide = false; continue; // Retry with expanded table } //13. 为了能够找到一个空闲的Cell,重新计算hash值,xorshift算法生成随机数 h = advanceProbe(h); } //14. 初始化Cell数组 else if (cellsBusy == 0 && cells == as && casCellsBusy()) { boolean init = false; try { // Initialize table if (cells == as) { //14.1 Cell[] rs = new Cell[2]; //14.2 rs[h & 1] = new Cell(x); cells = rs; init = true; } } finally { //14.3 cellsBusy = 0; } if (init) break; } else if (casBase(v = base, ((fn == null) ? v + x : fn.applyAsLong(v, x)))) break; // Fall back on using base } }该方法较为复杂,我们主要关注问题 3,问题 4,问题 5。如何初始化 Cell 数组?Cell 数组如何扩容?线程访问分配的 Cell 元素有冲突后如何处理?当每个线程第一次执行到代码第六步的时候,会初始化当前线程变量 ThreadLocalRandomProbe 的值,该值主要为了计算当前线程为了分配到cells数组中的哪一个cell元素中。cells 数组的初始化是在代码第十四步中进行,其中 cellsBusy 是一个标识,为 0 说明当前 cells 数组没有被初始化或者扩容,也没有在新建 Cell 元素,为 1 说明 cells 数组正在被初始化或者扩容、创建新元素,通过 CAS 来进行 0 或 1 状态切换,调用的是casCellsBusy()。假设当前线程通过 CAS 设置 cellsBuys 为 1,则当前线程开始初始化操作,那么这时候其他线程就不能进行扩容了,如代码(14.1)初始化 cells 数组个数为 2,然后使用h & 1计算当前线程应该访问 cell 数组的那个位置,使用的 h 就是当前线程的 threadLocalRandomProbe 变量。然后标识 Cells 数组以及被初始化,最后(14.3)重置了 cellsBusy 标记。虽然这里没有使用CAS操作,但是却是线程安全的,原因是cellsBusy是volatile类型的,保证了内存可见性。在这里初始化的 cells 数组里面的两个元素的值目前还是 null。现在我们知道了问题 3 的答案。而 cells 数组的扩容是在代码第十二步进行的,对 cells 扩容是有条件的,也就是第十步、十一步条件都不满足后进行扩容操作。具体就是当前 cells 的元素个数小于当前机器 CPU 个数并且当前多个线程访问了 cells 中同一个元素,从而导致某个线程 CAS 失败才会进行扩容。为何要涉及 CPU 个数呢?只有当每个 CPU 都运行一个线程时才会使多线程的效果最佳,也就是当 cells 数组元素个数与 CPU 个数一致时,每个 Cell 都使用一个 CPU 进行处理,这时性能才是最佳的。代码第十二步也是先通过 CAS 设置 cellsBusy 为 1,然后才能进行扩容。假设 CAS 成功则执行代码(12.1)将容量扩充为之前的 2 倍,并复制 Cell 元素到扩容后数组。另外,扩容后 cells 数组里面除了包含复制过来的元素外,还包含其他新元素,这些元素的值目前还是 null。现在我们知道了问题 4 的答案。在代码第七步、第八步中,当前线程调用 add()方法并根据当前线程的随机数 threadLocalRandomProbe 和 cells 元素个数计算要访问的 Cell 元素下标,然后如果发现对应下标元素的值为 null,则新增一个 Cell 元素到 cells 数组,并且在将其添加到 cells 数组之前要竞争设置 cellsBusy 为 1。而代码第十三步,对 CAS 失败的线程重新计算当前线程的随机值 threadLocalRandomProbe,以减少下次访问 cells 元素时的冲突机会。这里我们就知道了问题 5 的答案。总结该类通过内部 cells 数组分担了高并发下多线程同时对一个原子变量进行更新时的竞争量,让多个线程可以同时对 cells 数组里面的元素进行并行的更新操作。另外,数组元素 Cell 使用@Contended 注解进行修饰,这避免了 cells 数组内多个原子变量被放入同一个缓存行,也就是避免了伪共享。LongAccumulator 相比于 LongAdder,可以为累加器提供非 0 的初始值,后者只能提供默认的 0 值。另外,前者还可以指定累加规则,比如不进行累加而进行相乘,只需要在构造 LongAccumulator 时传入自定义的双目运算器即可,后者则内置累加的规则。
NIO 基础什么是 NIOJava NIO 全称 Java non-blocking IO,指的是 JDK 提供的新 API。从 JDK 1.4 开始,Java 提供了一系列改进的输入/输出的新特性,被统称为 NIO,即 New IO,是同步非阻塞的。NIO 相关类都放在 java.nio 包下,并对原 java.io 包中很多类进行了改写。NIO 有三大核心部分:Channel(管道)、Buffer(缓冲区)、Selector(选择器)。NIO 是面向缓冲区编程的。数据读取到了一个它稍微处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞的高伸缩性网络。Java NIO 的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用数据,如果目前没有可用数据时,则说明不会获取,而不是保持线程阻塞,所以直到数据变为可以读取之前,该线程可以做其他事情。非阻塞写入同理。三大核心组件Channel 的基本介绍NIO 的通道类似于流,但有如下区别:通道是双向的可以进行读写,而流是单向的只能读,或者写通道可以实现异步读写数据通道可以从缓冲区读取数据,也可以写入数据到缓冲区四种通道:FileChannel :从文件中读写数据DatagramChannel:通过 UDP 协议,读写网络中的数据SocketChannel:能通过 TCP 协议来读写网络中数据,常用于客户端ServerSocketChannel:监听 TCP 连接,对每个新的连接会创建一个 SocketChannelBuffer(缓冲区)基本介绍NIO 中的 Buffer 用于 NIO 通道(Channel)进行交互。缓冲区本质上是一个可以读写数据的内存块,可以理解为是一个容器对象(含数组),该对象提供了一组方法,可以更轻松地使用内存块,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况。当向 Buffer 写入数据时,Buffer 会记录下写了多少数据,一旦要读取数据,需要通过flip()方法将 Buffer 从写模式切换到读模式。在读模式下,可以读取之前写入到 Buffer 的所有数据。当读完了所有数据,就需要清空缓存区,让它可以再次被写入。有两种方式能清空缓冲区,调用clear()或者compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。Channel 提供从文件、网络读取数据的渠道,但是读取或者都必须经过 Buffer。在 Buffer 子类中维护着一个对应类型的数组,用来存放数据。Selector 的基本介绍Java 的 NIO 使用了非阻塞的 I/O 方式。可以用一个线程处理若干个客户端连接,就会使用到 Selector(选择器)Selector 能够检测到多个注册通道上是否有事件发生(多个 Channel 以事件的形式注册到同一个 selector),如果有事件发生,便获取事件然后针对每个事件进行相应的处理只有在连接真正有读写事件发生时,才会进行读写,减少了系统开销,并且不必为每个连接都创建一个线程,不用维护多个线程避免了多线程之间上下文切换导致的开销Selector 的特点Netty 的 I/O 线程 NioEventLoop 聚合了 Selector(选择器 / 多路复用器),可以并发处理成百上千个客户端连接。当线程从某客户端 Socket 通道进行读写时,若没有数据可用,该线程可以进行其他任务。线程通常将非阻塞 I/O 的空闲时间用于其他通道上执行 I/O 操作,所以单独的线程可以管理多个输入输出通道。由于读写操作都是非阻塞的,就可以充分提高 I/O 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程挂起。一个 I/O 线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 I/O 一连接一线程模型,架构性能、弹性伸缩能力和可靠性都得到极大地提升。ByteBuffer 的基本使用核心依赖<dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.36.Final</version> </dependency>/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/28 * @Description ByteBuffer基本使用,读取文件内容并打印 */ public class ByteBufferTest { public static void main(String[] args) { //获取channel try (FileChannel channel = new FileInputStream("data.txt").getChannel()) { //创建ByteBuffer final ByteBuffer buffer = ByteBuffer.allocate(1024); //读取文件内容,并存入buffer channel.read(buffer); //切换为读模式 buffer.flip(); while (buffer.hasRemaining()) { System.out.print((char) buffer.get()); } //清空缓冲区,并重置为写模式 buffer.clear(); } catch (IOException e) { e.printStackTrace(); } } }输出结果:1234567890abcByteBuffer 的结构Buffer 中定义了四个属性来提供所其包含的数据元素。// Invariants: mark <= position <= limit <= capacity private int mark = -1; private int position = 0; private int limit; private int capacity;capacity:缓冲区的容量。通过构造函数赋予,一旦设置,无法更改limit:缓冲区的界限。位于 limit 后的数据不可读写。缓冲区的限制不能为负,并且不能大于其容量position:下一个读写位置的索引(类似 PC)。缓冲区的位置不能为负,并且不能大于 limitmark:记录当前 position 的值。position 被改变后,可以通过调用 reset() 方法恢复到 mark 的位置在一开始的情况下,position 指向第一位写入位置,limit 和 capacity 则等于缓冲区的容量。在写模式下,position 是写入位置,limit 等于容量,下图表示写入 4 个元素后的状态。当调用flip()方法切换为读模式后,position 切换为读取位置,limit 切换为读取限制。当读取到 limit 位置后,则不可以继续读取。当调用clear()方法后,则回归最原始状态。当调用 compact()方法时,需要注意:此方法为 ByteBuffer 的方法,而不是 Buffer 的方法。compact 会把未读完的数据向前压缩,然后切换到写模式数据前移后,原位置的值并未清零,写时会覆盖之前的值ByteBuffer 的常见方法分配空间:allocate()//java.nio.HeapByteBuffer java堆内存,读写效率较低,受到gc影响 System.out.println(ByteBuffer.allocate(1024).getClass()); //java.nio.DirectByteBuffer 直接内存,读写效率较高(少一次拷贝),不会受gc影响,分配内存效率较低,使用不当则可能会发生内存泄漏 System.out.println(ByteBuffer.allocateDirect(1024).getClass());flip()flip()方法会切换对缓冲区的操作模式,由写->读 / 读->写put()put()方法可以将一个数据放入到缓冲区中。进行该操作后,postition 的值会+1,指向下一个可以放入的位置。get()get()方法会读取缓冲区中的一个值进行该操作后,position 会+1,如果超过了 limit 则会抛出异常注意:get(i)方法不会改变 position 的值。rewind()该方法只能在读模式下使用rewind()方法后,会恢复 position、limit 和 capacity 的值,变为进行 get()前的值clear()clear()方法会将缓冲区中的各个属性恢复为最初的状态,position = 0, capacity = limit此时缓冲区的数据依然存在,处于“被遗忘”状态,下次进行写操作时会覆盖这些数据mark()和 reset()mark()方法会将 postion 的值保存到 mark 属性中reset()方法会将 position 的值改为 mark 中保存的值字符串和 ByteBuffer 相互转换引入工具类:import io.netty.util.internal.MathUtil; import io.netty.util.internal.StringUtil; import java.nio.ByteBuffer; /** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/28 * @Description 工具类 */ public class ByteBufferUtil { private static final char[] BYTE2CHAR = new char[256]; private static final char[] HEXDUMP_TABLE = new char[256 * 4]; private static final String[] HEXPADDING = new String[16]; private static final String[] HEXDUMP_ROWPREFIXES = new String[65536 >>> 4]; private static final String[] BYTE2HEX = new String[256]; private static final String[] BYTEPADDING = new String[16]; static { final char[] DIGITS = "0123456789abcdef".toCharArray(); for (int i = 0; i < 256; i++) { HEXDUMP_TABLE[i << 1] = DIGITS[i >>> 4 & 0x0F]; HEXDUMP_TABLE[(i << 1) + 1] = DIGITS[i & 0x0F]; } int i; // Generate the lookup table for hex dump paddings for (i = 0; i < HEXPADDING.length; i++) { int padding = HEXPADDING.length - i; StringBuilder buf = new StringBuilder(padding * 3); for (int j = 0; j < padding; j++) { buf.append(" "); } HEXPADDING[i] = buf.toString(); } // Generate the lookup table for the start-offset header in each row (up to 64KiB). for (i = 0; i < HEXDUMP_ROWPREFIXES.length; i++) { StringBuilder buf = new StringBuilder(12); buf.append(StringUtil.NEWLINE); buf.append(Long.toHexString(i << 4 & 0xFFFFFFFFL | 0x100000000L)); buf.setCharAt(buf.length() - 9, '|'); buf.append('|'); HEXDUMP_ROWPREFIXES[i] = buf.toString(); } // Generate the lookup table for byte-to-hex-dump conversion for (i = 0; i < BYTE2HEX.length; i++) { BYTE2HEX[i] = ' ' + StringUtil.byteToHexStringPadded(i); } // Generate the lookup table for byte dump paddings for (i = 0; i < BYTEPADDING.length; i++) { int padding = BYTEPADDING.length - i; StringBuilder buf = new StringBuilder(padding); for (int j = 0; j < padding; j++) { buf.append(' '); } BYTEPADDING[i] = buf.toString(); } // Generate the lookup table for byte-to-char conversion for (i = 0; i < BYTE2CHAR.length; i++) { if (i <= 0x1f || i >= 0x7f) { BYTE2CHAR[i] = '.'; } else { BYTE2CHAR[i] = (char) i; } } } /** * 打印所有内容 * * @param buffer */ public static void debugAll(ByteBuffer buffer) { int oldlimit = buffer.limit(); buffer.limit(buffer.capacity()); StringBuilder origin = new StringBuilder(256); appendPrettyHexDump(origin, buffer, 0, buffer.capacity()); System.out.println("+--------+-------------------- all ------------------------+----------------+"); System.out.printf("position: [%d], limit: [%d]\n", buffer.position(), oldlimit); System.out.println(origin); buffer.limit(oldlimit); } /** * 打印可读取内容 * * @param buffer */ public static void debugRead(ByteBuffer buffer) { StringBuilder builder = new StringBuilder(256); appendPrettyHexDump(builder, buffer, buffer.position(), buffer.limit() - buffer.position()); System.out.println("+--------+-------------------- read -----------------------+----------------+"); System.out.printf("position: [%d], limit: [%d]\n", buffer.position(), buffer.limit()); System.out.println(builder); } private static void appendPrettyHexDump(StringBuilder dump, ByteBuffer buf, int offset, int length) { if (MathUtil.isOutOfBounds(offset, length, buf.capacity())) { throw new IndexOutOfBoundsException( "expected: " + "0 <= offset(" + offset + ") <= offset + length(" + length + ") <= " + "buf.capacity(" + buf.capacity() + ')'); } if (length == 0) { return; } dump.append( " +-------------------------------------------------+" + StringUtil.NEWLINE + " | 0 1 2 3 4 5 6 7 8 9 a b c d e f |" + StringUtil.NEWLINE + "+--------+-------------------------------------------------+----------------+"); final int startIndex = offset; final int fullRows = length >>> 4; final int remainder = length & 0xF; // Dump the rows which have 16 bytes. for (int row = 0; row < fullRows; row++) { int rowStartIndex = (row << 4) + startIndex; // Per-row prefix. appendHexDumpRowPrefix(dump, row, rowStartIndex); // Hex dump int rowEndIndex = rowStartIndex + 16; for (int j = rowStartIndex; j < rowEndIndex; j++) { dump.append(BYTE2HEX[getUnsignedByte(buf, j)]); } dump.append(" |"); // ASCII dump for (int j = rowStartIndex; j < rowEndIndex; j++) { dump.append(BYTE2CHAR[getUnsignedByte(buf, j)]); } dump.append('|'); } // Dump the last row which has less than 16 bytes. if (remainder != 0) { int rowStartIndex = (fullRows << 4) + startIndex; appendHexDumpRowPrefix(dump, fullRows, rowStartIndex); // Hex dump int rowEndIndex = rowStartIndex + remainder; for (int j = rowStartIndex; j < rowEndIndex; j++) { dump.append(BYTE2HEX[getUnsignedByte(buf, j)]); } dump.append(HEXPADDING[remainder]); dump.append(" |"); // Ascii dump for (int j = rowStartIndex; j < rowEndIndex; j++) { dump.append(BYTE2CHAR[getUnsignedByte(buf, j)]); } dump.append(BYTEPADDING[remainder]); dump.append('|'); } dump.append(StringUtil.NEWLINE + "+--------+-------------------------------------------------+----------------+"); } private static void appendHexDumpRowPrefix(StringBuilder dump, int row, int rowStartIndex) { if (row < HEXDUMP_ROWPREFIXES.length) { dump.append(HEXDUMP_ROWPREFIXES[row]); } else { dump.append(StringUtil.NEWLINE); dump.append(Long.toHexString(rowStartIndex & 0xFFFFFFFFL | 0x100000000L)); dump.setCharAt(dump.length() - 9, '|'); dump.append('|'); } } public static short getUnsignedByte(ByteBuffer buffer, int index) { return (short) (buffer.get(index) & 0xFF); } }测试类:/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/28 * @Description 字符串和ByteBuffer相互转换 */ public class TranslateTest { public static void main(String[] args) { String str1 = "hello"; String str2; String str3; // 通过StandardCharsets的encode方法获得ByteBuffer // 此时获得的ByteBuffer为读模式,无需通过flip切换模式 ByteBuffer buffer = StandardCharsets.UTF_8.encode(str1); //也可以使用wrap方法实现,无需通过flip切换模式 ByteBuffer wrap = ByteBuffer.wrap(str1.getBytes()); ByteBufferUtil.debugAll(wrap); ByteBufferUtil.debugAll(buffer); // 将缓冲区中的数据转化为字符串 // 通过StandardCharsets解码,获得CharBuffer,再通过toString获得字符串 str2 = StandardCharsets.UTF_8.decode(buffer).toString(); System.out.println(str2); str3 = StandardCharsets.UTF_8.decode(wrap).toString(); System.out.println(str3); } }运行结果:+--------+-------------------- all ------------------------+----------------+ position: [0], limit: [5] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 68 65 6c 6c 6f |hello | +--------+-------------------------------------------------+----------------+ +--------+-------------------- all ------------------------+----------------+ position: [0], limit: [5] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 68 65 6c 6c 6f |hello | +--------+-------------------------------------------------+----------------+ hello hello粘包与半包现象网络上有多条数据发送给服务端,数据之间使用 \n 进行分隔。但由于某种原因这些数据在接收时,被进行了重新组合,例如原始数据有 3 条为:Hello,world\nI’m Jack\nHow are you?\n变成了下面的两个 byteBuffer (粘包,半包)Hello,world\nI’m Jack\nHow are you?\n出现原因粘包发送方在发送数据时,并不是一条一条地发送数据,而是将数据整合在一起,当数据达到一定的数量后再一起发送。这就会导致多条信息被放在一个缓冲区中被一起发送出去。半包接收方的缓冲区的大小是有限的,当接收方的缓冲区满了以后,就需要将信息截断,等缓冲区空了以后再继续放入数据。这就会发生一段完整的数据最后被截断的现象。解决办法通过get(index)方法遍历 ByteBuffer,当遇到\n后进行处理。记录从 position 到 index 的数据长度,申请对应大小的缓冲区。将缓冲区的数据通过get()获取写入到 target 缓冲区中。最后,调用 compact()方法切换为写模式,因为缓冲区中可能还存在未读取的数据。/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/29 * @Description 解决黏包和半包 */ public class ByteBufferTest { public static void main(String[] args) { ByteBuffer buffer = ByteBuffer.allocate(32); //模拟黏包和半包 buffer.put("Hello,world\nI'm Jack\nHo".getBytes(StandardCharsets.UTF_8)); split(buffer); buffer.put("w are you?\n".getBytes(StandardCharsets.UTF_8)); split(buffer); } private static void split(ByteBuffer buffer) { //切换读模式 buffer.flip(); for (int i = 0; i < buffer.limit(); i++) { //找到完整消息 if (buffer.get(i) == '\n') { int length = i + 1 - buffer.position(); final ByteBuffer target = ByteBuffer.allocate(length); //从buffer中读取,写入 target for(int j = 0; j < length; j++) { // 将buffer中的数据写入target中 target.put(buffer.get()); } // 打印查看结果 ByteBufferUtil.debugAll(target); } } //清空已读部分,并切换写模式 buffer.compact(); } }运行结果:+--------+-------------------- all ------------------------+----------------+ position: [12], limit: [12] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 48 65 6c 6c 6f 2c 77 6f 72 6c 64 0a |Hello,world. | +--------+-------------------------------------------------+----------------+ +--------+-------------------- all ------------------------+----------------+ position: [9], limit: [9] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 49 27 6d 20 4a 61 63 6b 0a |I'm Jack. | +--------+-------------------------------------------------+----------------+ +--------+-------------------- all ------------------------+----------------+ position: [13], limit: [13] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 48 6f 77 20 61 72 65 20 79 6f 75 3f 0a |How are you?. | +--------+-------------------------------------------------+----------------+文件编程FileChannel工作模式📢:FileChannel 只能工作在阻塞模式下!获取不能直接打开 FileChannel,必须通过 FileInputStream、FileOutputStream 或者 RandomAccessFile 来获取 FileChannel,它们都有 getChannel() 方法。通过 FileInputStream 获取的 channel 只能读通过 FileOutputStream 获取的 channel 只能写通过 RandomAccessFile 是否能读写根据构造 RandomAccessFile 时的读写模式决定读取通过read()方法将数据填充到 ByteBuffer 中,返回值表示读到了多少字节,-1表示读到了文件末尾。int readBytes = channel.read(buffer);写入因为 channel 是有写入上限的,所以 write() 方法并不能保证一次将 buffer 中的内容全部写入 channel。必须按照以下规则进行写入。// 通过hasRemaining()方法查看缓冲区中是否还有数据未写入到通道中 while(buffer.hasRemaining()) { channel.write(buffer); }关闭Channel 必须关闭,不过调用 FileInputStream、FileOutputStream、 RandomAccessFile 的close()方法时也会间接的调用 Channel 的 close()方法。位置channel 也拥有一个保存读取数据位置的属性,即 position。long pos = channel.position();可以通过 position(int pos)设置 channel 中 position 的值。long newPos = 10; channel.position(newPos);设置当前位置时,如果设置为文件的末尾:这时读取会返回 -1这时写入,会追加内容,但要注意如果 position 超过了文件末尾,再写入时在新内容和原末尾之间会有空洞(00)强制写入操作系统出于性能的考虑,会将数据缓存,不是立刻写入磁盘,而是等到缓存满了以后将所有数据一次性的写入磁盘。可以调用 force(true) 方法将文件内容和元数据(文件的权限等信息)立刻写入磁盘。常见方法FileChannel 主要用来对本地文件进行 IO 操作,常见的方法有:public int read(ByteBuffer dst) :从通道中读取数据到缓冲区中。public int write(ByteBuffer src):把缓冲区中的数据写入到通道中。public long transferFrom(ReadableByteChannel src,long position,long count):从目标通道中复制数据到当前通道。public long transferTo(long position,long count,WriteableByteChannel target):把数据从当前通道复制给目标通道。使用 FileChannel 写入文本文件/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/29 * @Description FileChannel测试写入文件 */ public class FileChannelTest { public static void main(String[] args) { try (final FileChannel channel = new FileOutputStream("data1.txt").getChannel()) { String msg = "Hello World!!!"; final ByteBuffer buffer = ByteBuffer.allocate(16); buffer.put(msg.getBytes(StandardCharsets.UTF_8)); buffer.flip(); channel.write(buffer); } catch (IOException e) { e.printStackTrace(); } } }使用 FileChannel 读取文本文件/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/29 * @Description FileChannel测试读取文件 */ public class FileChannelTest { public static void main(String[] args) { try (final FileChannel channel = new FileInputStream("data1.txt").getChannel()) { final ByteBuffer buffer = ByteBuffer.allocate(16); channel.read(buffer); buffer.flip(); while (buffer.hasRemaining()) { System.out.print((char) buffer.get()); } //清空缓冲区,并重置为写模式 buffer.clear(); } catch (IOException e) { e.printStackTrace(); } } }使用 FileChannel 进行数据传输/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/29 * @Description FileChannel测试文件传输 */ public class FileChannelTest { public static void main(String[] args){ try (final FileChannel from = new FileInputStream("data.txt").getChannel(); final FileChannel to = new FileOutputStream("data1.txt").getChannel()) { // 参数:inputChannel的起始位置,传输数据的大小,目的channel // 返回值为传输的数据的字节数 // transferTo一次只能传输2G的数据 from.transferTo(0, from.size(), to); } catch (IOException e) { e.printStackTrace(); } } }transferTo()方法对应的还有 transferFrom()方法。虽然 transferTo()方法传输效率较高,底层利用操作系统的零拷贝进行优化,但是 transferTo 方法一次只能传输 2G 的数据。解决方法:可以根据 transferTo()的返回值来判断,返回值代表传输了多少,通过 from 的 size()大小来每次减去即可。long size = from.size(); for (long left = size; left > 0; ) { left -= from.transferTo(size - left, size, to); } Channel 和 Buffer 的注意事项ByteBuffer 支持类型化的 put 和 get,put 放入什么数据类型,get 就应该使用相应的数据类型来取出,否则可能会产生 ByteUnderflowException 异常。可以将一个普通的 Buffer 转换为只读的 Buffer:asReadOnlyBuffer()方法。NIO 提供了 MapperByteBuffer,可以让文件直接在内存(堆外内存)中进行修改,而如何同步到文件由 NIO 来完成。NIO 还支持通过多个 Buffer(即 Buffer 数组)完成读写操作,即Scattering(分散)和 Gathering(聚集)。Scattering(分散):在向缓冲区写入数据时,可以使用 Buffer 数组依次写入,一个 Buffer 数组写满后,继续写入下一个 Buffer 数组。Gathering(聚集):从缓冲区读取数据时,可以依次读取,读完一个 Buffer 再按顺序读取下一个。网络编程阻塞 vs 非阻塞阻塞在没有数据可读时,包括数据复制过程中,线程必须阻塞等待,不会占用 CPU,但线程相当于闲置状态32 位 JVM 一个线程 320k,64 位 JVM 一个线程 1024k,为了减少线程数量,需要采用线程池技术但即使使用线程池,如果有很多连接建立,但长时间 inactive,会阻塞线程池中所有线程非阻塞在某个 Channel 没有可读事件时,线程不必阻塞,它可以去处理其它有可读事件的 Channel数据复制过程中,线程实际还是阻塞的(AIO 改进的地方)写数据时,线程只是等待数据写入 Channel 即可,无需等待 Channel 通过网络把数据发送出去阻塞案例代码服务端代码:/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/29 * @Description 使用NIO来理解阻塞模式-服务端 */ public class Server { public static void main(String[] args) { //1. 创建服务器 try (ServerSocketChannel ssc = ServerSocketChannel.open()) { final ByteBuffer buffer = ByteBuffer.allocate(16); //2. 绑定监听端口 ssc.bind(new InetSocketAddress(7777)); //3. 存放建立连接的集合 List<SocketChannel> channels = new ArrayList<>(); while (true) { System.out.println("建立连接..."); //4. accept 建立客户端连接 , 用来和客户端之间通信 final SocketChannel socketChannel = ssc.accept(); System.out.println("建立连接完成..."); channels.add(socketChannel); //5. 接收客户端发送的数据 for (SocketChannel channel : channels) { System.out.println("正在读取数据..."); channel.read(buffer); buffer.flip(); ByteBufferUtil.debugRead(buffer); buffer.clear(); System.out.println("数据读取完成..."); } } } catch (IOException e) { System.out.println("出现异常..."); } } }客户端代码:/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/29 * @Description 使用NIO来理解阻塞模式-客户端 */ public class Client { public static void main(String[] args) { try (SocketChannel socketChannel = SocketChannel.open()) { // 建立连接 socketChannel.connect(new InetSocketAddress("localhost", 7777)); final ByteBuffer buffer = ByteBuffer.allocate(10); buffer.put("hello".getBytes(StandardCharsets.UTF_8)); buffer.flip(); socketChannel.write(buffer); } catch (IOException e) { e.printStackTrace(); } } }运行结果:在刚开始服务器运行后:服务器端因 accept 阻塞。在客户端和服务器建立连接后,客户端发送消息前:服务器端因通道为空被阻塞。客户端发送数据后,服务器处理通道中的数据。之后再次进入循环时,再次被 accept 阻塞。之前的客户端再次发送消息,服务器端因为被 accept 阻塞,就无法处理之前客户端再次发送到通道中的信息了。非阻塞通过 ServerSocketChannel 的configureBlocking(false)方法将获得连接设置为非阻塞的。此时若没有连接,accept 会返回 null通过 SocketChannel 的configureBlocking(false)方法将从通道中读取数据设置为非阻塞的。若此时通道中没有数据可读,read 会返回-1/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/29 * @Description 使用NIO来理解阻塞模式-服务端 */ public class Server { public static void main(String[] args) { //1. 创建服务器 try (ServerSocketChannel ssc = ServerSocketChannel.open()) { final ByteBuffer buffer = ByteBuffer.allocate(16); //2. 绑定监听端口 ssc.bind(new InetSocketAddress(7777)); //3. 存放建立连接的集合 List<SocketChannel> channels = new ArrayList<>(); //设置非阻塞!! ssc.configureBlocking(false); while (true) { System.out.println("建立连接..."); //4. accept 建立客户端连接 , 用来和客户端之间通信 final SocketChannel socketChannel = ssc.accept(); //设置非阻塞!! socketChannel.configureBlocking(false); System.out.println("建立连接完成..."); channels.add(socketChannel); //5. 接收客户端发送的数据 for (SocketChannel channel : channels) { System.out.println("正在读取数据..."); channel.read(buffer); buffer.flip(); ByteBufferUtil.debugRead(buffer); buffer.clear(); System.out.println("数据读取完成..."); } } } catch (IOException e) { System.out.println("出现异常..."); } } }因为设置为了非阻塞,会一直执行while(true)中的代码,CPU 一直处于忙碌状态,会使得性能变低,所以实际情况中不使用这种方法处理请求。Selector基本介绍Java 的 NIO 使用了非阻塞的 I/O 方式。可以用一个线程处理若干个客户端连接,就会使用到 Selector(选择器)。Selector 能够检测到多个注册通道上是否有事件发生(多个 Channel 以事件的形式注册到同一个 selector),如果有事件发生,便获取事件然后针对每个事件进行相应的处理。只有在连接真正有读写事件发生时,才会进行读写,减少了系统开销,并且不必为每个连接都创建一个线程,不用维护多个线程。避免了多线程之间上下文切换导致的开销。特点单线程可以配合 Selector 完成对多个 Channel 可读写事件的监控,这称为多路复用。多路复用仅针对网络 IO,普通文件 IO 无法利用多路复用如果不用 Selector 的非阻塞模式,线程大部分时间都在做无用功,而 Selector 能够保证有可连接事件时才去连接有可读事件才去读取有可写事件才去写入限于网络传输能力,Channel 未必随时可写,一旦 Channel 可写,会触发 Selector 的可写事件进行写入。Selector 相关方法说明selector.select()://若未监听到注册管道中有事件,则持续阻塞selector.select(1000)://阻塞 1000 毫秒,1000 毫秒后返回selector.wakeup()://唤醒 selectorselector.selectNow(): //不阻塞,立即返回NIO 非阻塞网络编程过程分析当客户端连接时,会通过 SeverSocketChannel 得到对应的 SocketChannel。Selector 进行监听,调用 select()方法,返回注册该 Selector 的所有通道中有事件发生的通道个数。将 SocketChannel 注册到 Selector 上,public final SelectionKey register(Selector sel, int ops),一个 Selector 上可以注册多个 SocketChannel。注册后返回一个 SelectionKey,会和该 Selector 关联(以集合的形式)。进一步得到各个 SelectionKey,有事件发生。再通过 SelectionKey 反向获取 SocketChannel,使用 channnel()方法。可以通过得到的 channel,完成业务处理。SelectionKey 中定义了四个操作标志位:OP_READ表示通道中发生读事件;OP_WRITE—表示通道中发生写事件;OP_CONNECT—表示建立连接;OP_ACCEPT—请求新连接。SelectionKey 的相关方法方法描述public abstract Selector selector();得到与之关联的 Selector 对象public abstract SelectableChannel channel();得到与之关联的通道public final Object attachment()得到与之关联的共享数据public abstract SelectionKey interestOps(int ops);设置或改变监听的事件类型public final boolean isReadable();通道是否可读public final boolean isWritable();通道是否可写public final boolean isAcceptable();是否可以建立连接 ACCEPTSelector 基本使用及 Accpet 事件接下来我们使用 Selector 实现多路复用,对服务端代码进行改进。/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/29 * @Description Selector基本使用-服务端 */ public class Server { public static void main(String[] args) { try (ServerSocketChannel ssc = ServerSocketChannel.open(); final Selector selector = Selector.open()) {//创建selector 管理多个channel ssc.bind(new InetSocketAddress(7777)); ssc.configureBlocking(false); // 将通道注册到选择器中,并设置感兴趣的事件 ssc.register(selector, SelectionKey.OP_ACCEPT); ByteBuffer buffer = ByteBuffer.allocate(16); while (true) { // 如果事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转 // 返回值为就绪的事件个数 int ready = selector.select(); System.out.println("selector就绪总数: " + ready); // 获取所有事件 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { final SelectionKey key = iterator.next(); //判断key的事件类型 if (key.isAcceptable()) { ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel(); final SocketChannel socketChannel = serverSocketChannel.accept(); System.out.println("获取到客户端连接..."); } // 处理完毕后移除 iterator.remove(); } } } catch (IOException e) { System.out.println("出现异常..."); } } }事件发生后,要么处理,要么使用 key.cancel()方法取消,不能什么都不做,否则下次该事件仍会触发,这是因为 nio 底层使用的是水平触发。当选择器中的通道对应的事件发生后,SelectionKey 会被放到另一个集合中,但是selecionKey 不会自动移除,所以需要我们在处理完一个事件后,通过迭代器手动移除其中的 selecionKey。否则会导致已被处理过的事件再次被处理,就会引发错误。Read 事件/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/29 * @Description Read事件-服务端 */ public class Server { public static void main(String[] args) { try (ServerSocketChannel ssc = ServerSocketChannel.open(); final Selector selector = Selector.open()) {//创建selector 管理多个channel ssc.bind(new InetSocketAddress(7777)); ssc.configureBlocking(false); // 将通道注册到选择器中,并设置感兴趣的事件 ssc.register(selector, SelectionKey.OP_ACCEPT); ByteBuffer buffer = ByteBuffer.allocate(16); while (true) { // 如果事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转 // 返回值为就绪的事件个数 int ready = selector.select(); System.out.println("selector就绪总数: " + ready); // 获取所有事件 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { final SelectionKey key = iterator.next(); //判断key的事件类型 if (key.isAcceptable()) { ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel(); final SocketChannel socketChannel = serverSocketChannel.accept(); System.out.println("获取到客户端连接..."); // 设置为非阻塞模式,同时将连接的通道也注册到选择其中 socketChannel.configureBlocking(false); socketChannel.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { //读事件 SocketChannel channel = (SocketChannel) key.channel(); channel.read(buffer); buffer.flip(); ByteBufferUtil.debugRead(buffer); buffer.clear(); } // 处理完毕后移除 iterator.remove(); } } } catch (IOException e) { System.out.println("出现异常..."); } } }断开处理当客户端与服务器之间的连接断开时,会给服务器端发送一个读事件,对异常断开和正常断开需要不同的方式进行处理:正常断开正常断开时,服务器端的 channel.read(buffer)方法的返回值为-1,所以当结束到返回值为-1 时,需要调用 key 的 cancel()方法取消此事件,并在取消后移除该事件异常断开异常断开时,会抛出 IOException 异常, 在 try-catch 的catch 块中捕获异常并调用 key 的 cancel()方法即可消息边界⚠️ 不处理消息边界存在的问题将缓冲区的大小设置为 4 个字节,发送 2 个汉字(你好),通过 decode 解码并打印时,会出现乱码ByteBuffer buffer = ByteBuffer.allocate(4); // 解码并打印 System.out.println(StandardCharsets.UTF_8.decode(buffer)); 你� ��这是因为 UTF-8 字符集下,1 个汉字占用 3 个字节,此时缓冲区大小为 4 个字节,一次读时间无法处理完通道中的所有数据,所以一共会触发两次读事件。这就导致 你好 的 好 字被拆分为了前半部分和后半部分发送,解码时就会出现问题。💡 处理消息边界传输的文本可能有以下三种情况:文本大于缓冲区大小,此时需要将缓冲区进行扩容发生半包现象发生粘包现象解决方案:固定消息长度,数据包大小一样,服务器按预定长度读取,当发送的数据较少时,需要将数据进行填充,直到长度与消息规定长度一致。缺点是浪费带宽另一种思路是按分隔符拆分,缺点是效率低,需要一个一个字符地去匹配分隔符TLV 格式,即 Type 类型、Length 长度、Value 数据(也就是在消息开头用一些空间存放后面数据的长度),如 HTTP 请求头中的 Content-Type 与Content-Length。类型和长度已知的情况下,就可以方便获取消息大小,分配合适的 buffer,缺点是 buffer 需要提前分配,如果内容过大,则影响 server 吞吐量下面演示第二种解决方案,按分隔符拆分:我们需要在 Accept 事件发生后,将通道注册到 Selector 中时,对每个通道添加一个 ByteBuffer 附件,让每个通道发生读事件时都使用自己的通道,避免与其他通道发生冲突而导致问题。ByteBuffer buffer = ByteBuffer.allocate(16); // 添加通道对应的Buffer附件 socketChannel.register(selector, SelectionKey.OP_READ, buffer);当 Channel 中的数据大于缓冲区时,需要对缓冲区进行扩容操作。此代码中的扩容的判定方法:Channel 调用 compact 方法后,的 position 与 limit 相等,说明缓冲区中的数据并未被读取(容量太小),此时创建新的缓冲区,其大小扩大为两倍。同时还要将旧缓冲区中的数据拷贝到新的缓冲区中,同时调用 SelectionKey 的 attach 方法将新的缓冲区作为新的附件放入 SelectionKey 中。// 如果缓冲区太小,就进行扩容 if (buffer.position() == buffer.limit()) { ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity()*2); // 将旧buffer中的内容放入新的buffer中 buffer.flip(); newBuffer.put(buffer); // 将新buffer放到key中作为附件 key.attach(newBuffer); }改进后的代码如下:/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/29 * @Description Read事件完整版-服务端 */ public class Server { public static void main(String[] args) { try (ServerSocketChannel ssc = ServerSocketChannel.open(); final Selector selector = Selector.open()) {//创建selector 管理多个channel ssc.bind(new InetSocketAddress(7777)); ssc.configureBlocking(false); // 将通道注册到选择器中,并设置感兴趣的事件 ssc.register(selector, SelectionKey.OP_ACCEPT); while (true) { // 如果事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转 // 返回值为就绪的事件个数 int ready = selector.select(); System.out.println("selector就绪总数: " + ready); // 获取所有事件 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { final SelectionKey key = iterator.next(); //判断key的事件类型 if (key.isAcceptable()) { ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel(); final SocketChannel socketChannel = serverSocketChannel.accept(); System.out.println("获取到客户端连接..."); socketChannel.configureBlocking(false); ByteBuffer byteBuffer = ByteBuffer.allocate(16); //注册到Selector并且设置读事件,设置附件bytebuffer socketChannel.register(selector, SelectionKey.OP_READ, byteBuffer); } else if (key.isReadable()) { //读事件 try { SocketChannel channel = (SocketChannel) key.channel(); // 通过key获得附件 ByteBuffer buffer = (ByteBuffer) key.attachment(); int read = channel.read(buffer); if (read == -1) { key.cancel(); channel.close(); } else { // 通过分隔符来分隔buffer中的数据 split(buffer); // 如果缓冲区太小,就进行扩容 if (buffer.position() == buffer.limit()) { ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity() * 2); // 将旧buffer中的内容放入新的buffer中 buffer.flip(); newBuffer.put(buffer); // 将新buffer放到key中作为附件 key.attach(newBuffer); } } } catch (IOException e) { //异常断开,取消事件 key.cancel(); } } // 处理完毕后移除 iterator.remove(); } } } catch (IOException e) { System.out.println("出现异常..."); } } private static void split(ByteBuffer buffer) { buffer.flip(); for (int i = 0; i < buffer.limit(); i++) { //找到一条完成数据 if (buffer.get(i) == '\n') { // 缓冲区长度 int length = i + 1 - buffer.position(); ByteBuffer target = ByteBuffer.allocate(length); // 将前面的内容写入target缓冲区 for (int j = 0; j < length; j++) { // 将buffer中的数据写入target中 target.put(buffer.get()); } ByteBufferUtil.debugAll(target); } } // 切换为写模式,但是缓冲区可能未读完,这里需要使用compact buffer.compact(); } }/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/29 * @Description Read事件完整版-客户端 */ public class Client { public static void main(String[] args) { try (SocketChannel socketChannel = SocketChannel.open()) { // 建立连接 socketChannel.connect(new InetSocketAddress("localhost", 7777)); final ByteBuffer buffer = ByteBuffer.allocate(32); buffer.put("01234567890abcdef3333\n".getBytes(StandardCharsets.UTF_8)); buffer.flip(); socketChannel.write(buffer); } catch (IOException e) { e.printStackTrace(); } } }ByteBuffer 的大小分配每个 channel 都需要记录可能被切分的消息,因为 ByteBuffer 不能被多个 channel 共同使用,因此需要为每个 channel 维护一个独立的 ByteBufferByteBuffer 不能太大,比如一个 ByteBuffer 1Mb 的话,要支持百万连接就要 1Tb 内存,因此需要设计大小可变的 ByteBuffer分配思路:一种思路是首先分配一个较小的 buffer,例如 4k,如果发现数据不够,再分配 8k 的 buffer,将 4k buffer 内容拷贝至 8k buffer,优点是消息连续容易处理,缺点是数据拷贝耗费性能另一种思路是用多个数组组成 buffer,一个数组不够,把多出来的内容写入新的数组,与前面的区别是消息存储不连续解析复杂,优点是避免了拷贝引起的性能损耗Write 事件服务器通过 Buffer 通道中写入数据时,可能因为通道容量小于 Buffer 中的数据大小,导致无法一次性将 Buffer 中的数据全部写入到 Channel 中,这时便需要分多次写入,具体步骤如下:执行一次写操作,向将 buffer 中的内容写入到 SocketChannel 中,然后判断 Buffer 中是否还有数据若 Buffer 中还有数据,则需要将 SockerChannel 注册到 Seletor 中,并关注写事件,同时将未写完的 Buffer 作为附件一起放入到 SelectionKey 中。/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/29 * @Description Write事件-服务端 */ public class Server { public static void main(String[] args) { try (ServerSocketChannel ssc = ServerSocketChannel.open(); final Selector selector = Selector.open()) { ssc.bind(new InetSocketAddress(7777)); ssc.configureBlocking(false); ssc.register(selector, SelectionKey.OP_ACCEPT); while (true) { int ready = selector.select(); Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { final SelectionKey key = iterator.next(); //判断key的事件类型 if (key.isAcceptable()) { final SocketChannel socketChannel = ssc.accept(); socketChannel.configureBlocking(false); StringBuilder sb = new StringBuilder(); for (int i = 0; i < 3000000; i++) { sb.append("a"); } final ByteBuffer buffer = Charset.defaultCharset().encode(sb.toString()); final int write = socketChannel.write(buffer); System.out.println("accept事件器写入.."+write); // 判断是否还有剩余内容 if (buffer.hasRemaining()) { // 注册到Selector中,关注可写事件,并将buffer添加到key的附件中 socketChannel.register(selector, SelectionKey.OP_WRITE, buffer); } }else if (key.isWritable()) { SocketChannel socket = (SocketChannel) key.channel(); // 获得事件 ByteBuffer buffer = (ByteBuffer) key.attachment(); int write = socket.write(buffer); System.out.println("write事件器写入.."+write); // 如果已经完成了写操作,需要移除key中的附件,同时不再对写事件感兴趣 if (!buffer.hasRemaining()) { key.attach(null); key.interestOps(0); } } // 处理完毕后移除 iterator.remove(); } } } catch (IOException e) { System.out.println("出现异常..."); } } }/** * @author 神秘杰克 * 公众号: Java菜鸟程序员 * @date 2021/12/29 * @Description Write事件-客户端 */ public class Client { public static void main(String[] args) { try (SocketChannel socketChannel = SocketChannel.open()) { // 建立连接 socketChannel.connect(new InetSocketAddress("localhost", 7777)); int count = 0; while (true) { final ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024); count += socketChannel.read(buffer); System.out.println("客户端接受了.."+count); buffer.clear(); } } catch (IOException e) { e.printStackTrace(); } } }运行结果:
概述篇计算机网络在信息时代的作用计算机网络已由一种通讯基础设施发展成为一种重要的信息服务基础设施。计算机网络已经像水、电、煤气这些基础设施一样,成为我们生活中不可或缺的一部分。因特网概述网络、互连网(互联网)和因特网网络(Network)由若干结点(Node)和连接这些结点的链路(Link)组成。在如下例子中,4个节点和3段链路就构成了一个简单的网络。多个网络还可以通过路由器互连起来,这样就构成了一个覆盖范围更大的网络,即互连网(互联网)。因此互联网是“网络的网络(Network of Networks)”。因特网(Internet)是世界上最大的互联网络(用户数以亿计,互连的网络数以百万计)。internet和Internet的区别internet(互联网或互连网)是一个通用名词,它泛指由多个计算机网络互连而成的网络。在这些网络之间的通讯协议可以是任意的。Internet(因特网)则是一个专用名词,它是指当前全球最大的、开放的、由众多网络相互连接而成的特定计算机网络,它采用TCP/IP协议族作为通信的规则,其前身是美国的ARPANET。因特网发展的三个阶段因特网服务提供者ISP(Internet Service Provider)普通用户是如何接入到因特网的呢?答:通过ISP接入因特网ISP可以从因特网管理机构申请到成块的IP地址,同时拥有通信线路以及路由器等联网设备。任何机构和个人只需缴纳费用,就可从ISP的得到所需要的IP地址。因为因特网上的主机都必须有IP地址才能进行通信,这样就可以通过该ISP接入到因特网中国的三大ISP:中国电信,中国联通和中国移动基于ISP的三层结构的因特网第一层ISP通常也被称为因特网主干网,一般都能够覆盖国际性区域范围,并拥有高速链路和交换设备。第一层ISP之间直接互联。第二层的ISP和一些大公司都是第一层ISP的用户,通常具有区域性或国家性覆盖规模,与少数第一层ISP相连接。第三层ISP又称为本地ISP,它们是第二层的用户且只拥有本地范围的网络。一般的校园网或者企业网,以及住宅用户和无线移动用户,都是第三层ISP的用户。一旦某个用户能够接入到因特网,那么他也可以成为一个ISP,所需要做的就是购买一些如调制解调器或路由器这样的设备,让其他用户可以和他相连。因特网的标准化工作因特网的标准化工作对因特网的发展起到了非常重要的作用。因特网在制定其标准上的一个很大的特点是面向公众。因特网所有的RFC(Request For Comments)技术文档都可以从因特网上免费下载。任何人都可以随时用电子邮件发表对某个文档的意见或建议。因特网协会ISOC是一个国际性组织,它负责对因特网进行全面管理,以及在世界范围内促进发展和作用。因特网体系结构委员会IAB,负责管理因特网有关协议的开发。因特网工程部IETF,负责研究中短期工程问题,主要针对协议的开发和标准化。因特网研究部IRTF,从事理论方面的研究和开发一些需要长期考虑的问题。制订因特网的正式标准要经过以下4个阶段:因特网草案(在这个阶段还不是RFC文档)建议标准(从这个阶段开始就成为RFC文档)草案标准因特网标准因特网的组成因特网的拓扑结构虽然十分复杂,并且在地理上覆盖了全球,但从功能上看,可以划分为以下两个部分。边缘部分由所有连接在因特网上的主机组成。这部分是用户直接使用的,用来进行通信(传送数据、音频或视频)和资源共享。核心部分由大量网络和连接这些网络的路由器组成。这部分是为边缘部分提供服务的(提供连通性和交换)。路由器是一种专用计算机,但我们不称它为主机,路由器是实现分组交换的关键构件,其任务是转发收到的分组,这是网络核心最重要的部分。处在互联网边缘的部分就是连接在互联网上的所有的主机。这些主机又称为端系统 (end system)。端系统在功能上可能有很大的差别:小的端系统可以是一台普通个人电脑,具有上网功能的智能手机、网络摄像头等。大的端系统则可以是一台非常昂贵的大型计算机。端系统的拥有者可以是个人,也可以是单位(如学校、企业、政府机关等),当然也可以是某个ISP。端系统之间的通信方式通常可划分为两大类:客户-服务器方式(C/S):客户 (client) 和服务器 (server) 都是指通信中所涉及的两个应用进程。客户 - 服务器方式所描述的是进程之间服务和被服务的关系。客户是服务的请求方,服务器是服务的提供方。对等连接方式(peer-to-peer:P2P):对等连接是指两个主机在通信时并不区分哪一个是服务请求方还是服务提供方。只要两个主机都运行了对等连接软件 ( P2P 软件) ,它们就可以进行平等的、对等连接通信。双方都可以下载对方已经存储在硬盘中的共享文档。三种交换方式电路交换(Circuit Switching)传统两两相连的方式,当电话数量很多时,电话线就也会非常的多,这样是非常不方便的。所以要使得每一部电话能够很方便地和另一部电话进行通信,就应该使用一个中间设备将这些电话连接起来,这个中间设备就是电话交换机。每一部电话都连接到电话交换机上,可以把电话交换机简单地看成是一个有多个开关的开关器,可以将需要通信的任意两部电话的电话线路按需接通,从而大大减少了连接的电话线数量。当电话数量增多时,就要使用很多彼此连接起来的电话交换机来完成全网的交换任务。用这样的方法就构成了覆盖全世界的电信网。电话交换机接通电话线的方式称为电路交换。从通信资源的分配角度来看,交换(Switching)就是按照某种方式动态地分配传输线路的资源。电路交换的三个步骤:建立连接(分配通信资源)通话(一直占用通信资源)释放连接(归还通信资源)这里就引发一个问题:如果使用电路交换来传送计算机数据,是否可行?尽管采用电路交换可以实现计算机之间的数据传送,但其线路的传输效率往往很低。这是因为计算机数据是突发式地出现在传输线路上的。所以计算机通常采用的是分组交换,而不是电路交换。分组交换(Packet Switching)在因特网中,最重要的分组交换机就是路由器。它负责将各种网络互连起来,并对接收到的分组进行转发,也就是进行分组交换。我们来看一个例子:假设主机H6的用户要给主机H2的用户发送一条消息,通常我们把表示该消息的整块数据称作一个报文。在发送报文之前,先把较长的报文划分成为一个个更小的等长数据段。在每一个数据段前面,加上一些由必要的控制信息组成的首部后,就构成了一个分组,也可简称为“包”,相应的首部也可以称为“包头”。那么,添加首部的作用是什么?这不是额外增加了传输量吗?首先,首部包含了分组的目的地址,否则分组传输路径中的各分组交换机(也就是各路由器)就不知道如何转发分组了。分组交换机收到一个分组后,先将分组暂时存储下来,再检查其首部,按照首部的目的地址进行查表转发,找到合适的转发接口,通过该接口将分组转发给下一个分组交换机。在本例中,主机H6将所构造出的各分组依次发送出去,各分组经过途中各分组交换机的存储转发,最终达到主机H2。主机H2在收到这些分组后,去掉他们的首部,将各数据段组合还原出原始报文。需要注意的是接受顺序可能和发送顺序并不相同。在本例中,对于可能出现的分组丢失、误码、重复等问题并没有演示。(后续介绍)接下来我们看一下它们的职责:发送方:构造分组发送分组路由器:缓存分组转发分组接收方:接收分组还原报文报文交换(Message Switching)报文交换中的交换结点也采用存储转发方式,但报文交换对报文的大小没有限制,这就要求交换结点需要较大的缓存空间。报文交换主要用于早期的电报通信网,现在较少使用,通常被较先进的分组交换方式所取代。三种交换方式的对比假设A,B,C,D是分组传输路径所要经过的4个结点交换机,纵坐标为时间电路交换:通信之前首先要建立连接;连接建立好之后,就可以使用已建立好的连接进行数据传送;数据传送后,需释放连接,以归还之前建立连接所占用的通信线路资源。一旦建立连接,中间的各结点交换机就是直通形式的,比特流可以直达终点;报文交换:可以随时发送报文,而不需要事先建立连接;整个报文先传送到相邻结点交换机,全部存储下来后进行查表转发,转发到下一个结点交换机。整个报文需要在各结点交换机上进行存储转发,由于不限制报文大小,因此需要各结点交换机都具有较大的缓存空间。分组交换:可以随时发送分组,而不需要事先建立连接。构成原始报文的一个个分组,依次在各结点交换机上存储转发。各结点交换机在发送分组的同时,还缓存接收到的分组。构成原始报文的一个个分组,在各结点交换机上进行存储转发,相比报文交换,减少了转发时延,还可以避免过长的报文长时间占用链路,同时也有利于进行差错控制。小总结计算机网络的定义和分类计算机网络的定义计算机网络的精确定义并未统一。计算机网络的最简单的定义是:一些互相连接的、自治的计算机的集合。互连:是指计算机之间可以通过有线或无线的方式进行数据通信自治:是指独立的计算机,他有自己的硬件和软件,可以单独运行使用集合:是指至少需要两台计算机计算机网络的较好的定义是:计算机网络主要是由一些通用的,可编程的硬件(一定包含有中央处理机CPU)互连而成的,而这些硬件并非专门用来实现某一特定目的(例如,传送数据或视频信号)。这些可编程的硬件能够用来传送多种不同类型的数据,并能支持广泛的和日益增长的应用。计算机网络所连接的硬件,并不限于一般的计算机,而是包括了智能手机等智能硬件。计算机网络并非专门用来传送数据,而是能够支持很多种的应用(包括今后可能出现的各种应用)。计算机网络的分类按交换技术分类:电路交换网络报文交换网络分组交换网络按使用者分类:公用网专用网按传输介质分类:有线网络无线网络按覆盖范围分类:广域网WAN(Wide Area Network):作用范围通常为几十到几千公里,因而有时也称为远程网(long haul network)。广域网是互联网的核心部分,其任务是通过长距离(例如,跨越不同的国家)运送主机所发送的数据。城域网MAN:作用范围一般是一个城市,可跨越几个街区甚至整个城市。局域网LAN:一般用微型计算机或工作站通过高速通信线路相连(速率通常在 10 Mbit/s 以上),但地理上范围较小(1 KM 左右)。个域网PAN:就是在个人工作的地方把个人使用的电子设备用无线技术连接起来的网络。按拓扑结构分类:总线型网络:使用单根传输线把计算机连接起来。它的优点是容易、增减结点方便、节省线路;缺点是重负载时通信效率不高,总线任意一处出现故障,则全网瘫痪。星型网络:将每个计算机都以单独的线路与中央设备相连。中央设备早期是计算机,后来是集线器,现在一般是交换机和路由器。便于网络的集中控制和管理,因为端用户之间的通信必须经过中央设备。缺点是成本高,中央设备对故障敏感。环形网络:将所有计算机的网络接口连接成一个环。最经典的例子是令牌环局域网。环可以是单环也可以是双环,环中信号是单向传输的。网状型网络:一般情况下,每个结点至少由两条路径与其它节点相连。多用在广域网中。其优点是可靠性高,缺点是控制复杂、线路成本高。计算机网络的性能指标性能指标可以从不同的方面来度量计算机网络的性能。常用的计算机网络的性能有如下8个:速率带宽吞吐量时延时延带宽积往返时间利用率丢包率速率了解速率之前,我们先了解什么是比特。比特:就是计算机中数据量的单位,也是信息论中信息量的单位。一个比特就是二进制数字中的一个1或者0。常用的数据量单位:接下来我们了解一下什么是速率:连接在计算机网络上的主机在数字信道上传送比特的速率,也称为比特率和数据率。常用的数据量单位:带宽带宽在模拟信号系统中的意义信号所包含的各种不同频率成分所占据的频率范围。单位:Hz(kHz,MHz,GHz)带宽在计算机网络中的意义用来表示网络的通信线路所能传送数据的能力,因此网络带宽表示在单位时间内从网络的某一点到另一点所能通过的“最高数据率”。单位:b/s(kb/s,Mb/s,Gb/s,Tb/s)其实“带宽”的这两种表述之间有着密切的联系。一条通信线路的“频带宽度”越宽,其所传输的数据“最高数据率”也就越高。吞吐量吞吐量表示在单位时间内通过某个网络(或信道、接口)的数据量。吞吐量被经常用于对现实世界中的网络的一种测量,以便知道实际上到底有多少数据量能够通过网络。吞吐量受网络的带宽或额定速率的限制。带宽1 Gb/s的以太网,代表其额定速率是1 Gb/s,这个数值也是该以太网的吞吐量的绝对上限值。因此,对于带宽1 Gb/s的以太网,可能实际吞吐量只有 700 Mb/s,甚至更低。注意:吞吐量还可以用每秒传送的字节数或帧数表示时延时延时指数据(一个报文或分组,甚至比特)从网络(或链路)的一端传送到另一端所需的时间。网络时延由几部分组成:发送时延:主机或路由器发送数据帧所需要的时间,也就是从发送数据帧的第一个比特算起,到该帧的最后一个比特发送完毕所需的时间。传播时延:电磁波在信道中传播一定的距离需要花费的时间。处理时延:主机或路由器在收到分组时要花费一定时间进行处理。排队时延:分组在进过网络传输时,要经过许多路由器。但分组在进入路由器后要先在输入队列中排队等待处理。问题:网络总延迟中是传播时延占主导还是发送时延占主导?当处理时延忽略不计时,发送时延和传播时延谁占主导,要具体情况具体分析时延带宽积首先根据名字可以知道时延带宽积就是时延和带宽的乘积。那时延又分为发送时延,传播时延,处理时延三部分构成,那究竟是哪一个时延呢?答案是传播时延。时延带宽积 = 传播时延 * 带宽若发送端连续发送数据,则在所发送的第一个比特即将到达终点时,发送端就已经发送了时延带宽积个比特。链路的时延带宽积又称为“以比特为单位的链路长度”。往返时间在很多情况下,因特网上的信息不仅仅单方面传输,而是双向交互。我们有时需要知道双向交互一次所需要的时间。因此往返时间RTT(Round-Tirp Time)也是一个重要的性能指标。我们来看一个例子:以太网上的某台主机要与无限局域网中的某台主机进行信息交互。往返时间RTT是指从源主机发送分组开始,直到源主机收到来自目的主机的确认分组为止,所需要的时间。根据之前的内容我们可以想一下,到底在以太网耗时较多还是无线局域网又或者是卫星链路呢?答案为:卫星链路耗时较多。一般情况下,卫星链路的距离比较远。所带来的传播时延比较大。利用率利用率包括两种:信道利用率:用来表示某信道有百分之几的时间是被利用的(有数据通过)。网络利用率:全网络的信道利用率的加权平均。根据排队论,当某信道的利用率增大时,该信道引起的时延也会迅速增加。因此,信道利用率并非越高越好。如果使用D0表示网络空闲时的时延,D表示网络当前的时延,那么在适当的假定条件下,可以用下面的简单公式来表示D、D0和利用率U之间的关系:当网络的利用率达到50%时,时延就要加倍。当网络的利用率超过50%时,时延急剧增大。当网络的利用率接近100%时,时延就趋于无穷大。因此,一些拥有较大主干网的ISP通常会控制它们的信道利用率不超过50%,如果超过了,就准备扩容,增大线路的带宽。当然,也不能使信道利用率太低,这会使宝贵的通信资源被白白浪费。应该使用一些机制,可以根据情况动态调整输入到网络中的通信量,使网络利用率保持在一个合理的范围内。丢包率丢包率即分组丢失率,是指在一定的时间范围内,传输过程中丢失的分组数量与总分组数量的比率。丢包率具体可分为接口丢包率、结点丢包率、链路丢包率、路径丢包率、网络丢包率等。分组丢失主要情况分为两种:1.分组在传输过程中出现误码,被结点丢弃。如下图所示,主机发送的分组在传输过程中出现了误码,当分组进入传输路径中的交换机后,被结点交换机检测出了误码,进而被丢弃。2.分组到达一台队列已满的分组交换机后时被丢弃,在通信量较大时就可能造成网络拥塞。如图所示,假设路由器R5当前的输入缓冲区已满,此时主机发送的分组到达该路由器,路由器没有存储空间暂存该分组,只能将其丢弃。需要说明的是:实际上,路由器会根据自身的拥塞控制方法,在输入缓存还未满的时候就主动丢弃分组。因此丢包率反映了网络的拥塞情况。无拥塞时丢包率为0。轻度拥塞时路径丢包率为1%~4%严重拥塞时枯井丢包率为5%~15%计算机网络体系结构常见的计算机网络体系结构如今用的最多的是TCP/IP体系结构,现今规模最大的、覆盖全球的、基于TCP/IP的互联网并未使用OSI标准。TCP/IP体系结构相当于将OSI体系结构的物理层和数据链路层合并为了网络接口层,并去掉了会话层和表示层。TCP/IP在网络层使用的协议是IP协议,IP协议的意思是网际协议,因此TCP/IP体系结构的网络层称为网际层在用户主机的操作系统中,通常都带有符合TCP/IP体系结构标准的TCP/IP协议族。而用于网络互连的路由器中,也带有符合TCP/IP体系结构标准的TCP/IP协议族。只不过路由器一般只包含网络接口层和网际层。TCP/IP体系结构中的网络接口层并没有规定什么具体的内容,这样做的目的就是为了可以互连全世界不同的网络接口,例如有线的以太网接口,无线局域网的WiFi接口,而不限定仅使用一种或几种网络接口。因此,本质上TCP/IP体系结构只有上面的三层。IP协议是TCP/IP体系结构中网际层的核心协议。TCP和UDP是TCP/IP体系结构中运输层的两个重要协议。TCP/IP体系结构中的应用层包含了大量的应用层协议,例如:HTTP、SMTP、DNS、RTP等等。IP协议可以将不同的网络接口进行互连,并向其上的TCP协议和UDP协议提供网络互连服务。TCP协议在享受IP协议提供的网络互连服务的基础上,可向应用层的相应协议提供可靠传输的服务。UDP协议在享受IP协议提供的网络互连服务的基础上,可向应用层的相应协议提供不可靠传输的服务。IP协议作为TCP/IP体系结构中的核心协议,一方面负责互连不同的网络接口,另一方面为各种网络应用提供服务。TCP/IP体系结构中最重要的是IP协议和TCP协议,因此用TCP和IP来表示整个协议大家族。该文中使用五层协议的原理体系结构,来方便理解。计算机网络体系结分层的必要性计算机网络是个非常复杂的系统,早在最初的ARPANET设计时就提出了分层的设计理念。"分层"可将庞大而复杂的问题,转化为若干较小的局部问题,而这些较小的局部问题就比较容易研究和处理。下面,我们按照由简单到复杂的顺序,来看看实现计算机网络要面临哪些主要的问题,以及如何将这些问题划分到响应的层次,层层处理。物理层我们首先看最简单的情况,两台计算机通过一根网线进行连接。我们需要考虑以下问题:采用怎样的传输媒体(介质)采用怎样的物理接口使用怎样的信号来表示比特0和1解决了这些问题就可以进行数据传输了,而这些问题我们可以划分到物理层。但我们需要说明一点,严格来说,传输媒体并不属于物理层,它并不包含在体系结构之中。数据链路层实用的计算机网络,一般由多台主机构成。例如下图通过总线互连,构成了一个总线型网络。假设,我们已经解决了物理层的问题,也就是说,主机间可以发送信号来传输比特0或1了。我们接下来看看,在这样一个总线型网络上,还面临什么样的问题需要解决。假设A需要发送数据给C,但是,表示数据的信号会通过总线传播给总线上的每一台主机。主机C如何知道该数据是发送给自己的,自己要接收?而其它主机又如何知道数据并不是发送给自己的,自己应该拒绝呢?如何标识网络中的各主机(主机编址问题,例如MAC地址)目的主机如何从信号所表示的一连串比特流中区分出地址和数据如何协调各主机争用总线需要说明的是,这种总线型的网络早已淘汰,现在常用的是使用以太网交换机将多台主机互连形成的交换式以太网。我们将这些问题划归到数据链路层。我们发现只要解决了物理层和数据链路层各自所面临的问题,我们就可以实现分组在一个网络上传输了。但是我们平常使用的因特网,是由非常多的网络和路由器互连起来的,仅解决物理层和数据链路层还是不能正常工作。网络层我们来看下面这个例子,这是一个由3个路由器,4个网络互连起来的小型互联网。在之前的例子中仅有一个网络,我们不需要对网络进行标识。而在本例中存在多个网络,很显然,我们面临如何标识各网络以及网络中各主机的问题。如何标识各网络以及网络中的各主机(网络和主机共同编址的问题,例如IP地址)我们再来看另一个问题。源主机与目的主机之间的传输路径往往不止一条。分组从源主机到目的主机可走不同的路径。这样就引出了路由器如何转发分组的问题以及如何进行路由选择的问题这些问题我们都划归到网络层。运输层至此,如果我们解决了物理层、数据链路层以及网络层各自的问题,则可以实现分组在网络间传输的问题。但是,对于计算机网络应用而言,这仍然不够。例如,假设这台主机中运行着两个与网络通信相关的应用进程,一个是浏览器进程,另一个是QQ进程。这台服务器中运行着与网络通信相关的服务器进程。某个时刻,主机收到了来自服务器的分组,那么,这些分组应该交给浏览器进程处理呢?还是QQ进程处理?如何解决进程之间基于网络的通信问题出现传输错误时(例如误码,丢包等),如何处理我们将这些问题全部划归到运输层。应用层当解决以上的问题,我们就可以进程之间基于网络的通信。在此基础上,只需制定各种应用层协议,并按协议标准编写相应的应用程序,通过应用进程间的交互来完成特定的网络应用。例如,支持万维网应用的HTTP协议、支持电子邮件的SMTP协议、支持文件传输的FTP协议等。我们将这些问题全部划归到应用层。小总结计算机网络体系结构分层思想举例假设,网络拓扑如下图所示:主机属于网络N1,Web服务器属于网络N2,N1和N2通过路由器互连。我们使用主机中的浏览器来访问Web服务器,当输入网址后,主机会向Web服务器发送请求。Web服务器收到请求后,会发回相应的响应。主机收到响应后,将其解析为具体的网页内容显示出来。主机和Web服务器之间基于网络的通信,实际上是主机中的浏览器应用进程与Web服务器中的Web服务器应用进程之间基于网络的通信。那么,体系结构中的各层在整个过程中起到怎样的作用呢?从主机端按体系结构自顶向下的顺序来看:应用层按HTTP协议的规定,构建一个HTTP请求报文,随后应用层将HTTP请求报文交付给运输层处理。运输层给HTTP请求报文添加一个TCP首部,使之成文TCP报文段。(该首部的作用主要是为了区分应用进程,以及实现可靠传输)。随后运输层将TCP报文段交给网络层处理。网络层给TCP报文段添加一个IP首部,使之成为IP数据报。(该首部的作用是为了使IP数据报,可以在互联网上传输,也就是被路由器转发)。随后网络层交付给数据链路层处理。数据链路层给IP数据报添加一个首部和一个尾部,使之成为帧。(该首部的作用主要是为了让帧能够在一段链路上或一个网络上传输),能够被相应的目的主机接收。而尾部的帧是为了让目的主机检查所接收到的帧是否有误码。随后交付给物理层。物理层将帧看作是比特流,由于网络N1是以太网,因此物理层还会给该比特流前面添加前导码。起作用是为了让目的主机做好接收帧的准备。随后将比特流变换成响应的信号发送给传输媒体。我们接下来看路由器的处理过程:物理层将信号变换为比特流,然后去掉前导码,将其交付给数据链路层,交付的其实是帧。数据链路层将帧的首部和尾部去掉后,交付给网络层,交付的其实是IP数据报。网络层解析IP数据包的首部,从中提取出目的网络地址,然后查找自身的路由表,确定转发端口。最后将IP数据包交付给数据链路层。数据链路层给IP数据报添加一个首部和一个尾部使之成为帧。随后交付给物理层。物理层将数据看作为比特流,由于网络N2是以太网,因此物理层还会给该比特流前面添加前导码。随后变换为响应的信号发送到传输媒体。信号通过传输媒体到达Web服务器。我们接下来看Web服务器的处理过程:物理层将信号变换为比特流,然后去掉前导码后,交付给数据链路层,交付的其实是帧。数据链路层将帧的首部和尾部去掉后,交付给网络层,交付的其实是IP数据报。网络层将IP数据报的首部去掉后,交付给运输层,交付的其实是TCP报文段。运输层将TCP报文段的首部去掉后,将其交付给应用层,交付的其实是HTTP请求报文。应用层对HTTP请求报文进行解析,然后给主机发回HTTP响应报文。之后和上述类似,层层封装后返回。计算机网络体系结构中的专用术语以下介绍的专用术语来源于OSI的七层协议体系结构,但也适用于TCP/IP的四层体系结构和五层协议原理体系结构。实体实体:任何发送或接收信息的硬件或软件进程。对等实体:收发双方相同层次中的实体。协议协议:控制两个对等实体进行逻辑通信的规则的集合协议的三个要素:语法:定义所交换信息的格式语义:定义收发双方所要完成的操作同步:定义收发双方的时序关系服务在协议的控制下,两个对等实体间的逻辑通信使得本层能够向上一层提供服务。要实现本层协议,还需要使用下一层所提供的服务。协议是"水平的",服务是"垂直的"。实体看得见相邻下层所提供的服务,但并不知道实现该服务的具体协议。也就是说,下层协议对上层的实体是"透明的"。服务访问点在同一系统中相邻两层的实体交换信息的逻辑接口,用于区分不同的服务类型。数据链路层的服务访问点为帧的"类型"字段。网络层的服务访问点位IP数据报首部中的"协议字段"。运输层的访问访问点为"端口号"。服务原语上层使用下层所提供的服务必须通过与下层交换一些命令,这些命令称为服务原语。协议数据单元PDU对等层次之间传送的数据包称为该层的协议数据单元。例如,物理层对等实体逻辑通信的数据包称为比特流;数据链路层对等实体逻辑通信的数据包称为帧;网络层对等实体逻辑通信的数据包称为分组,如果使用IP协议也成为IP数据报;运输层对等实体逻辑通信的数据包一般根据协议而定,比如使用TCP协议,就称为TCP报文段,使用UDP协议,则称为UDP用户数据报;应用层对等实体逻辑通信的数据包一般称为应用报文。服务数据单元SDU同一系统内,层与层之间交换的数据包称为服务数据单元。多个SDU可以合成一个PDU;一个SDU也可以划分为几个PDU。
背景说明生产环境中出现的问题生产环境发生了内存溢出该如何处理?生产环境应该给服务器分配多少内存合适?如何对垃圾回收器的性能进行调优?生产环境 CPU 负载期飙高该如何处理?生产环境应该给应用分配多少线程合适?不加 log,如何确定请求是否执行了某一行代码?不加 log,如何实时查看某个方法的入参与返回值?为什么要调优防止出现 OOM解决 OOM减少 Full GC 出现的频率不同阶段的考虑上线前项目运行阶段线上出现 OOM调优概述监控的依据运行日志异常堆栈GC 日志线程快照堆转储快照调优的大方向合理的编写代码充分并合理使用硬件资源合理地进行 JVM 调优性能优化的步骤第一步:性能监控(发现问题)一种以非强行或者入侵方式收集或查看应用运营性能数据的活动。监控通常是指一种在生产、质量评估或者开发环境下实施的带有预防或主动性的活动。当应用相关干系人提出性能问题却没有提供足够多的线索时,首先我们需要进行性能监控,随后是性能分析。GC 频繁CPU load 过高OOM内存泄漏死锁程序响应时间较长第二步:性能分析(排查问题)一种以侵入方式收集运行性能数据的活动,它会影响应用的吞吐量或响应性。性能分析是针对性能问题的答复结果,关注的范围通常比性能监控更加集中。性能分析很少在生产环境下进行,通常是在质量评估、系统测试或者开发环境下进行,是性能监控之后的步骤。打印 GC 日志,通过GCview或者 http://gceasy.io来分析日志信息灵活运用,命令行工具,jstack、 jmap、 jinfo等dump出堆文件,使用内存分析工具分析文件使用阿里Arthas, 或 jconsole, JVisualVM来实时查看 JVM 状态jstack查看堆栈信息第三步:性能调优(解决问题)一种为改善应用响应性或吞吐量而更改参数、源代码、属性配置的活动,性能调优是在性能监控、性能分析之后的活动。适当增加内存,根据业务背景选择垃圾回收器优化代码,控制内存使用增加机器,分散节点压力合理设置线程池线程数量使用中间件提高程序效率,比如缓存,消息队列等...性能评价/测试指标停顿时间(响应时间)提交请求和返回该请求的响应之间使用的时间,一般比较关注平均响应时间。常用操作的响应时间列表:操作响应时间打开一个站点几秒数据库查询一条记录(有索引)十几毫秒机械磁盘一次寻址定位4 毫秒从机械磁盘顺序读取 1M 数据2 毫秒从 SSD 磁盘顺序读取 1M 数据0.3 毫秒从远程分布式换成 Redis 读取一个数据0.5 毫秒从内存读取 1M 数据十几微妙Java 程序本地方法调用几微妙网络传输 2Kb 数据1 微妙在垃圾回收环节中:暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。-XX:MaxGCPauseMillis吞吐量对单位时间内完成的工作量(请求)的量度在 GC 中:运行用户代码的时间占总运行时间的比例(总运行时间:程序的运行时间+内存回收的时间)吞吐量为:1-1/(1+n)-XX:GCTimeRatio=n并发数同一时刻,对服务器有实际交互的请求数。1000 个人同时在线,估计并发数在 5%-15%之间,也就是同时并发量:50-150 之间。内存占用Java 堆区所占的内存大小。相互间的关系以高速公路通行状况为例吞吐量:每天通过高速公路收费站的车辆的数据并发数:高速公路上正在行驶的车辆的数响应时间:车速随着并发数越来越多,响应时间也就是车速会慢慢降低,吞吐量也可能会反而降低。JVM 监控及诊断工具-命令行概述性能诊断是软件工程师在日常工作中需要经常面对和解决的问题,在用户体验至上的今天,解决好应用的性能问题能带来非常大的收益。Java 作为最流行的编程语言之一,其应用性能诊断一直受到业界广泛关注。可能造成 Java 应用出现性能问题的因素非常多,例如线程控制、磁盘读写、数据库访问、网络 I/O、垃圾收集等。想要定位这些问题,一款优秀的性能诊断工具必不可少。简单命令行工具刚接触 java 学习的时候,大家肯定最先了解的两个命令就是javac , java那么 , 除此之外,还有没有其他的命令可以供我们使用呢?我们进入到安装 jdk 中的 bin 目录,发现还有一系列辅助工具。这些辅助工具用来获取目标 JVM 不同方面、不同层次的信息,帮助开发人员很好地解决 Java 应用程序的一些疑难杂症。mac 系统:jps:查看正在运行的 Java 进程JPS(Java Process Staflus):显示指定系统内所有的 HotSpot 虚拟机进程,可用于查询正在运行的虚拟机进程。测试/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2021/2/7 * @Description: 线程休眠,查看jps命令 */ public class ThreadSleep { public static void main(String[] args) throws InterruptedException { TimeUnit.HOURS.sleep(1); } }运行起来之后,我们在命令行输入 jps 可以查看到该进程 id 以及名称。对于本地虚拟机进程来说,进程的本地虚拟机 ID与操作系统的进程 ID是一致的,是唯一的。基本用法语法格式:jps [options] [hostid]options 参数-q:仅仅显示本地虚拟机唯一 id。不显示名称。jps -q-l:输入应用程序朱磊的全类名或如果执行的是 jar 包,则输出 jar 完整路径。jps -l-m:输出虚拟机进程启动时传递给主类 main()的参数jps -m-v :列出虚拟机进程启动时的 JVM 参数。比如:-Xms20m -Xmx50mjps -v如果 Java 进程关闭了默认开启的UsePerfData参数(即使用参数-XX:-UsePerfData) , 那么 jps 命令以及 jstat 将无法获取该 Java 进程。hostid 参数RMI 注册表中注册的主机名。如果想要远程监控主机上的 java 程序,需要安装 jstatd。对于具有更严格的安全实践的网络场所而言,可能使用一个自定义的策略文件来显示对特定的可信主机或网络的访问,尽管这种技术容易受到 IP 地址欺诈攻击。如果安全问题无法使用一个定制的策略文件来处理,那么最安全的操作是不运行 jstatd 服务器,而是在本地使用 jstat 和 jps 工具。jstat:查看 JVM 统计信息jstat(JVM Statistics Monitoring Tool): 用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT 编译等运行数据。在没有 GUI 图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期间定位虚拟机性能问题的首选工具。常用于检测垃圾回收问题以及内存泄漏问题。基本语法基本语法为:jstat -<option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]查看命令相关参数:jstat -h 或者 jstat -helpoption 参数选项 option 可以由以下值构成 :类装载相关的:-class :显示 ClassLoader 的相关信息:类的装载、卸载数量、总空间、类装载所消耗的时间等jstat -class PIDJIT 相关的:-compiler:显示 JIT 编译器编译过的方法、耗时等信息。jstat -compiler PID-printcompilation:输出已经被 JIT 编译的方法。jstat -printcompilation PID垃圾回收相关的:/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2021/2/7 * @Description: 测试jstat垃圾回收参数相关 * * -Xms60m -Xmx60m -XX:SurvivorRatio=8 */ public class GCTest { public static void main(String[] args) { ArrayList<byte[]> list = new ArrayList<>(); int num = 1000; for (int i = 0; i < num; i++) { //100KB byte[] arr = new byte[1024 * 100]; list.add(arr); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } }-gc:显示与 GC 相关的堆信息。包括 Eden 区、两个 Survivor 区、老年代、永久代等的容量、已用空间、GC 时间合计等信息。jstat -gc PID参数细节新生代相关SOC 是第一个幸存者区的大小(字节)S1C 是第二个幸存者区的大小(字节)SOU 是第一个幸存者区已使用的大小(字节)S1U 是第二个幸存者区已使用的大小(字节)EC 是 Eden 空间的大小(字节)EU 是 Eden 空间已使用大小(字节)老年代相关OC 是老年代的大小(字节)OU 是老年代已使用的大小(字节)方法区(元空间)相关MC 是方法区的大小MU 是方法区已使用的大小CCSC 是压缩类空间的大小CCSU 是压缩类空间已使用的大小其它YGC 是指从应用程序启动到采样时 young gc 次数YGCT 是指从应用程序启动到采样时 young gc 消耗的时间(秒)FGC 是指从应用程序启动到采样时 full gc 次数FGCT 是指从应用程序启动到采样时 full gc 消耗的时间(秒)GCT 是指从应用程序启动到采样时 gc 的总时间-gcutil:显示内容与 -gc 基本相同,但输出主要关注已使用空间占总空间的百分比jstat -gcutil PID-gccapacity:显示内容与-gc基本相同,但输出主要关注 Java 堆各个区域使用到的最大、最小空间jstat -gccapacity PID-gccause:与 -gcutil 功能一样,但是会额外输出导致最后一次或当前正在发生的 GC 产生的原因。jstat -gccause PID-gcnew:显示新生代 GC 状况。jstat -gcnew PIDS0C:第一个 survivor 区大小S1C:第二个 survivor 区的大小S0U:第一个 survivor 区的使用大小S1U:第二个 survivor 区的使用大小TT:对象在新生代存活的次数MTT:对象在新生代存活的最大次数DSS:期望的 survivor 区大小EC:eden 区的大小EU:eden 区的使用大小YGC:年轻代垃圾回收次数YGCT:年轻代垃圾回收消耗时间-gcnewcapacity:显示内容与-gcnew基本相同,输出主要关注使用到的最大、最小空间。jstat -gcnewcapacity PIDNGCMN:新生代最小容量NGCMX:新生代最大容量NGC:当前新生代容量S0CMX:最大 survivor1 区大小S0C:当前 survivor1 区大小S1CMX:最大 survivor2 区大小S1C:当前 survivor2 区大小ECMX:最大 eden 区大小EC:当前 eden 区大小YGC:年轻代垃圾回收次数FGC:老年代回收次数-gcold: 显示老年代 GC 状况。jstat -gcold PIDMC:方法区大小MU:方法区使用大小CCSC:压缩类空间大小CCSU:压缩类空间使用大小OC:老年代大小OU:老年代使用大小YGC:年轻代垃圾回收次数FGC:老年代垃圾回收次数FGCT:老年代垃圾回收消耗时间GCT:垃圾回收消耗总时间-gcoldcapacity:老年代内存统计,主要关注使用到的最大、最小空间。jstat -gcoldcapacity PIDOGCMN:老年代最小容量OGCMX:老年代最大容量OGC:当前老年代大小OC:老年代大小YGC:年轻代垃圾回收次数FGC:老年代垃圾回收次数FGCT:老年代垃圾回收消耗时间GCT:垃圾回收消耗总时间-gcmetacapacity:输出永久代使用到的最大、最小空间。jstat -gcmetacapacity PIDMCMN:最小元数据容量MCMX:最大元数据容量MC:当前元数据空间大小CCSMN:最小压缩类空间大小CCSMX:最大压缩类空间大小CCSC:当前压缩类空间大小YGC:年轻代垃圾回收次数FGC:老年代垃圾回收次数FGCT:老年代垃圾回收消耗时间GCT:垃圾回收消耗总时间-t 参数可以在输出信息前加上一个 Timestamp 列,显示程序的运行时间。单位:秒。jstat -class -t PID-h 参数可以在周期性数据输出时,输出多少行数据后输出一个表头信息。jstat -class -hx PIDinterval 参数用于指定输出统计数据的周期,单位为毫秒。即:查询间隔。jstat -class PID mscount 参数用于指定查询的总次数,n 为总次数jstat -class PID ms njstat 还可以用来判断是否出现内存泄漏 :第 1 步:在长时间运行的 Java 程序中,我们可以运行 jstat 命令连续获取多行性能数据,并取这几行数据中 OU 列(即已占用的老年代内存)的最小值。第 2 步:然后,我们每隔一段较长的时间重复一次上述操作,来获得多组 OU 最小值。如果这些值呈上涨趋势,则说明该 Java 程序的老年代内存已使用量在不断上涨,这意味着无法回收的对象在不断增加,因此很有可能存在内存泄漏。jinfo:实时查看和修改 JVM 配置参数jinfo(Configuralion Info for Java) : 查看虚拟机配置参数信息,也可用于调整虚拟机的配置参数。在很多情况下,Java 应用程序不会指定所有的 Java 虚拟机参数。而此时,开发人员可能不知道某一个具体的 Java 虚拟机参数的默认值。在这种情况下,可能需要通过查找文档获取某个参数的默认值。这个查找过程可能是非常艰难的。但有了 jinfo 工具,开发人员可以很方便地找到 Java 虚拟机参数的当前值。基本语法jinfo [ options ] pid[options]:选项选项说明no option输出全部的参数和系统属性-flag name输出对应名称的参数-flag [+-]name开启或者关闭对应名称的参数只有被标记为 manageable 的参数才可以被动态修改-flag name=value设定对应名称的参数-flags输出全部的参数-sysprops输出系统属性查看-sysprops:可以查看由 System.getProperties()取得的参数。jinfo -sysprops PID-flags:查看曾经赋过值的一些参数。jinfo -flags PID-flag:查看某个 java 进程具体参数。jinfo -flag 具体参数 PID修改jinfo 不仅可以查看运行时某一个 Java 虚拟机参数的实际取值,甚至可以在运行时修改部分参数,并使之立即生效。但是,并非所有参数都支持动态修改。参数只有被标记为 manageable 的 flag 可以被实时修改。其实,这个修改能力是极其有限的。可以查看被标记为 manageable 的参数。java -XX:+PrintFlagsFinal -version | grep manageableintx CMSAbortablePrecleanWaitMillis=100{manageable}intx CMSWaitDuration=2000{manageable}bool HeapDumpAfterFullGC=false{manageable}bool HeapDumpBeforeFullGC=false{manageable}bool HeapDumpOnOutofMemoryError=false{manageable}ccstr HeapDumpPath{manageable}uintx MaxHeapFreeRatio=100{manageable}uintx MinHeapFreeRatio=0{manageable}bool PrintClassHistogram=false{manageable}bool PrintClassHistogragAfterFullGC=false{manageable}bool PrintClassHistogramBeforeFullGC=false{manageable}bool PrintConcurrentLocks=false{manageable}bool PrintGC=false{manageable}bool PrintGCDateStamps=false{manageable}bool PrintGCDetails=false[manageable}bool PrintGCTimeStamps=false{manageable}针对 boolean 类型:jinfo -flag [+-]具体参数 PID针对非 boolean 类型:jinfo -flag 具体参数=具体参数值 PID扩展java -XX+PrintFlagslnitial PID:查看所有 JVM 参数启动的初始值。java -XX:+PrintFlagsFinal PID:查看所有 JVM 参数的最终值。java -XX+PrintCommandLineFlags PID:查看已经被用户或者 JVM 设置过的详细参数名称和值。jmap:导出内存映像文件/内存使用情况jmap(JVM Memory Map) : 作用一方面是获取 dump 文件(堆转储快照文件,二进制文件),它还可以获取目标 Java 进程的内存相关信息,包括 Java堆各区域的使用情况、堆中对象的统计信息、类加载信息等。基本语法jmap [option] <pid> jmap [option] <executable <core> jmap [option] [server_id@]<remote server IP or hostname>其中 option 包括:选项作用-dump生成 dump 文件,-dump:live 只保存堆中存活的对象-heap输出整个堆空间的详细信息,包括 GC 的使用、堆配置信息,以及内存的使用信息等-histo输出堆空间中对象的统计信息,包括类、实例数量和合计容量-permstat以 ClassLoader 为统计口径输出永久代的内存状态信息(仅 linux/solaris 平台有效)-finalizerinfo显示在 F-Queue 中等待 Finalizer 线程执行 finalize 方法的对象(仅 linux/solaris 平台有效)-F当虚拟机进程对-dump 选项没有任何响应时,强制执行生成 dump 文件(仅 linux/solaris 平台有效)说明:这些参数和 linux 下输入显示的命令多少会有不同,包括也受 jdk 版本的影响。生成 Java 堆转储快照文件:dump一般来说,使用 jmap 指令生成 dump 文件的操作算得上是最常用的 jmap 命令之一,将堆中所有存活对象导出至一个文件之中。Heap Dump 又叫做堆存储文件,指一个 Java 进程在某个时间点的内存快照。Heap Dump 在触发内存快照的时候会保存此刻的信息如下:All Object’sClass, fields, primitive values and referencesAll ClassesClassLoader, name, super class, static fieldsGarbage Collection RootsObjects defined to be reachable by the JVMThread Stacks and Local VariablesThe call-stacks of threads at the moment of the snapshot, and per-frame information about local objects说明:通常在写 Heap Dump 文件前会触发一次 Full GC , 所以 Heap Dump 文件里保存的都是 Full GC 后留下的对象信息。由于生成 dump 文件比较耗时,大家需要耐心等待,尤其是大内存镜像生成的 dump 文件则需要耗费更长的时间来完成。手动的方式jmap -dump:format=b,file=<filename.hprof><pid> jmap -dump:live,format=b,file=<filename.hprof><pid> //存活对象自动的方式当程序发生 OOM 退出系统时,一些瞬时信息都随着程序的终止而消失,而重现 OOM 问题往往比较困难或者耗时。此时若能在 OOM 时,自动导出 dump 文件就显得非常迫切。这里介绍一种比较常用的取得堆快照文件的方法,即使用:在程序发生 OOM 时,导出应用程序的当前堆快照 :-XX:+HeapDumpOnOutOfMemoryError可以指定堆快照的保存位置-XX:HeapDumpPath=<filename.hprof>显示堆内存相关信息查看各区大小jmap -heap pid所有类型使用的内存jmap -histo pid由于 jmap 将访问堆中的所有对象,为了保证在此过程中不被应用线程干扰,jmap 需要借助安全点机制,让所有线程停留在不改变堆中数据的状态。也就是说,由 jmap 导出的堆快照必定是安全点位置的。这可能导致基于该堆快照的分析结果存在偏差。举个例子,假设在编译生成的机器码中,某些对象的生命周期在两个安全点之间,那么 :live 选项将无法探知到这些对象。另外,如果某个线程长时间无法跑到安全点, jmap 将一直等下去。与前面讲的 jstat 则不同 , 垃圾回收器会主动将 jstat 所需要的摘要数据保存至固定位置之中, jstat 只需直接读取即可。jhat:JDK 堆分析工具jhat(JVM Heap Analysis Topl) :Sun JDK 提供的jhat命令与 jmap 命令搭配使用,用于分析 jmap 生成的 heap dump 文件(堆转储快照)。jhat 内置了一个微型的 HTTP/HTML 服务器,生成 dump 文件的分析结果后,用户可以在浏览器中查看分析结果(分析虚拟机转储快照信息)。使用了 jhat 命令,就启动了一个 http 服务,端口是 7000 , 即 http://localhost:7000/ , 就可以在浏览器里分析。说明:jhat 命令在 JDK9、JDK10 中已经被删除,官方建议用 VisualVM 代替。基本语法jhat [option] [dumpfile]之后我们访问 localhost:7000option 参数参数含义-stack false、true关闭、打开对象分配调用栈跟踪-refs false、true关闭、打开对象引用跟踪-port port-number设置 jhat http 端口号 默认 7000-exclude exclude-file执行对象查询时需要排除的数据成员列表文件-baseline exclude-file制定一个基准堆转储-debug int设置 debug 级别-version启动后显示版本信息后就退出-J <flag>传入启动参数,比如 -J -Xmx512mjstack:打印 JVM 中线程快照jstack(JVM stlack Trace):用于生成虚拟机指定进程当前时刻的线程快照(虚拟机堆栈跟踪)。线程快照 : 当前虚拟机内指定进程的每一条线程正在执行的方法堆栈的集合。生成线程快照的作用:可用于定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等问题。这些都是导致线程长时间停顿的常见原因。当线程出现停顿时,就可以用 jstack 显示各个线程调用的堆栈情况。在 thread dump 中,要留意下面几种状态:死锁,Deadlock等待资源,Waiting on condition等待获取监视器,Waiting on monitor entry阻塞,Blocked执行中,Runnable暂停,Suspended对象等待中,Object.wait() 或 TIMED_WAITING停止,Parked基本语法jstack [option] pidjstack 管理远程进程的话,需要在远程程序的启动参数中增加:Djava.rmi.server.hostname=……Dcom.sun.management.jmxremoteDcom.sun.management.jmxremote.port=8888Dcom.sun.management.jmxremote.authenticate=falseDcom.sun.management.jmxremote.ssl=false/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2021/2/7 * @Description: 死锁demo */ public class DeadLock { private static Object firstMonitor = new Object(); private static Object secondMonitor = new Object(); public static void main(String[] args) { new Thread(() -> { while (true) { synchronized (firstMonitor) { synchronized (secondMonitor) { System.out.println("Thread1"); } } } }).start(); new Thread(() -> { while (true) { synchronized (secondMonitor) { synchronized (firstMonitor) { System.out.println("Thread2"); } } } }).start(); } }option 参数参数含义-F当正常输出的请求不被响应时,强制输出线程堆栈-I出堆栈外,显示关于锁的附加信息-m如果调用到本地方法,可以显示 C/C++的堆栈-h帮助操作jcmd:多功能命令行在 JDK 1.7 以后,新增了一个命令行工具 jcmd 。它是一个多功能的工具,可以用来实现前面除了 jstat 之外所有命令的功能。比如:用它来导出堆、内存使用、查看Java 进程、导出线程信息、执行GC、JVM 运行时间等。jcmd 拥有 jmap 的大部分功能,并且在 Oracle 的官方网站上也推荐使用 jcmd 命令代 jmap 命令。基本语法jcmd -helpjcmd -l:列出所有的 JVM 进程jcmd pid help:针对指定的进程,列出支持的所有命令jcmd pid 具体命令:显示指定进程的指令命令数据。
RocketMQ生产者核心详解核心参数详解ProducerGroup:组名在一个应用里面是唯一的。CreateTopicKey:实际生产中不会使此参数进行生产者创建Topic。defaultTopicQueueNums:默认大小为4,一个topic下默认挂载的是四个队列。sendMsgTimeout:单位ms,消息发送的超时时间。compressMsgBodyOverHowmuch:默认压缩字节4096,自动压缩机制,当消息超过4096就会压缩。retryTimesWhenSendFailed:同步重发次数。retryAnotherBrokerWhenNotStoreOK:默认false,没有存储成功的话,是否可以向其它Broker存储。maxMessageSize:默认128k,最大消息长度。主从同步机制解析我们之前已经了解了,当一条消息发送到Master节点时候,会将消息同步到Slave节点。但是怎么做的呢?首先主从同步需要同步哪些内容?第一点就是元数据信息同步,第二点就是消息数据的同步。元数据信息:是指topic config配置信息,还有consumer的offset(消费端的进度信息)。需要注意的是,并不是即时同步,而是底层代码启动定时任务去同步的。同步信息:数据内容+元数据信息。数据内容:commitlog实际消息的存储信息,是实时同步的,并且底层使用的是Socket而不是Netty。元数据信息:slave和master基于commitlog里面的数据不断对比,然后不断的同步。元数据丢失是可以接受的,可以恢复。如果元数据在slave和master里面不一致,可以做恢复,可以调整offset位置或者重启consumer。需要注意的是:commitlog里面的数据丢失了,无法恢复。主从同步相关源码如果Broker角色为从服务器,会通过定时任务调用syncAll。我们点击syncAll()方法。从主服务器定时同步topic配置信息、消息消费偏移量、延迟队列偏移量、消费组订阅信息。commitlog数据同步代码HAConnection主要用于消息读写操作。里面包含两个内部类:ReadSocketService、WriteSocketService。Master节点:AcceptSocketService:接收Slave节点连接。HAConnectionReadSocketService:读来自Slave节点的数据。WriteSocketService:写往到Slave节点的数据。Slave节点:HAServiceHAClient:对Master节点连接、读写数据。通信协议:Master节点与Slave节点通信协议很简单,只有如下两条。对象用途第几位字段数据类型字节数说明Slave=>Master上报CommitLog已经同步到的物理位置0maxPhyOffsetLong8CommitLog最大物理位置Master=>Slave传输新的CommitLog数据0fromPhyOffsetLong8CommitLog开始物理位置1sizeInt4传输的数据长度2bodyBytessize传输的数据知道了每个类的大概用途,下面我们看一下代码。在HAService中我们可以看到ConnectMaster(),用于连接Master的方法。使用NIO函数:目的很明显,就是为了更加的高效。private boolean connectMaster() throws ClosedChannelException { if (null == socketChannel) { String addr = this.masterAddress.get(); if (addr != null) { SocketAddress socketAddress = RemotingUtil.string2SocketAddress(addr); if (socketAddress != null) { this.socketChannel = RemotingUtil.connect(socketAddress); if (this.socketChannel != null) { this.socketChannel.register(this.selector, SelectionKey.OP_READ); } } } this.currentReportedOffset = HAService.this.defaultMessageStore.getMaxPhyOffset(); this.lastWriteTimestamp = System.currentTimeMillis(); } return this.socketChannel != null; }我们可以点开RemotingUtil.connect(socketAddress),然后继续跟进去public static SocketChannel connect(SocketAddress remote) { return connect(remote, 1000 * 5); //连接远程地址,超时时间5000ms }public static SocketChannel connect(SocketAddress remote, final int timeoutMillis) { SocketChannel sc = null; try { sc = SocketChannel.open(); //打开channel sc.configureBlocking(true); // 设置同步阻塞 sc.socket().setSoLinger(false, -1); //设置关闭socket的延迟事件,当线程执行到socket的close()方法时候,进入阻塞状态,知道底层数据发送完成,或者超过了延迟时间,才从close()方法返回 sc.socket().setTcpNoDelay(true);//禁止使用Nagle算法,使用小数据即时传输 sc.socket().setReceiveBufferSize(1024 * 64);//设置缓冲区大小 sc.socket().setSendBufferSize(1024 * 64);//设置发送缓冲区大小 sc.socket().connect(remote, timeoutMillis);//连接 sc.configureBlocking(false); //不清楚为什么设置回去了? return sc; } catch (Exception e) { if (sc != null) { try { sc.close(); } catch (IOException e1) { e1.printStackTrace(); } } } return null; }接下来我们看另一个重要的方法:dispatchReadRequest()。读取Master传输的CommitLog数据,并返回是否OK。如果读取到数据,就写入CommitLog。如果发生异常:Master传输的数据开始位置Offset不等于Slave的CommitLog数据最大Offset。上报到Master进度失败。从dispatchReadRequest( )方法里可以看到,Slave使用dispatchPostion变量来指定每次处理的位置,其目的是为了应对粘包问题。每次提取数据的body部分,追加到CommitLog,当添加成功一次就马上向Master上报此次的进度。private boolean dispatchReadRequest() { final int msgHeaderSize = 8 + 4; // phyoffset + size int readSocketPos = this.byteBufferRead.position(); while (true) { // begin -> 读取到请求数据 int diff = this.byteBufferRead.position() - this.dispatchPostion; if (diff >= msgHeaderSize) { // 读取MasterPhyOffset、BodySize,使用dispatchPostion的原因是:处理数据"粘包"导致数据读取不完整 long masterPhyOffset = this.byteBufferRead.getLong(this.dispatchPostion); int bodySize = this.byteBufferRead.getInt(this.dispatchPostion + 8); // 获取slave节点上commitLog文件最大的offset位置 long slavePhyOffset = HAService.this.defaultMessageStore.getMaxPhyOffset(); if (slavePhyOffset != 0) { // 校验 Master传输来的数据offset 是否和 Slave的CommitLog数据最大offset 是否相同 if (slavePhyOffset != masterPhyOffset) { log.error("master pushed offset not equal the max phy offset in slave, SLAVE: " + slavePhyOffset + " MASTER: " + masterPhyOffset); return false; } } // 读取到消息 if (diff >= (msgHeaderSize + bodySize)) { // 写入CommitLog byte[] bodyData = new byte[bodySize]; this.byteBufferRead.position(this.dispatchPostion + msgHeaderSize); this.byteBufferRead.get(bodyData); HAService.this.defaultMessageStore.appendToCommitLog(masterPhyOffset, bodyData); // 设置处理到的位置 this.byteBufferRead.position(readSocketPos); this.dispatchPostion += msgHeaderSize + bodySize; // 上报到Master进度 if (!reportSlaveMaxOffsetPlus()) { return false; } //继续读数据 continue; } } // 空间写满,重新分配空间 if (!this.byteBufferRead.hasRemaining()) { this.reallocateByteBuffer(); } break; } return true; }消息同步发送机制分析消息的同步发送:Producer.send(msg)同步发送消息核心实现:DefaultMQProducerImpl消息的异步发送:Producer.send(msg,SendCallback sendCallback)异步发送消息核心实现:DefaultMQProducerImplproducer.send(message, new SendCallback() { @Override public void onSuccess(SendResult sendResult) { System.out.println("消息发送结果:" + sendResult); } @Override public void onException(Throwable e) { System.out.println("消息发送失败:" + e); } });我们可以看一下源码最终调用sendDefaultImpl()方法,在此方法中主要做了:查找路由信息使用故障容错组件选择消息队列。private SendResult sendDefaultImpl( Message msg, // 发送的消息 final CommunicationMode communicationMode, // 网络通信的模式:同步、异步、单向 final SendCallback sendCallback, // 消息发送后的回调函数,主要用在异步发送 final long timeout // 超时时间 ) throws MQClientException, RemotingException, MQBrokerException, InterruptedException { // 检查消息发送客户端是否是在运行状态 this.makeSureStateOK(); // 检查消息,再一次检查 Validators.checkMessage(msg, this.defaultMQProducer); // 生成一个调用编号,用于下面打印日志,标记为同一次发送消息 final long invokeID = random.nextLong(); // 开始时间戳 long beginTimestampFirst = System.currentTimeMillis(); long beginTimestampPrev = beginTimestampFirst; long endTimestamp = beginTimestampFirst; // 获取topic路由信息 TopicPublishInfo topicPublishInfo = this.tryToFindTopicPublishInfo(msg.getTopic()); // 只在有路由信息的时候,且路由信息正常(有消息队列) if (topicPublishInfo != null && topicPublishInfo.ok()) { boolean callTimeout = false; MessageQueue mq = null; Exception exception = null; SendResult sendResult = null; // 次数,同步=重试次数+1,异步=1, int timesTotal = communicationMode == CommunicationMode.SYNC ? 1 + this.defaultMQProducer.getRetryTimesWhenSendFailed() : 1; // 当前为第几次发送 int times = 0; // 存储每次发送消息选择的Broker名称 String[] brokersSent = new String[timesTotal]; // 循环timesTotal次数进行发送,直到发送成功为止 for (; times < timesTotal; times++) { // 选择的broker String lastBrokerName = null == mq ? null : mq.getBrokerName(); //根据路由信息和Broker选择消息队列 MessageQueue mqSelected = this.selectOneMessageQueue(topicPublishInfo, lastBrokerName); if (mqSelected != null) { mq = mqSelected; // 设置当前发送的broker brokersSent[times] = mq.getBrokerName(); try { // 开始时间 beginTimestampPrev = System.currentTimeMillis(); // 如果重试次数大于0,表明已经重试了 if (times > 0) { msg.setTopic(this.defaultMQProducer.withNamespace(msg.getTopic())); } // 已经用了的时间 long costTime = beginTimestampPrev - beginTimestampFirst; // 超时了,就不继续了,直接退出循环,也就是重试必须在设置的超时时间以内才重新发送 if (timeout < costTime) { callTimeout = true; break; } // 找到后消息队列和路由信息后处理 sendResult = this.sendKernelImpl(msg, mq, communicationMode, sendCallback, topicPublishInfo, timeout - costTime); // 结束时间戳 endTimestamp = System.currentTimeMillis(); // 没有出现异常时的更新故障容错 this.updateFaultItem(mq.getBrokerName(), endTimestamp - beginTimestampPrev, false); // 根据不同的发送方式返回不同的结果 // 异步和单向直接返回null switch (communicationMode) { case ASYNC: return null; case ONEWAY: return null; case SYNC: // 如果返回的结果不是OK的话且能重试那么就重试,如果得到的结果不是 SEND_OK // 没有返回结果时,比如超时了,那么此时就直接进行重试 if (sendResult.getSendStatus() != SendStatus.SEND_OK) { // 同步发送成功但存储有问题时候并且配置存储异常时重新发送开关时,进行重试 if (this.defaultMQProducer.isRetryAnotherBrokerWhenNotStoreOK()) { continue; } } // 如果不重试的话直接返回结果了 return sendResult; // 如果通信模式是其他,那么直接返回 default: break; } } catch (RemotingException e) { endTimestamp = System.currentTimeMillis(); // 更新故障容错 this.updateFaultItem(mq.getBrokerName(), endTimestamp - beginTimestampPrev, true); log.warn(String.format("sendKernelImpl exception, resend at once, InvokeID: %s, RT: %sms, Broker: %s", invokeID, endTimestamp - beginTimestampPrev, mq), e); log.warn(msg.toString()); exception = e; continue; } catch (MQClientException e) { endTimestamp = System.currentTimeMillis(); this.updateFaultItem(mq.getBrokerName(), endTimestamp - beginTimestampPrev, true); log.warn(String.format("sendKernelImpl exception, resend at once, InvokeID: %s, RT: %sms, Broker: %s", invokeID, endTimestamp - beginTimestampPrev, mq), e); log.warn(msg.toString()); exception = e; continue; } catch (MQBrokerException e) { endTimestamp = System.currentTimeMillis(); this.updateFaultItem(mq.getBrokerName(), endTimestamp - beginTimestampPrev, true); log.warn(String.format("sendKernelImpl exception, resend at once, InvokeID: %s, RT: %sms, Broker: %s", invokeID, endTimestamp - beginTimestampPrev, mq), e); log.warn(msg.toString()); exception = e; switch (e.getResponseCode()) { case ResponseCode.TOPIC_NOT_EXIST: case ResponseCode.SERVICE_NOT_AVAILABLE: case ResponseCode.SYSTEM_ERROR: case ResponseCode.NO_PERMISSION: case ResponseCode.NO_BUYER_ID: case ResponseCode.NOT_IN_CURRENT_UNIT: continue; default: if (sendResult != null) { return sendResult; } throw e; } } catch (InterruptedException e) { endTimestamp = System.currentTimeMillis(); this.updateFaultItem(mq.getBrokerName(), endTimestamp - beginTimestampPrev, false); log.warn(String.format("sendKernelImpl exception, throw exception, InvokeID: %s, RT: %sms, Broker: %s", invokeID, endTimestamp - beginTimestampPrev, mq), e); log.warn(msg.toString()); log.warn("sendKernelImpl exception", e); log.warn(msg.toString()); throw e; } } else { // 如果选择到的消息队列为空,那么直接退出循环 break; } } // if (sendResult != null) { return sendResult; } // 重试仍然失败 String info = String.format("Send [%d] times, still failed, cost [%d]ms, Topic: %s, BrokersSent: %s", times, System.currentTimeMillis() - beginTimestampFirst, msg.getTopic(), Arrays.toString(brokersSent)); info += FAQUrl.suggestTodo(FAQUrl.SEND_MSG_FAILED); MQClientException mqClientException = new MQClientException(info, exception); // 如果超时了就抛出异常 if (callTimeout) { throw new RemotingTooMuchRequestException("sendDefaultImpl call timeout"); } // 出现其他异常的情况 if (exception instanceof MQBrokerException) { mqClientException.setResponseCode(((MQBrokerException) exception).getResponseCode()); } else if (exception instanceof RemotingConnectException) { mqClientException.setResponseCode(ClientErrorCode.CONNECT_BROKER_EXCEPTION); } else if (exception instanceof RemotingTimeoutException) { mqClientException.setResponseCode(ClientErrorCode.ACCESS_BROKER_TIMEOUT); } else if (exception instanceof MQClientException) { mqClientException.setResponseCode(ClientErrorCode.BROKER_NOT_EXIST_EXCEPTION); } throw mqClientException; } validateNameServerSetting(); throw new MQClientException("No route info of this topic: " + msg.getTopic() + FAQUrl.suggestTodo(FAQUrl.NO_TOPIC_ROUTE_INFO), null).setResponseCode(ClientErrorCode.NOT_FOUND_TOPIC_EXCEPTION); }消息的返回状态public enum SendStatus { SEND_OK, FLUSH_DISK_TIMEOUT, FLUSH_SLAVE_TIMEOUT, SLAVE_NOT_AVAILABLE, }FLUSH_DISK_TIMEOUT如果设置了 FlushDiskType=SYNC_FLUSH (默认是 ASYNC_FLUSH),并且 Broker 没有在 syncFlushTimeout (默认是 5 秒)设置的时间内完成刷盘,就会收到此状态码。FLUSH_SLAVE_TIMEOUT如果设置为 SYNC_MASTER,并且 slave Broker 没有在 syncFlushTimeout 设定时间内完成同步,就会收到此状态码。SLAVE_NOT_AVAILABLE如果设置为 SYNC_MASTER,并没有配置 slave Broker,就会收到此状态码。SEND_OK这个状态可以简单理解为,没有发生上面列出的三个问题状态就是SEND_OK。需要注意的是,SEND_OK 并不意味着可靠,如果想严格确保没有消息丢失,需要开启 SYNC_MASTER or SYNC_FLUSH。如果收到了 FLUSH_DISK_TIMEOUT, FLUSH_SLAVE_TIMEOUT,意味着消息会丢失,有2个选择,一是无所谓,适用于消息不关紧要的场景,二是重发,但可能产生消息重复,这就需要consumer进行去重控制。消息的延迟投递延迟消息:消息到达Broker后,要在特定的时间后才会被Consumer消费。目前只支持固定精度的定时消息。MessageSoreConfig类中有messageDelayLevel属性。private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h"; message.setDelayTimeLevel(1); // 也就是延迟1秒之后投递消息的自定义投递规则实现消息的自定义投递,我们需要在发送的时候去指定某一个队列。重写MessageQueueSelector的select方法。 SendResult sr = producer.send(message, new MessageQueueSelector() { @Override public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) { Integer queueNumber = (Integer)arg; return mqs.get(queueNumber); } }, 2); //发送同一topic第二个队列里面 System.err.println(sr);RocketMQ消费者核心详解PushConsumer核心参数详解consumeFromWhere:消费者从那个位置开始消费。CONSUME_FROM_LAST_OFFSET: 第一次启动从队列最后位置消费,后续再启动接着上次消费的进度开始消费。CONSUME_FROM_FIRST_OFFSET:第一次启动从队列初始位置消费,后续再启动接着上次消费的进度开始消费。CONSUME_FROM_TIMESTAMP:第一次启动从指定时间点位置消费,后续再启动接着上次消费的进度开始消费。allocateMessageQueueStrategy:默认AllocateMessageQueueAveragely,Rebalance(轮询)算法实现策略。subscription:订阅。offsetStore:消息进度存储,存储实际的偏移量,两种实现:分为本地和远程的存储。consumeThreadMin/consumeThreadMax:线程池的数量。consumeConcurrentlyMaxSpan/pullThresholdForQueue:单队列并行消费允许的最大跨度,默认2000;拉消息本地队列缓存消息最大数,默认1000。pullInterval:默认0,拉消息间隔,由于是长轮询,所以为0,但是如果应用为了流控,也可以设置大于0的值,单位毫秒。pullBatchSize: 默认32, 批量拉消息,一次最多拉多少条。consumeMessageBatchMaxSize: 默认1,批量消费,一次消费多少条消息。PushConsumer消费模式-集群模式RocketMQ有两种消费模式:Broadcasting广播模式,Clustering集群模式,默认的是集群消费模式。Clustering集群模式(默认):通过consumer.setMessageModel(MessageModel.CLUSTERING)进行设置。GroupName用于把多个Consumer组织到一起。相同GroupName的Consumer只消费所订阅消息的一部分,即ConsumerGroup中的Consumer实例平均分摊消费topic的消息。目的:达到天然的负载均衡机制。消息的消费进度,即consumerOffset.json保存在broker上。消息消费失败后,consumer会发回broker,broker根据消费失败次数设置不同的delayLevel进行重发。相同topic不同的consumerGroup组成伪广播模式,可达到所有consumer都会收到消息。PushConsumer消费模式-广播模式通过consumer.setMessageModel(MessageModel.BROADCASTING)进行设置。消息的消费进度保存在consumer的机器上。同一个ConsumerGroup里的Consumer都消费订阅Topic的全部信息。不同ConsumerGroup里的Consumer可以实现根据tags进行消费即:consumer1.subscribe("test_model_topic","TagA"); consumer2.subscribe("test_model_topic","TagB");消息消费失败后直接丢弃,不会发回broker进行重新投递。由于所有consumer都需要收到消息,所以不存在负载均衡策略。消息存储核心-Offset存储Offset是消息消费进度的核心,指某个topic下的一条消息在某个MessageQueue里的位置,通过Offset可以进行消息的定位Offset的存储实现分为远程文件类型和本地文件类型两种:集群模式下offset存在broker中; 广播模式下offset存在consumer中。RocketMQ默认是集群消费模式Clustering,采用远程文件存储Offset,即存储在broker中本质是因为多消费模式,每个Consumer只消费所订阅主题的一部分,这种情况下就需要由Broker去控制Offset的值,使用RemoteBrokerOffsetStore来实现。在广播模式下,由于每个Consumer都会收到消息且消费,那么各个Consumer之间没有任何干扰,都是独立线程消费,所以使用LocalFileOffsetStore,即把Offset存储到本地。PushConsumer消费者长轮询模式DefaultPushConsumer是使用长轮询模式进行实现的。常见的数据同步方式有下面几种:push:producer发送消息后,broker马上把消息投递给consumer。这种方式好在实时性比较高,但是会增加broker的负载;而且消费端能力不同,如果push推送过快,消费端会出现很多问题。pull:producer发送消息后,broker什么也不做,等着consumer自己来读取。它的优点在于主动权在消费者端,可控性好;但是间隔时间不好设置,间隔太短浪费资源,间隔太长又会消费不及时。长轮询机制:当consumer过来请求时,broker会保持当前连接一段时间 默认15s,如果这段时间内有消息到达,则立刻返回给consumer;15s没消息的话则返回空然后重新请求。这种方式的缺点就是服务端要保存consumer状态,客户端过多会一直占用资源。consumer是长轮询拉消息,当consumer拉消息时,broker端如果没有新消息,broker会通过PullRequestHoldService服务hold住这个请求。public void run() { log.info("{} service started", this.getServiceName()); while (!this.isStopped()) { try { if (this.brokerController.getBrokerConfig().isLongPollingEnable()) { this.waitForRunning(5 * 1000); } else { this.waitForRunning(this.brokerController.getBrokerConfig().getShortPollingTimeMills()); } long beginLockTimestamp = this.systemClock.now(); // 检查是否有新的消息 this.checkHoldRequest(); long costTime = this.systemClock.now() - beginLockTimestamp; if (costTime > 5 * 1000) { log.info("[NOTIFYME] check hold request cost {} ms.", costTime); } } catch (Throwable e) { log.warn(this.getServiceName() + " service has exception. ", e); } } log.info("{} service end", this.getServiceName()); }RocketMQ消费者-PullConsumer使用pull方式主要做了三件事:获取MessageQueue并遍历维护OffsetStore根据不同的消息状态做不同的处理DefaultMQPullConsumer,Pull模式简单样例/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2021/1/27 * @Description: */ public class Consumer { // Map<key,value> key为指定队列,value为这个队列拉取数据的最后位置 private static final Map<MessageQueue, Long> offsetTable = new HashMap<>(); public static final String NAME_SRV_ADDR = "192.168.3.160:9876;192.168.3.161"; public static void main(String[] args) { try { String group_name = "test_pull_producer_name"; DefaultMQPullConsumer consumer = new DefaultMQPullConsumer(group_name); consumer.setNamesrvAddr(NAME_SRV_ADDR); consumer.start(); //从topicTest这个主题去获取所有队列(默认会有4个队列) Set<MessageQueue> messageQueues = consumer.fetchSubscribeMessageQueues("test_pull_topic"); //遍历每一个队列进行数据拉取 for (MessageQueue messageQueue : messageQueues) { System.out.println("consumer from the queue:" + messageQueue); SINGLE_MQ: while (true) { try { //从queue中获取数据,从什么位置开始拉取数据,单次最多拉取32条数据 PullResult pullResult = consumer.pullBlockIfNotFound(messageQueue, null, getMessageQueueOffset(messageQueue), 32); System.out.println(pullResult); System.out.println(pullResult.getPullStatus()); putMessageQueueOffset(messageQueue, pullResult.getNextBeginOffset()); switch (pullResult.getPullStatus()) { case FOUND: break; case NO_MATCHED_MSG: break; case NO_NEW_MSG: System.out.println("没有新的数据"); break SINGLE_MQ; case OFFSET_ILLEGAL: break; default: break; } } catch (Exception e) { e.printStackTrace(); } } } } catch (MQClientException e) { e.printStackTrace(); } } private static long getMessageQueueOffset(MessageQueue mq) { Long offset = offsetTable.get(mq); if (offset != null) { return offset; } return 0; } private static void putMessageQueueOffset(MessageQueue mq, long offset) { offsetTable.put(mq, offset); } }RocketMQ Pull模式下提供的负载均衡样例(基于MQPullConsumerScheduleService)/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2021/1/26 * @Description: */ public class PullConsumerScheduleService { public static final String NAME_SRV_ADDR = "192.168.3.160:9876;192.168.3.161"; public static void main(String[] args) throws MQClientException { String group_name = "test_pull_consumer_name"; final MQPullConsumerScheduleService scheduleService = new MQPullConsumerScheduleService(group_name); scheduleService.getDefaultMQPullConsumer().setNamesrvAddr(NAME_SRV_ADDR); scheduleService.setMessageModel(MessageModel.CLUSTERING); scheduleService.registerPullTaskCallback("test_pull_topic", (mq, context) -> { MQPullConsumer consumer = context.getPullConsumer(); System.err.println("-------------- queueId: " + mq.getQueueId() + "-------------"); try { // 获取从哪里拉取 long offset = consumer.fetchConsumeOffset(mq, false); if (offset < 0) { offset = 0; } PullResult pullResult = consumer.pull(mq, "*", offset, 32); switch (pullResult.getPullStatus()) { case FOUND: List<MessageExt> list = pullResult.getMsgFoundList(); for (MessageExt msg : list) { //消费数据 System.out.println(new String(msg.getBody())); } break; case NO_MATCHED_MSG: break; case NO_NEW_MSG: case OFFSET_ILLEGAL: break; default: break; } consumer.updateConsumeOffset(mq, pullResult.getNextBeginOffset()); // 设置再过3000ms后重新拉取 context.setPullNextDelayTimeMillis(3000); } catch (Exception e) { e.printStackTrace(); } }); scheduleService.start(); } }核心原理解析RocketMQ消息的存储结构如下图所示:消息主体以及元数据都存储在CommitLog文件当中,完全顺序写,随机读。Consume Queue相当于kafka中的partition,是一个逻辑队列,存储了这个Queue在CommiLog中的起始offset,log大小和MessageTag的hashCode。每次读取消息队列先读取consumerQueue,然后再通过consumerQueue去commitLog中拿到消息主体。同步刷盘和异步刷盘RocketMQ消息存储:内存+磁盘存储,两种刷盘方式。RocketMQ和Redis等其他存储系统类似,提供了同步和异步两种刷盘方式,同步刷盘方式能够保证数据被写入硬盘,做到真正的持久化,但是也会让系统的写入速度受制于磁盘的IO速度;而异步刷盘方式在将数据写入缓冲之后就返回,提供了系统的IO速度,却存在系统发生故障时未来得及写入硬盘的数据丢失的风险。RocketMQ的消息是存储到磁盘上的,这样既能保证断电后恢复,又可以让存储的消息量超出内存的限制。RocketMQ为了提高性能,会尽可能地保证磁盘的顺序写。消息在通过Producer写入RocketMQ的时候,有两种:异步刷盘方式:在返回写成功状态时,消息可能只是被写入了内存的PAGECACHE,写操作的返回快,吞吐量大;当内存里的消息量积累到一定程度时,统一触发写磁盘操作,快速写入。同步刷盘方式:在返回写成功状态时,消息已经被写入磁盘。具体流程是,消息写入内存的PAGECACHE后,立刻通知刷盘线程刷盘,然后等待刷盘完成,刷盘线程执行完成后唤醒等待的线程,返回消息写成功的状态。同步刷盘还是异步刷盘,是通过Broker配置文件里的flushDiskType参数设置的,这个参数被设置成SYNC_FLUSH、ASYNC_FLUSH中的一个。同步复制和异步复制同一组broker中有Master和Slave,消息需要从Master复制到Slave上,那么有同步和异步两种复制方式。同步复制:是等Master和Slave均写成功后才反馈给客户端写成功状态。异步复制:是只要Master写成功即可反馈给客户端写成功状态。两种复制方式对比:异步复制方式下,系统拥有较低的延迟和较高的吞吐量,但是如果Master出了故障,有些数据因为没有被写入Slave,有可能会丢失。同步复制方式下,如果Master出故障,Slave上有全部的备份数据,容易恢复,但是同步复制会增大数据写入延迟,降低系统吞吐量。配置方式:同步复制和异步复制是通过Broker配置文件里的brokerRole参数进行设置的,这个参数可以被设置成ASYNC_MASTER、SYNC_MASTER、SLAVE三个值中的一个。实际应用中要结合业务场景,合理设置刷盘方式和主从复制方式,尤其是SYNC_FLUSH方式,由于频繁的触发写磁盘动作,会明显降低性能。通常情况下,应该把Master和Slave设置成ASYNC_FLUSH的刷盘方式,主从之间配置成SYNC_MASTER的复制方式,这样即使有一台机器出故障,仍然可以保证数据不丢。高可用机制当Master节点繁忙,可自动切换到Slave节点读取信息。当Master节点down机或不可用时,rocketmq基于raft 协议支持主从切换,引入了多副本机制,即DLedger,支持主从切换,即当一个复制组内的主节点宕机后,会在该复制组内触发重新选主,选主完成后即可继续提供消息写功能。NameServer协调服务Namesrv的功能,就相当于RPC或微服务中的注册中心。对于MQ而言,broker启动,将自身创建的topic等信息注册到Namesrv上。consumer和producer需要配置namesrv的地址,启动后,首先和namesrv建立长连接,并获取相应的topic信息(比如,哪些broker有topic路由信息),然后再和broker建立长连接。Namesrv本身无状态,可集群横向扩展部署。所有的注册信息,都保存在namesrv的类似map内存数据结构中。public class RouteInfoManager { private final HashMap<String/* topic */, List<QueueData>> topicQueueTable; private final HashMap<String/* brokerName */, BrokerData> brokerAddrTable; private final HashMap<String/* clusterName */, Set<String/* brokerName */>> clusterAddrTable; private final HashMap<String/* brokerAddr */, BrokerLiveInfo> brokerLiveTable; private final HashMap<String/* brokerAddr */, List<String>/* Filter Server */> filterServerTable; }
RocketMQ - 整体介绍简介RocketMQ是一款分布式、队列模型的消息中间件。支持集群模型、负载均衡、水平扩展能力。采用零拷贝的原理、顺序写盘、随机读。代码优秀,底层通信框架使用 Netty 。强调集群无单点,可扩展,任意一点高可用,水平可扩展。消息失败重试机制、消息可查询。RcoketMQ 是一款低延迟、高可靠、可伸缩、易于使用的消息中间件,具有以下特性:支持发布/订阅(Pub/Sub)和点对点(P2P)消息模型。在一个队列中可靠的先进先出(FIFO)和严格的顺序传递。支持拉(pull)和推(push)两种消息模式。单一队列百万消息的堆积能力。支持多种消息协议,如 JMS、MQTT 等。分布式高可用的部署架构,满足至少一次消息传递语义。提供 docker 镜像用于隔离测试和云集群部署。提供配置、指标和监控等功能丰富的 Dashboard。概念模型Producer:消息生产者,负责生产消息,一般由业务系统负责产生消息。Consumer:消息消费者,负责消费消息,一般是后台系统负责异步消费。Push Consumer:Consumer的一种,需要向Consumer对象注册监听。Pull Consumer:Consumer的一种,需要主动请求Broker拉取消息。Producer Group:生产者集合,一般用于发送一类消息。Consumer Group:消费者集合,一般用于接收一类消息进行消费。Broker:MQ消息服务(中转角色,用于消息存储于生产消息转发)。环境搭建环境:JDK8、Centos7、RocketMQ 4.3首先我们编辑Hostsvim /etc/hosts加入下面两句话,修改为你自己的ip。192.168.3.160 rocketmq-nameserver1 192.168.3.160 rocketmq-master1随后我们将RocketMQ tar.gz传入服务器。传入之后我们创建文件夹。# 创建文件夹 mkdir /usr/local/apache-rocketmq # 然后解压 tar -zxvf apache-rocketmq.tar.gz -C /usr/local/apache-rocketmq # 建立软连接 ln -s apache-rocketmq rocketmq创建存储路径。修改配置文件。vim /usr/local/rocketmq/conf/2m-2s-async/broker-a.properties brokerClusterName=rocketmq-cluster #broker 名字,注意此处不同的配置文件填写的不一样 brokerName=broker-a #0 表示 Master,>0 表示 Slave brokerId=0 #nameServer 地址,分号分割 一定要和我们配置的hosts里的相同 namesrvAddr=rocketmq-nameserver1:9876 #在发送消息时,自动创建服务器不存在的 topic,默认创建的队列数 defaultTopicQueueNums=4 #是否允许 Broker 自动创建 Topic,建议线下开启,线上关闭 autoCreateTopicEnable=true #是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭 autoCreateSubscriptionGroup=true #Broker 对外服务的监听端口 listenPort=10911 #删除文件时间点,默认凌晨 4 点 deleteWhen=04 #文件保留时间,默认 48 小时 fileReservedTime=120 #commitLog 每个文件的大小默认 1G mapedFileSizeCommitLog=1073741824 #ConsumeQueue 每个文件默认存 30W 条,根据业务情况调整 mapedFileSizeConsumeQueue=300000 #destroyMapedFileIntervalForcibly=120000 #redeleteHangedFileInterval=120000 #检测物理文件磁盘空间 diskMaxUsedSpaceRatio=88 #存储路径 storePathRootDir=/usr/local/rocketmq/store #commitLog 存储路径 storePathCommitLog=/usr/local/rocketmq/store/commitlog #消费队列存储路径存储路径 storePathConsumeQueue=/usr/local/rocketmq/store/consumequeue #消息索引存储路径 storePathIndex=/usr/local/rocketmq/store/index #checkpoint 文件存储路径 storeCheckpoint=/usr/local/rocketmq/store/checkpoint #abort 文件存储路径 abortFile=/usr/local/rocketmq/store/abort #限制的消息大小 maxMessageSize=65536 #flushCommitLogLeastPages=4 #flushConsumeQueueLeastPages=2 #flushCommitLogThoroughInterval=10000 #flushConsumeQueueThoroughInterval=60000 #Broker 的角色 #- ASYNC_MASTER 异步复制 Master #- SYNC_MASTER 同步双写 Master #- SLAVE brokerRole=ASYNC_MASTER #刷盘方式 #- ASYNC_FLUSH 异步刷盘 #- SYNC_FLUSH 同步刷盘 flushDiskType=ASYNC_FLUSH #checkTransactionMessageEnable=false #发消息线程池数量 #sendMessageThreadPoolNums=128 #拉消息线程池数量 #pullMessageThreadPoolNums=128修改日志配置文件。mkdir -p /usr/local/rocketmq/logs cd /usr/local/rocketmq/conf && sed -i 's#${user.home}#/usr/local/rocketmq#g' *.xml修改启动脚本参数。vim /usr/local/rocketmq/bin/runbroker.sh#============================================================================== # 开发环境 JVM Configuration #============================================================================== JAVA_OPT="${JAVA_OPT} -server -Xms1g -Xmx1g -Xmn1gvim /usr/local/rocketmq/bin/runserver.shJAVA_OPT="${JAVA_OPT} -server -Xms1g -Xmx1g -Xmn1g -XX:PermSize=128m - XX:MaxPermSize=320m"启动 NameServercd /usr/local/rocketmq/bin nohup sh mqnamesrv & # 使用jps查看 [root@localhost bin]# jps 22321 NamesrvStartup 22335 Jps启动BrokerServernohup sh mqbroker -c /usr/local/rocketmq/conf/2m-2s-async/broker-a.properties >/dev/null 2>&1 & # jps查看 [root@localhost bin]# jps 22321 NamesrvStartup 22535 Jps 22440 BrokerStartup控制台使用下载代码:https://github.com/apache/roc...打开rocketmq-console。修改propertiesrocketmq.config.namesrvAddr=192.168.3.160:9876启动代码,访问localhost:8080RocketMQ - 急速入门生产者使用首先我们创建一个SpringBoot项目,引入RocketMQ依赖。<dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-client</artifactId> <version>4.3.0</version> </dependency>创建一个Producer类import org.apache.rocketmq.client.producer.DefaultMQProducer; import org.apache.rocketmq.client.producer.SendResult; import org.apache.rocketmq.common.message.Message; /** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2021/1/26 * @Description: */ public class Producer { public static final String NAME_SRV_ADDR = "192.168.3.160:9876"; public static void main(String[] args) throws Exception { DefaultMQProducer producer = new DefaultMQProducer("test_quick_producer_name"); producer.setNamesrvAddr(NAME_SRV_ADDR); producer.start(); for (int i = 0; i < 5; i++) { //1.创建消息 Message message = new Message("test_quick_topic",//主题 "TagA_" + i, // 标签 "KeyA_" + i, //用户自定义key,唯一标识 "Hello RocketMQ".getBytes());//消息内容实体 //2.发送消息 SendResult result = producer.send(message); System.out.println("消息发送结果:" + result); } producer.shutdown(); } }点击运行后,我们可以在控制台看到5条结果已经发送成功。我们打开web管理界面可以看到今天有五条消息进来。并且在Message里我们可以看到五条消息。消费者使用创建一个Consumer类import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer; import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus; import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently; import org.apache.rocketmq.common.consumer.ConsumeFromWhere; import org.apache.rocketmq.common.message.MessageExt; import org.apache.rocketmq.remoting.common.RemotingHelper; /** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2021/1/26 * @Description: */ public class Consumer { public static final String NAME_SRV_ADDR = "192.168.3.160:9876"; public static void main(String[] args) throws Exception { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("test_quick_consumer_name"); consumer.setNamesrvAddr(NAME_SRV_ADDR); consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET); //从最后端开始消费 consumer.subscribe("test_quick_topic",//订阅的主题 "*");// *代表包含所有 consumer.registerMessageListener((MessageListenerConcurrently) (msgs, consumeConcurrentlyContext) -> { MessageExt messageExt = msgs.get(0); try { String topic = messageExt.getTopic(); String tags = messageExt.getTags(); String keys = messageExt.getKeys(); String msgBody = new String(messageExt.getBody(), RemotingHelper.DEFAULT_CHARSET); System.out.println("topic:" + topic + ", tags : " + tags + ", keys :" + keys + ", msgBody:" + msgBody); } catch (Exception e) { e.printStackTrace(); return ConsumeConcurrentlyStatus.RECONSUME_LATER; } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; }); consumer.start(); } }点击运行后,我们可以在控制台看到已经收到5条结果。消息失败重试下面我们测试一下消息发送失败的情况。/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2021/1/26 * @Description: */ public class Consumer { public static final String NAME_SRV_ADDR = "192.168.3.160:9876"; public static void main(String[] args) throws Exception { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("test_quick_consumer_name"); consumer.setNamesrvAddr(NAME_SRV_ADDR); consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET); //从最后端开始消费 consumer.subscribe("test_quick_topic",//订阅的主题 "*");// *代表包含所有 consumer.registerMessageListener((MessageListenerConcurrently) (msgs, consumeConcurrentlyContext) -> { MessageExt messageExt = msgs.get(0); try { String topic = messageExt.getTopic(); String tags = messageExt.getTags(); String keys = messageExt.getKeys(); if (keys.equals("KeyA_1")) { int i = 1 / 0; // 抛出异常 } String msgBody = new String(messageExt.getBody(), RemotingHelper.DEFAULT_CHARSET); System.out.println("topic:" + topic + ", tags : " + tags + ", keys :" + keys + ", msgBody:" + msgBody); } catch (Exception e) { e.printStackTrace(); int reconsumeTimes = messageExt.getReconsumeTimes(); // 失败次数 System.out.println("失败消息已被重发次数:"+ reconsumeTimes); if(reconsumeTimes == 3){ // 记录日志... // 补偿机制... return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } return ConsumeConcurrentlyStatus.RECONSUME_LATER; } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; }); consumer.start(); } }我们重新启动Producer和Consumer。可以看到RocketMQ会在一段时间间隔后重新发送此消息,直到达到三次我们进行SUCCESS做日志或者补偿机制。四种集群环境构建详解Name ServerName Server是一个几乎无状态节点,可集群部署,节点之间无任何信息同步。BrokerBroker部署相对复杂,Broker分为Master与Slave,一个Master可以对应多个Slave,但是一个Slave只能对应一个Master,Master与Slave的对应关系通过指定相同的Broker Name,不同的Broker Id来定义,BrokerId为0表示Master,非0表示Slave。Master也可以部署多个。每个Broker与Name Server集群中的所有节点建立长连接,定时(每隔30s)注册Topic信息到所有Name Server。Name Server定时(每隔10s)扫描所有存活broker的连接,如果Name Server超过2分钟没有收到心跳,则Name Server断开与Broker的连接。ProducerProducer与Name Server集群中的其中一个节点(随机选择)建立长连接,定期从Name Server取Topic路由信息,并向提供Topic服务的Master建立长连接,且定时向Master发送心跳。Producer完全无状态,可集群部署。Producer每隔30s(由ClientConfig的pollNameServerInterval)从Name server获取所有topic队列的最新情况,这意味着如果Broker不可用,Producer最多30s能够感知,在此期间内发往Broker的所有消息都会失败。Producer每隔30s(由ClientConfig中heartbeatBrokerInterval决定)向所有关联的broker发送心跳,Broker每隔10s中扫描所有存活的连接,如果Broker在2分钟内没有收到心跳数据,则关闭与Producer的连接。ConsumerConsumer与Name Server集群中的其中一个节点(随机选择)建立长连接,定期从Name Server取Topic路由信息,并向提供Topic服务的Master、Slave建立长连接,且定时向Master、Slave发送心跳。Consumer既可以从Master订阅消息,也可以从Slave订阅消息,订阅规则由Broker配置决定。Consumer每隔30s从Name server获取topic的最新队列情况,这意味着Broker不可用时,Consumer最多最需要30s才能感知。Consumer每隔30s(由ClientConfig中heartbeatBrokerInterval决定)向所有关联的broker发送心跳,Broker每隔10s扫描所有存活的连接,若某个连接2分钟内没有发送心跳数据,则关闭连接;并向该Consumer Group的所有Consumer发出通知,Group内的Consumer重新分配队列,然后继续消费。当Consumer得到master宕机通知后,转向slave消费,slave不能保证master的消息100%都同步过来了,因此会有少量的消息丢失。但是一旦master恢复,未同步过去的消息会被最终消费掉。集群模式之-单点模式这种模式很明显,一旦节点挂掉,整体服务就不可用了。集群模式之-主从模式多Master多Slave模式,同步双写(NM-NS,SYNC)每个Master配备一个Slave,共有多对Master-Slave,HA采用同步双写机制,主从都写入消息成功后,再向应用返回ACK。优点:数据与服务都无单点故障问题,Master宕机情况下,消息无延迟,服务可用性和数据可用性都非常高。缺点:性能比异步复制略低,大概低10%,发送单个消息的RT会略高。目前宕机情况下,从节点不能自动切换成主节点,后续会支持自动切换功能。多Master多Slave模式,异步复制(NM-NS,ASYNC)每个Master配备一个Slave,共有多对Master-Slave,HA采用异步复制方式,主从有短暂消息延迟,毫秒级别。优点:即使磁盘损坏,消息的丢失也非常少,而且消息的实时性不会受到影响,因为Master宕机后,消费者仍然可以从Slave中消费消息,此过程对应用完全透明,不需要人工干预,性能同多Master模式几乎一样。缺点:Master宕机后,如果磁盘出现损坏,可能丢失少量消息。集群模式之-双主模式双Master模式/多Master模式(2M)一个集群无Slave,全是Master,例如2个Master或者3个Master。优点:配置简单,单个Master宕机或者重启对应用无影响,在磁盘配置为RAID10时,即使机器宕机不可恢复情况下,由于RAID10磁盘可靠性非常高,消息也不会丢失(异步刷盘丢失少量消息,同步刷盘完全不丢失),性能最高。缺点:单台机器宕机时,这台机器上未被消费的消息在机器恢复之前不可订阅,消息的实时性会受到影响。另外还有双主双从模式、多主多从模式。主从集群模式搭建主节点:192.168.3.160从节点:192.168.3.161首先打开主节点vim /etc/hosts增加161节点数据192.168.3.161 rocketmq-nameserver2 192.168.3.161 rocketmq-master1-slave然后将4条数据复制到161节点的hosts文件中。接着我们把tar.gz复制到161。scp apache-rocketmq.tar.gz 192.168.3.161:/usr/local/还是之前的操作# 创建文件夹 mkdir /usr/local/apache-rocketmq # 然后解压 tar -zxvf apache-rocketmq.tar.gz -C /usr/local/apache-rocketmq # 建立软连接 ln -s apache-rocketmq rocketmq创建存储路径。mkdir /usr/local/rocketmq/store mkdir /usr/local/rocketmq/store/commitlog mkdir /usr/local/rocketmq/store/consumequeue mkdir /usr/local/rocketmq/store/index修改日志配置文件。mkdir -p /usr/local/rocketmq/logs cd /usr/local/rocketmq/conf && sed -i 's#${user.home}#/usr/local/rocketmq#g' *.xml修改启动脚本参数。vim /usr/local/rocketmq/bin/runbroker.sh#============================================================================== # 开发环境 JVM Configuration #============================================================================== JAVA_OPT="${JAVA_OPT} -server -Xms1g -Xmx1g -Xmn1gvim /usr/local/rocketmq/bin/runserver.shJAVA_OPT="${JAVA_OPT} -server -Xms1g -Xmx1g -Xmn1g -XX:PermSize=128m - XX:MaxPermSize=320m"接下来修改配置我们进入160服务器。cd /usr/local/rocketmq/conf/2m-2s-async首先我们修改vim broker-a.properties增加2节点的地址。namesrvAddr=rocketmq-nameserver1:9876;rocketmq-nameserver2:9876随后我们修改vim broker-a-s.propertiesbrokerClusterName=rocketmq-cluster #broker 名字,注意此处不同的配置文件填写的不一样 brokerName=broker-a #0 表示 Master,>0 表示 Slave brokerId=1 #nameServer 地址,分号分割 一定要和我们配置的hosts里的相同 namesrvAddr=rocketmq-nameserver1:9876;rocketmq-nameserver2:9876 #在发送消息时,自动创建服务器不存在的 topic,默认创建的队列数 defaultTopicQueueNums=4 #是否允许 Broker 自动创建 Topic,建议线下开启,线上关闭 autoCreateTopicEnable=true #是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭 autoCreateSubscriptionGroup=true #Broker 对外服务的监听端口 listenPort=10911 #删除文件时间点,默认凌晨 4 点 deleteWhen=04 #文件保留时间,默认 48 小时 fileReservedTime=120 #commitLog 每个文件的大小默认 1G mapedFileSizeCommitLog=1073741824 #ConsumeQueue 每个文件默认存 30W 条,根据业务情况调整 mapedFileSizeConsumeQueue=300000 #destroyMapedFileIntervalForcibly=120000 #redeleteHangedFileInterval=120000 #检测物理文件磁盘空间 diskMaxUsedSpaceRatio=88 #存储路径 storePathRootDir=/usr/local/rocketmq/store #commitLog 存储路径 storePathCommitLog=/usr/local/rocketmq/store/commitlog #消费队列存储路径存储路径 storePathConsumeQueue=/usr/local/rocketmq/store/consumequeue #消息索引存储路径 storePathIndex=/usr/local/rocketmq/store/index #checkpoint 文件存储路径 storeCheckpoint=/usr/local/rocketmq/store/checkpoint #abort 文件存储路径 abortFile=/usr/local/rocketmq/store/abort #限制的消息大小 maxMessageSize=65536 #flushCommitLogLeastPages=4 #flushConsumeQueueLeastPages=2 #flushCommitLogThoroughInterval=10000 #flushConsumeQueueThoroughInterval=60000 #Broker 的角色 #- ASYNC_MASTER 异步复制 Master #- SYNC_MASTER 同步双写 Master #- SLAVE brokerRole=SLAVE #刷盘方式 #- ASYNC_FLUSH 异步刷盘 #- SYNC_FLUSH 同步刷盘 flushDiskType=ASYNC_FLUSH #checkTransactionMessageEnable=false #发消息线程池数量 #sendMessageThreadPoolNums=128 #拉消息线程池数量 #pullMessageThreadPoolNums=128 修改完成后保存,然后执行复制命令拷贝到161节点。 scp broker-a.properties broker-a-s.properties 192.168.3.161:/usr/local/rocketmq/conf/2m-2s-async/检查没有问题后,我们回到160节点。进入bin目录,进行启动,同时161节点同样。nohup sh mqnamesrv &随后启动Brokernohup sh mqbroker -c /usr/local/rocketmq/conf/2m-2s-async/broker-a.properties >/dev/null 2>&1 &[root@localhost bin]# jps 4642 NamesrvStartup 4761 BrokerStartup 4777 Jps切换到161,我们执行下面命令,注意启动的配置是broker-a-s.propertiesnohup sh mqbroker -c /usr/local/rocketmq/conf/2m-2s-async/broker-a-s.properties >/dev/null 2>&1 &[root@localhost bin]# jps 23377 NamesrvStartup 23524 Jps 23414 BrokerStartup随后修改我们的web项目配置。增加161地址。rocketmq.config.namesrvAddr=192.168.3.160:9876;192.168.3.161:9876重启后查看Cluster。可以看到已经有两个节点。主从模式高可用机制故障演练Producer类:/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2021/1/26 * @Description: */ public class Producer { public static final String NAME_SRV_ADDR = "192.168.3.160:9876;192.168.3.161"; public static void main(String[] args) throws Exception { DefaultMQProducer producer = new DefaultMQProducer("test_quick_producer_name"); producer.setNamesrvAddr(NAME_SRV_ADDR); producer.start(); //1.创建消息 Message message = new Message("test_quick_topic",//主题 "TagA_", // 标签 "KeyA_", //用户自定义key,唯一标识 "Hello RocketMQ".getBytes());//消息内容实体 //2.发送消息 SendResult result = producer.send(message); System.out.println("消息发送结果:" + result); producer.shutdown(); } }Consumer类:/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2021/1/26 * @Description: */ public class Consumer { public static final String NAME_SRV_ADDR = "192.168.3.160:9876;192.168.3.161"; public static void main(String[] args) throws Exception { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("test_quick_consumer_name"); consumer.setNamesrvAddr(NAME_SRV_ADDR); consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET); //从最后端开始消费 consumer.subscribe("test_quick_topic",//订阅的主题 "*");// *代表包含所有 consumer.registerMessageListener((MessageListenerConcurrently) (msgs, consumeConcurrentlyContext) -> { MessageExt messageExt = msgs.get(0); try { String topic = messageExt.getTopic(); String tags = messageExt.getTags(); String keys = messageExt.getKeys(); String msgBody = new String(messageExt.getBody(), RemotingHelper.DEFAULT_CHARSET); System.out.println("topic:" + topic + ", tags : " + tags + ", keys :" + keys + ", msgBody:" + msgBody); } catch (Exception e) { e.printStackTrace(); return ConsumeConcurrentlyStatus.RECONSUME_LATER; } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; }); consumer.start(); } }我们启动Producer类。查看控制台。没有问题。消息发送结果:SendResult [sendStatus=SEND_OK, msgId=C0A803A5174918B4AAC284383C9C0000, offsetMsgId=C0A803A000002A9F0000000000000FE0, messageQueue=MessageQueue [topic=test_quick_topic, brokerName=broker-a, queueId=3], queueOffset=3]这个时候我们停止160主节点。sh mqshutdown broker可以看到目前只有slave节点。随后我们启动Consumer类。可以看到消息依然可以被消费。
缓存的使用与设计缓存的收益与成本收益:加速读写:CPU L1/L2/L3 Cache、浏览器缓存等。因为缓存通常都是全内存的(例如 Redis、Memcache),而 存储层通常读写性能不够强悍(例如 MySQL),通过缓存的使用可以有效 地加速读写,优化用户体验。降低后端负载:帮助后端减少访问量和复杂计算,在很大程度降低了后端的负载。成本:数据不一致:缓存层和数据层有时间窗口不一致,和更新策略有关。代码维护成本:加入缓存后,需要同时处理缓存层和存储层的逻辑, 增大了开发者维护代码的成本。运维成本:以 Redis Cluster 为例,加入后无形中增加了运维成本。使用场景:降低后端负载:对高消耗的 SQL:join 结果集/分组统计结果缓存。加速请求响应:利用 Redis/Memcache 优化 IO 响应时间。大量写合并为批量写:比如计数器先 Redis 累加再批量写入 DB。缓存更新策略缓存中的数据通常都是有生命周期的,需要在指定时间后被删除或更新,这样可以保证缓存空间在一个可控的范围。但是缓存中的数据会和数据源中的真实数据有一段时间窗口的不一致,需要利用某些策略进行更新。下面将分别从使用场景、一致性、开发人员开发/维护成本三个方面介绍三种缓存的更新策略。LRU/LFU/FIFO 算法剔除LRU:Least Recently Used,最近最少使用。LFU:Least Frequently Used,最不经常使用。FIFO:First In First Out,先进先出。使用场景:剔除算法通常用于缓存使用量超过了预设的最大值时候,如何对现有的数据进行剔除。例如 Redis 使用maxmemory-policy这个配置作为内存最大值后对于数据的剔除策略。一致性:要清理哪些数据是由具体算法决定,开发人员只能决定使用哪种算法,所以数据的一致性是最差的。维护成本:算法不需要开发人员自己来实现,通常只需要配置最大maxmemory和对应的策略即可。超时剔除使用场景:超时剔除通过给缓存数据设置过期时间,让其在过期时间后自动删除,例如 Redis 提供的 expire 命令。如果业务可以容忍一段时间内,缓存层数据和存储层数据不一致,那么可以为其设置过期时间。在数据过期后,再从真实数据源获取数据,重新放到缓存并设置过期时间。一致性:一段时间窗口内(取决于过期时间长短)存在一致性问题,即缓存数据和真实数据源的数据不一致。维护成本:维护成本不是很高,只需设置 expire 过期时间即可,当然前提是应用方允许这段时间可能发生的数据不一致。主动更新使用场景:应用方对于数据的一致性要求高,需要在真实数据更新后, 立即更新缓存数据。例如可以利用消息系统或者其他方式通知缓存更新。一致性:一致性最高,但如果主动更新发生了问题,那么这条数据很可能很长时间不会更新,所以建议结合超时剔除一起使用效果会更好。维护成本:维护成本会比较高,开发者需要自己来完成更新,并保证更新操作的正确性。总结策略一致性维护成本LRU/LFU/FIFO最差低超时剔除较差低主动更新最好高建议:低一致性业务建议配置最大内存和淘汰策略的方式使用。高一致性业务可以结合使用超时剔除和主动更新,这样即使主动更新出了问题,也能保证数据过期时间后删除脏数据。缓存粒度控制一般常用的架构就是缓存层使用 Redis,存储层使用 MySQL。比如:我们现在需要缓存用户信息。第一步:从 MySQL 查询,得到结果。第二步:放入缓存中。但是,我们是缓存 MySQL 查出的所有列呢,还是某一些比较重要常用的列。上述这个问题就是缓存粒度问题。下面将从通用性、空间占用、代码维护三个角度进行说明:通用性:缓存全部数据比部分数据更加通用,但从实际经验看,很长时间内应用只需要几个重要的属性。空间占用:缓存全部数据要比部分数据占用更多的空间,可能存在以下问题:全部数据会造成内存的浪费。全部数据可能每次传输产生的网络流量会比较大,耗时相对较大,在极端情况下会阻塞网络。全部数据的序列化和反序列化的 CPU 开销更大。代码维护:全部数据的优势更加明显,而部分数据一旦要加新字段需要修改业务代码,而且修改后通常还需要刷新缓存数据。缓存穿透问题缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中,通常出于容错的考虑,如果从存储层查不到数据则不写入缓存层。分为以下三步:缓存层不命中。存储层不命中,不将空结果写回缓存。返回空结果。缓存穿透带来的问题:缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储的意义。缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高并发性,甚至可能造成后端存储宕掉。通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。造成缓存穿透的原因:业务代码自身问题。一些恶意攻击、爬虫等。穿透优化的方案:缓存空对象。布隆过滤器。缓存空对象其实也就是当第 2 步存储层没有命中后,仍然将空对象保留到缓存层中,之后再访问这个数据将会从缓存中获取。这样会带来两种问题:空值做了缓存存储。意味着缓存中需要更多的内存空间。所以我们还需要针对这种空值增加一个过期时间,例如 1 分钟,3 分钟等等。具体还是根据业务来判断。这样做后会造成短期内缓存层与存储层有一段时间数据不一致问题,可能会对业务有所影响,比如我们查询商品 ID 为 888,此时缓存层和存储层都没有此 ID 数据,我们进行空值缓存后,如果此时恰好添加了 ID 为 888 的数据,就会导致短期内不一致问题。此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。布隆过滤器布隆过滤器是在访问缓存层和存储层之前,将存在的 key 用布隆过滤器提前保存起来,做第一层拦截。这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少。缓存雪崩问题由于 Cache 服务承载大量的请求,当 Cache 服务宕机后,大量的流量会直接压向后端组件 DB,造成级联故障。优化方案保证缓存高可用性,就算个别节点挂掉,依然还有别的可以提供服务。依赖隔离组件为后端限流降级,比如使用 Hystrix。提前演练。无底洞问题2010 年,Facebook 的 Memcache 节点已经达到了 3000 个,承载着 TB 级别的缓存数据。但开发和运维人员发现了一个问题,为了满足业务要求添加了大量新 Memcache 节点,但是发现性能不但没有好转反而下降了,当时将这种现象称为缓存的“无底洞”现象。那么为什么会产生这种现象呢,通常来说添加节点使得 Memcache 集群性能应该更强了,但事实并非如此。键值数据库由于通常采用哈希函数将 key 映射到各个节点上,造成 key 的分布与业务无关,但是由于数据量和访问量的持续增长,造成需要添加大量节点做水平扩容,导致键值分布到更多的节点上,所以无论是 Memcache 还是 Redis 的分布式,批量操作通常需要从不同节点上获取,相比于单机批量操作只涉及一次网络操作,分布式批量操作会涉及多次网络时间。优化思路命令本身的优化,例如:keys、hgetall、bigkey 等。减少网络通信次数。降低接入成本,例如客户端使用长连接/连接池、NIO 等。我们下面重点如何降低网络通信次数。串行 mget由于 n 个 key 是比较均匀地分布在 Redis Cluster 的各个节点上,因此无法使用 mget 命令一次性获取,所以通常来讲要获取 n 个 key 的值,最简单的方法就是逐次执行 n 个 get 命令,这种操作时间复杂度较高,它的操作时间=n 次网络时间+n 次命令时间。n 是 key 的数量,是最简单的实现方式但显然不是最优的。串行 IORedis Cluster 使用 CRC16 算法计算出散列值,再取对 16383 的余数就可以算出 slot 值,有了这两个数据就可以将属于同一个节点的 key 进行归档,得到每个节点的 key 子列表,之后对每个节点执行 mget 或者 Pipeline 操作。它的操作时间=node 次网络时间+n 次命令时间。这种方案比第一种要好一点,但是如果节点数太多,还是有一定的性能问题。并行 IO此方案是将方案 2 中的最后一步改为多线程执行,网络次数虽然还是节点个数,但由于使用多线程网络时间变为 O(1),这种方案会增加编程的复杂度。操作时间为max_slow(node 网络时间)+n 次命令时间。HASH_TAGRedis Cluster 的 hash_tag 功能可以强制将多个 key 强制分配到 一个节点上,它的操作时间=1 次网络时间+n 次命令时间。四种思路总结方案优点缺点时间复杂度串行命令简单,如果 key 少的话,性能可以接受大量 key 的话延迟严重O(keys)串行 IO简单,少量节点,性能满足要求大量节点的话延迟严重O(nodes)并行 IO并行特性,延迟取决于最慢的节点编程复杂,多线程定位复杂O(max_slow(nodes))hash_tag性能高读写增加 tag 维护成本,tag 分布易发生数据倾斜O(1)热点 key 的重建优化我们通常使用的“缓存+过期时间”的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:当前 key 是一个热点 key(例如一个热门的娱乐新闻),并发量非常大。重建缓存不能在短时间完成,可能是一个复杂计算。在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。我们需要制定如下目标:减少重建缓存的次数。数据尽可能一致。减少潜在危险。下面我们讲解一下两种解决方案。互斥锁此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完后,再重新从缓存获取数据即可。我们可以使用 Redis 的setnx命令来实现互斥锁。如果 Redis 数据存在则返回,不存在就进入第二步。如果 setnx 结果为 true,说明没有其它线程重建,我们执行重建缓存逻辑。如果 setnx 结果为 false,说明有其它的线程正在重建缓存,当前线程可以睡眠指定时间后再去获取缓存数据。永远不过期缓存层面:没有设置过期时间。功能层面:为每个 value 设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。此方法可以有效杜绝了热点 key 产生的问题,但唯一不足的就是重构缓存期间,会出现数据不一致的情况,这取决于是否可以容忍这种不一致。两种方案对比方案优点缺点互斥锁思路简单,保证一致性代码复杂度增加,存在死锁风险永远不过期基本杜绝热点 key 重建问题不保证一致性,逻辑过期时间增加维护成本
Redis Cluster在学习Redis Cluster之前,我们先了解为什么需要集群,当遇到单机内存、并发、流量等瓶颈时,单机已经无法满足我让节点7000和7001等节点进们的要求的时候,可以采用Cluster架构方案达到负载均衡的目的。数据分区概论分布式数据库首先要解决把整个数据集按照分区规则映射到多个节点的问题,即把数据集划分到多个节点上,每个节点负责整体数据的一个子集。常见的分区规则有哈希分区和顺序分区两种。首先看一下对比分布方式特点典型产品哈希分区数据分散度高、键值分布无业务无关、无法顺序访问、支持批量操作。一致性哈希:Mecache、Redis Cluster ...顺序分区数据分散度易倾斜、键值业务相关、可以顺序访问、支持批量操作。BigTable、HBase顺序分区比如:1-100个数字,要保存到3个节点上,每个节点平均存储,1-33存储在第1个节点,34-66存储到2节点,剩余存储到3节点。顺序存储常用在关系型存储上。哈希分区因为Redis Cluster采用的哈希分区,所以我们看一下常见的哈希分区有哪几种。节点取余分区比如100个数据,对每个数据进行hash运算之后,再于节点数进行取余运算,根据余数保存在不同节点上。缺点就是:当节点数量变化时,如扩容或收缩节点,数据节点映射关系需要重新计算,会导致数据的重新迁移。一致性哈希分区为系统中每个节点分配一个token,范围一般在0~2的32次方,这些token构成一个哈希环。数据读写执行节点查找操作时,先根据key计算hash值,然后顺时针找到第一个大于等于该哈希值的token节点,如下图所示这种方式相比节点取余最大的好处在于加入和删除节点只影响哈希环中相邻的节点,对其他节点无影响。但一致性哈希也存在一些问题:加减节点会造成哈希环中部分数据无法命中(例如一个key增减节点前映射到第n2个节点,因此它的数据是保存在第n2个节点上的;当我们增加一个节点后被映射到n5节点上了,此时我们去n5节点上去找这个key对应的值是找不到的,见下图),需要手动处理或者忽略这部分数据,因此一致性哈希常用于缓存场景。当使用少量节点时,节点变化将大范围影响哈希环中数据映射,因此这种方式不适合少量数据节点的分布式方案。普通的一致性哈希分区在增减节点时需要增加一倍或减去一半节点才能保证数据和负载的均衡。虚拟槽分区Redis Cluster采用的就是虚拟槽分区。槽的范围是0~16383,将16384个槽平均分配给节点,由节点进行管理。每次将key进行hash运算,对16383进行取余,然后去redis对应的槽进行查找。槽是集群内数据管理和迁移的基本单位。采用大范围槽的主要目的是为了方便数据拆分和集群扩展。每个节点会负责一定数量的槽。比如我们现在有5个集群,每个节点平均大约负责3276个槽。Redis Cluster 计算公式:slot=CRC16(key)&16383。每一个节点负责维护一部分槽以及槽所映射的键值数据。Redis虚拟槽分区的特点:解耦数据和节点之间的关系,简化了节点扩容和收缩难度。节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据。支持节点、槽、键之间的映射查询,用于数据路由、在线伸缩等场景。准备节点Redis集群一般由多个节点组成,节点数量至少为6个才能保证组成完整高可用的集群。每个节点需要开启配置cluster-enabled yes,让Redis运行在集群模式下。首先我们在redis文件中创建三个文件夹:config、data、log。分别存放配置、数据和日志相关文件。配置相关redis.conf#节点端口 port ${port} # 守护进程模式启动(可选) daemonize yes # 开启集群模式 cluster-enabled yes # 节点超时时间,单位毫秒 cluster-node-timeout 15000 # 集群内部配置文件 cluster-config-file /usr/local/redis/config/nodes-${port}.conf # 节点宕机后是否整个集群不可用 cluster-require-full-coverage no dir /usr/local/redis/data/ dbfilename dump-${port}.rdb logfile ${port}.log # 其余的配置与redis.conf默认配置文件一致即可6个节点全部配完成后就可以开启了。[root@localhost config]# ls redis-7000.conf redis-7001.conf redis-7002.conf redis-7003.conf redis-7004.conf redis-7005.conf[root@localhost redis]# redis-server config/redis-7000.conf [root@localhost redis]# cd config [root@localhost config]# cat nodes-7000.conf f4deba14aac6494e95e3e4ad060c94b8c82df7ec :0 myself,master - 0 0 0 connected vars currentEpoch 0 lastVoteEpoch 0 [root@localhost config]# cd .. [root@localhost redis]# redis-server config/redis-7001.conf [root@localhost redis]# redis-server config/redis-7002.conf [root@localhost redis]# redis-server config/redis-7003.conf [root@localhost redis]# redis-server config/redis-7004.conf [root@localhost redis]# redis-server config/redis-7005.conf [root@localhost redis]# cd config [root@localhost config]# ll 总用量 288 -rw-r--r--. 1 root root 112 12月 17 04:00 nodes-7000.conf -rw-r--r--. 1 root root 112 12月 17 04:00 nodes-7001.conf -rw-r--r--. 1 root root 112 12月 17 04:00 nodes-7002.conf -rw-r--r--. 1 root root 112 12月 17 04:00 nodes-7003.conf -rw-r--r--. 1 root root 112 12月 17 04:00 nodes-7004.conf -rw-r--r--. 1 root root 112 12月 17 04:00 nodes-7005.conf -rw-r--r--. 1 root root 41650 12月 17 03:59 redis-7000.conf -rw-r--r--. 1 root root 41649 12月 17 03:59 redis-7001.conf -rw-r--r--. 1 root root 41651 12月 17 03:59 redis-7002.conf -rw-r--r--. 1 root root 41651 12月 17 03:59 redis-7003.conf -rw-r--r--. 1 root root 41651 12月 17 03:59 redis-7004.conf -rw-r--r--. 1 root root 41651 12月 17 03:59 redis-7005.conf [root@localhost config]# cat nodes-7005.conf d1e8e8e42be8d3b2f3f44d197138e54d91170442 :0 myself,master - 0 0 0 connected vars currentEpoch 0 lastVoteEpoch 0 [root@localhost config]#检查节点日志是否正确:sudo cat /usr/local/redis/conf/nodes-${port}.conf文件内容记录了集群初始状态,这里最重要的是节点ID,它是一个40位16进制字符串,用于唯一标识集群内一个节点,之后很多集群操作都要借助于节点ID来完成。需要注意是,节点ID不同于运行ID:节点ID在集群初始化 时只创建一次,节点重启时会加载集群配置文件进行重用,而Redis的运行ID每次重启都会变化。我们现在启动6个节点,但每个节点彼此并不知道对方的存在,下面通过节点握手让6个节点彼此建立联系从而组成一个集群。[root@localhost redis]# ps -ef |grep redis root 1388 1 0 09:10 ? 00:00:00 redis-server *:7000 [cluster] root 1392 1 0 09:10 ? 00:00:00 redis-server *:7001 [cluster] root 1396 1 0 09:10 ? 00:00:00 redis-server *:7002 [cluster] root 1400 1 0 09:10 ? 00:00:00 redis-server *:7003 [cluster] root 1404 1 0 09:10 ? 00:00:00 redis-server *:7004 [cluster] root 1408 1 0 09:10 ? 00:00:00 redis-server *:7005 [cluster]节点握手节点握手是指一批运行在集群模式下的节点通过Gossip协议彼此通信, 达到感知对方的过程节点握手是集群彼此通信的第一步,由客户端发起下面的命令:cluster meet {ip} {port}[root@localhost redis]# redis-cli -h 127.0.0.1 -p 7000 cluster meet 127.0.0.1 7001 OK [root@localhost redis]# redis-cli -h 127.0.0.1 -p 7000 cluster meet 127.0.0.1 7002 OK [root@localhost redis]# redis-cli -h 127.0.0.1 -p 7000 cluster meet 127.0.0.1 7003 OK [root@localhost redis]# redis-cli -h 127.0.0.1 -p 7000 cluster meet 127.0.0.1 7004 OK [root@localhost redis]# redis-cli -h 127.0.0.1 -p 7000 cluster meet 127.0.0.1 7005 OK [root@localhost redis]# redis-cli -h 127.0.0.1 -p 7000 cluster meet 127.0.0.1 7006 OK上面执行命令之后让节点7000和7001等节点进行握手通信。cluster meet命令是一个异步命令,执行之后立刻返回。内部发起与目标节点进行握手通信。节点7000本地创建7001节点信息对象,并发送meet消息。节点7001接受到meet消息后,保存7000节点信息并回复pong消息。之后节点7000和7001彼此定期通过ping/pong消息进行正常的节点通信。这个时候我们再执行cluster nodes可以看到已经检测到其它节点了。[root@localhost redis]# redis-cli -h 127.0.0.1 -p 7000 cluster nodes d1e8e8e42be8d3b2f3f44d197138e54d91170442 127.0.0.1:7005 master - 0 1609463858135 4 connected 9a8abb84bcc8301a8f11c664471159dc0bf23a62 127.0.0.1:7001 master - 0 1609463860149 1 connected f4deba14aac6494e95e3e4ad060c94b8c82df7ec 127.0.0.1:7000 myself,master - 0 0 0 connected d5f317fc4597dbaac8b26a5897d801a72e45512e 127.0.0.1:7003 master - 0 1609463857127 3 connected 7dbbf232c72405a66416d2a0c335bd072f740644 127.0.0.1:7004 master - 0 1609463859143 5 connected d438b4689776cb6cd6b6d0eaecb7576669c7b3fe 127.0.0.1:7002 master - 0 1609463861156 2 connected节点建立握手之后集群还不能正常工作,这时集群处于下线状态,所有的数据读写都被禁止。通过如下命令可以看到:[root@localhost redis]# redis-cli -h 127.0.0.1 -p 7000 127.0.0.1:7000> set jack hello (error) CLUSTERDOWN The cluster is down通过cluster info命令可以获取集群当前状态:127.0.0.1:7000> cluster info cluster_state:fail cluster_slots_assigned:0 cluster_slots_ok:0 cluster_slots_pfail:0 cluster_slots_fail:0 cluster_known_nodes:6 cluster_size:0 cluster_current_epoch:5 cluster_my_epoch:0 cluster_stats_messages_sent:670 cluster_stats_messages_received:521可以看到我们现在的状态是fail,被分配的槽 cluster_slots_assigned是0,由于目前所有的槽没有分配到节点,因此集群无法完成槽到节点的映射。只有当16384个槽全部分配给节点后,集群才进入在线状态。分配槽Redis集群把所有的数据映射到16384个槽中。每个key会映射为一个固定的槽,只有当节点分配了槽,才能响应和这些槽关联的键命令。通过cluster addslots命令为节点分配槽。因为我们有6个节点,我们是三主三从的模式,所以只用给三个主节点进行配置即可。redis-cli -h 127.0.0.1 -p 7000 cluster addslots {0..5461} redis-cli -h 127.0.0.1 -p 7001 cluster addslots {5462..10922} redis-cli -h 127.0.0.1 -p 7002 cluster addslots {10923..16383}配置成功后,我们再进入节点看一下:[root@localhost redis]# redis-cli -h 127.0.0.1 -p 7000 127.0.0.1:7000> cluster info cluster_state:ok cluster_slots_assigned:16384 cluster_slots_ok:16384 cluster_slots_pfail:0 cluster_slots_fail:0 cluster_known_nodes:6 cluster_size:3 cluster_current_epoch:5 cluster_my_epoch:0 cluster_stats_messages_sent:1384 cluster_stats_messages_received:1235可以看到,cluster_state 和 cluster_slots_assigned都没有问题。设置主从目前还有三个节点没有使用,作为一个完整的集群,每个负责处理槽的节点应该具有从节点,保证当它出现故障时可以自动进行故障转移。集群模式下,Reids节点角色分为主节点和从节点。首次启动的节点和被分配槽的节点都是主节点,从节点负责复制主节点槽信息和相关的数据。使用cluster replicate {node-id}命令让一个节点成为从节点。其中命令执行必须在对应的从节点上执行,node-id是要复制主节点的节点ID。我们首先找到三个已经配置槽的节点的node-id。[root@localhost redis]# redis-cli -h 127.0.0.1 -p 7000 cluster nodes d1e8e8e42be8d3b2f3f44d197138e54d91170442 127.0.0.1:7005 master - 0 1609464545892 4 connected 9a8abb84bcc8301a8f11c664471159dc0bf23a62 127.0.0.1:7001 master - 0 1609464547906 1 connected 5462-10922 f4deba14aac6494e95e3e4ad060c94b8c82df7ec 127.0.0.1:7000 myself,master - 0 0 0 connected 0-5461 d5f317fc4597dbaac8b26a5897d801a72e45512e 127.0.0.1:7003 master - 0 1609464546899 3 connected 7dbbf232c72405a66416d2a0c335bd072f740644 127.0.0.1:7004 master - 0 1609464549923 5 connected d438b4689776cb6cd6b6d0eaecb7576669c7b3fe 127.0.0.1:7002 master - 0 1609464548916 2 connected 10923-16383[root@localhost redis]# redis-cli -h 127.0.0.1 -p 7003 cluster replicate f4deba14aac6494e95e3e4ad060c94b8c82df7ec OK [root@localhost redis]# redis-cli -h 127.0.0.1 -p 7004 cluster replicate 9a8abb84bcc8301a8f11c664471159dc0bf23a62 OK [root@localhost redis]# redis-cli -h 127.0.0.1 -p 7005 cluster replicate d438b4689776cb6cd6b6d0eaecb7576669c7b3fe OK完成后我们查看是否已经ok。[root@localhost redis]# redis-cli -h 127.0.0.1 -p 7000 cluster nodes d1e8e8e42be8d3b2f3f44d197138e54d91170442 127.0.0.1:7005 slave d438b4689776cb6cd6b6d0eaecb7576669c7b3fe 0 1609464847442 4 connected 9a8abb84bcc8301a8f11c664471159dc0bf23a62 127.0.0.1:7001 master - 0 1609464846435 1 connected 5462-10922 f4deba14aac6494e95e3e4ad060c94b8c82df7ec 127.0.0.1:7000 myself,master - 0 0 0 connected 0-5461 d5f317fc4597dbaac8b26a5897d801a72e45512e 127.0.0.1:7003 slave f4deba14aac6494e95e3e4ad060c94b8c82df7ec 0 1609464849456 3 connected 7dbbf232c72405a66416d2a0c335bd072f740644 127.0.0.1:7004 slave 9a8abb84bcc8301a8f11c664471159dc0bf23a62 0 1609464848449 5 connected d438b4689776cb6cd6b6d0eaecb7576669c7b3fe 127.0.0.1:7002 master - 0 1609464850468 2 connected 10923-16383目前为止,我们依照Redis协议手动建立一个集群。它由6个节点构成, 3个主节点负责处理槽和相关数据,3个从节点负责故障转移。Redis自动化安装我们之前分别使用命令搭建了一个完整的集群,但是命令过多,当集群节点众多时,必然会加大搭建集群的复杂度和运维成本。因此redis还提供了redis-cli --cluster来搭建集群。首先我们还是启动六个单独的节点。使用下面命令进行安装,--cluster-replicas 1 指定集群中每个主节点配备几个从节点,这里设置为1。并且该命令会自己创建主节点和分配从节点,其中前3个是主节点,后3个是从节点,后3个从节点分别复制前3个主节点。redis-cli --cluster create --cluster-replicas 1 127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005最后的输出报告说明:16384个槽全部被分配,集群创建成功。这里需要注意命令中节点的地址必须是不包含任何槽/数据的节点,否则会拒绝创建集群。如果不想要从节点则不填写该参数即可--cluster-replicas 1。最后我们可以使用下面命令进行查看是否已经ok。redis-cli --cluster check 127.0.0.1:7000集群伸缩原理Redis集群提供了灵活的节点扩容和收缩方案。在不影响集群对外服务的情况下,可以为集群添加节点进行扩容也可以下线部分节点进行缩容。原理可抽象为槽和对应数据在不同节点之间灵活移动。当我们现在有三个节点,此时想增加6385节点,也就是每个节点把一部分槽和数据迁移到新的节点6385,每个节点负责的槽和数据相比之前变少了从而达到了集群扩容的目的。扩容集群实操准备节点之前我们有6个节点,7000~7005节点。现在我们增加两个单独的节点也就是7006和7007。然后7006节点当做主节点,7007当做从节点。新节点跟集群内的节点配置保持一致,便于管理统一。随后我们进行启动[root@localhost redis]# redis-server config/redis-7006.conf [root@localhost redis]# redis-server config/redis-7007.conf这个时候我们的两个新的节点只是单独运行,并没有加入集群中。可以看到下面并没有7006和7007节点。[root@localhost redis]# redis-cli -h 127.0.0.1 -p 7000 cluster nodes d1e8e8e42be8d3b2f3f44d197138e54d91170442 127.0.0.1:7005 slave d438b4689776cb6cd6b6d0eaecb7576669c7b3fe 0 1609467765084 4 connected 9a8abb84bcc8301a8f11c664471159dc0bf23a62 127.0.0.1:7001 master - 0 1609467769137 1 connected 5462-10922 f4deba14aac6494e95e3e4ad060c94b8c82df7ec 127.0.0.1:7000 myself,master - 0 0 0 connected 0-5461 d5f317fc4597dbaac8b26a5897d801a72e45512e 127.0.0.1:7003 slave f4deba14aac6494e95e3e4ad060c94b8c82df7ec 0 1609467767119 3 connected 7dbbf232c72405a66416d2a0c335bd072f740644 127.0.0.1:7004 slave 9a8abb84bcc8301a8f11c664471159dc0bf23a62 0 1609467768127 5 connected d438b4689776cb6cd6b6d0eaecb7576669c7b3fe 127.0.0.1:7002 master - 0 1609467766110 2 connected 10923-16383结构图如下:加入集群redis-cli -h 127.0.0.1 -p 7000 cluster meet 127.0.0.1 7006 redis-cli -h 127.0.0.1 -p 7000 cluster meet 127.0.0.1 7007集群内新旧节点经过一段时间的ping/pong消息通信之后,所有节点会发现新节点并将它们的状态保存到本地。随后我们再进行查看cluster nodes。[root@localhost redis]# redis-cli -h 127.0.0.1 -p 7000 cluster nodes d1e8e8e42be8d3b2f3f44d197138e54d91170442 127.0.0.1:7005 slave d438b4689776cb6cd6b6d0eaecb7576669c7b3fe 0 1609468208783 4 connected 9a8abb84bcc8301a8f11c664471159dc0bf23a62 127.0.0.1:7001 master - 0 1609468204768 1 connected 5462-10922 f4deba14aac6494e95e3e4ad060c94b8c82df7ec 127.0.0.1:7000 myself,master - 0 0 0 connected 0-5461 d5f317fc4597dbaac8b26a5897d801a72e45512e 127.0.0.1:7003 slave f4deba14aac6494e95e3e4ad060c94b8c82df7ec 0 1609468210798 3 connected 35f9f0abd365bb0fc424dbdaa849f1f1c71163bb 127.0.0.1:7006 master - 0 1609468209790 6 connected 55b028fbd0a0207b6acc6e2b1067bf79f3090534 127.0.0.1:7007 master - 0 1609468206777 7 connected 7dbbf232c72405a66416d2a0c335bd072f740644 127.0.0.1:7004 slave 9a8abb84bcc8301a8f11c664471159dc0bf23a62 0 1609468205773 5 connected d438b4689776cb6cd6b6d0eaecb7576669c7b3fe 127.0.0.1:7002 master - 0 1609468206274 2 connected 10923-16383然后我们把7007设置为7006的从节点redis-cli -h 127.0.0.1 -p 7007 cluster replicate 35f9f0abd365bb0fc424dbdaa849f1f1c71163bb再次查看已经OK。[root@localhost redis]# redis-cli -h 127.0.0.1 -p 7000 cluster nodes d1e8e8e42be8d3b2f3f44d197138e54d91170442 127.0.0.1:7005 slave d438b4689776cb6cd6b6d0eaecb7576669c7b3fe 0 1609470748800 4 connected 9a8abb84bcc8301a8f11c664471159dc0bf23a62 127.0.0.1:7001 master - 0 1609470750824 1 connected 5462-10922 f4deba14aac6494e95e3e4ad060c94b8c82df7ec 127.0.0.1:7000 myself,master - 0 0 0 connected 0-5461 d5f317fc4597dbaac8b26a5897d801a72e45512e 127.0.0.1:7003 slave f4deba14aac6494e95e3e4ad060c94b8c82df7ec 0 1609470745778 3 connected 35f9f0abd365bb0fc424dbdaa849f1f1c71163bb 127.0.0.1:7006 master - 0 1609470746785 6 connected 55b028fbd0a0207b6acc6e2b1067bf79f3090534 127.0.0.1:7007 slave 35f9f0abd365bb0fc424dbdaa849f1f1c71163bb 0 1609470751833 7 connected 7dbbf232c72405a66416d2a0c335bd072f740644 127.0.0.1:7004 slave 9a8abb84bcc8301a8f11c664471159dc0bf23a62 0 1609470749817 5 connected d438b4689776cb6cd6b6d0eaecb7576669c7b3fe 127.0.0.1:7002 master - 0 1609470747795 2 connected 10923-16383槽迁移计划上面我们添加了两个新节点:7006、7007。其中7006作为主节点存储数据,7007作为从节点复制7006。下面我们要把其他节点的槽和数据迁移到7006这个节点中。再迁移后原有节点负责的槽数量变为4096个。迁移数据数据迁移过程是逐个槽进行的。流程如下:对目标节点发送:cluster setslot {slot} importing {sourceNodeId}命令,让目标节点准备导入槽数据。对源节点发送:cluster setslot {slot} migrating {targetNodeId}命令,让源节点准备迁出槽数据。源节点循环执行:cluster getkeysinslot {slot} {count}命令,每次获取count个属于槽的键。在源节点上执行:migrate {targetIP} {targetPort} key 0 {timeout}命令,把指定的key迁移。重复执行步骤3和步骤4,直到槽下所有的键值数据迁移到目标节点。向集群内所有主节点发送:cluster setslot {slot} node {targetNodeId}命令,通知槽分配给目标节点。伪代码如下:def move_slot(source,target,slot): # 目标节点准备导入槽 target.cluster("setslot",slot,"importing",source.nodeId); # 源节点准备全出槽 source.cluster("setslot",slot,"migrating",target.nodeId); while true : # 批量从源节点获取键 keys = source.cluster("getkeysinslot",slot,pipeline_size); if keys.length == 0: # 键列表为空时,退出循环 break; # 批量迁移键到目标节点 source.call("migrate",target.host,target.port,"",0,timeout,"keys",keys); # 向集群所有主节点通知槽被分配给目标节点 for node in nodes: if node.flag == "slave": continue; node.cluster("setslot",slot,"node",target.nodeId);redis-cli cluster进行迁移redis-cli --cluster reshard host:port --from <arg> --to <arg> --slots <arg> --yes --timeout <arg> --pipeline <arg>host:port:必传参数,集群内任意节点地址,用来获取整个集群信息。--from:制定源节点的id,如果有多个源节点,使用逗号分隔,如果是all源节点变为集群内所有主节点,在迁移过程中提示用户输入。--to:需要迁移的目标节点的id,目标节点只能填写一个,在迁移过程 中提示用户输入。--slots:需要迁移槽的总数量,在迁移过程中提示用户输入。--yes:当打印出reshard执行计划时,是否需要用户输入yes确认后再执行reshard。--timeout:控制每次migrate操作的超时时间,默认为60000毫秒。·--pipeline:控制每次批量迁移键的数量,默认为10。开始迁移:redis-cli --cluster reshard 127.0.0.1:7000输入需要迁移的槽数量,此处我们输入4096。目标节点ID,只能指定一个,因为我们需要迁移到7006中,因此下面输入7006的ID。之后输入源节点的ID,redis会从这些源节点中平均取出对应数量的槽,然后迁移到6385中,下面我们分别输入7000、7001、7002的节点ID。最后要输入done表示结束。最后输入yes即可。我们可以检查一下节点之间的平衡性redis-cli --cluster rebalance 127.0.0.1:6380所有主节点负责的槽数量差异在2%以内,就算集群节点数据相对均匀,无需调整。收缩集群首先需要确定下线节点是否有负责的槽,如果是,需要把槽迁移到 其他节点,保证节点下线后整个集群槽节点映射的完整性。当下线节点不再负责槽或者本身是从节点时,就可以通知集群内其 他节点忘记下线节点,当所有的节点忘记该节点后可以正常关闭。收缩正好和扩容迁移方向相反,7006变为源节点,其他主节点变为目标节点,源节点需要把自身负责的4096个槽均匀地迁移到其他主节点上。具体步骤和上述扩容类似,这里就不演示。请求重定向在集群模式下,Redis接收任何键相关命令时首先计算键对应的槽,再根据槽找出所对应的节点,如果节点是自身,则处理键命令;否则回复MOVED重定向错误,通知客户端请求正确的节点。命中槽因为我们执行cluster keyslot hello之后,发现槽的位置在866,在我们之中,所以直接返回。127.0.0.1:7000> set hello world OK 127.0.0.1:7000> cluster keyslot hello (integer) 866 127.0.0.1:7000> get hello "world"未命中槽由于键对应槽是6918,不属于7000节点,则回复MOVED {slot} {ip} {port}格式重定向信息:127.0.0.1:7000> set test hello (error) MOVED 6918 127.0.0.1:7001我们可以切换到7001发送命令即可成功。127.0.0.1:7001> set test hello OK用redis-cli命令时,可以加入-c参数支持自动重定向,简化手动发起重定向操作。[root@localhost config]# redis-cli -h 127.0.0.1 -p 7000 -c 127.0.0.1:7000> set test hello -> Redirected to slot [6918] located at 127.0.0.1:7001 OKASK重定向Redis集群支持在线迁移槽(slot)和数据来完成水平伸缩,当slot对应的数据从源节点到目标节点迁移过程中,客户端需要做到智能识别,保证键命令可正常执行。例如当一个slot数据从源节点迁移到目标节点时,期间可能出现一部分数据在源节点,而另一部分在目标节点。当出现上述情况时,客户端键命令执行流程将发生变化,如下所示:客户端根据本地slots缓存发送命令到源节点,如果存在键对象则直 接执行并返回结果给客户端。如果键对象不存在,则可能存在于目标节点,这时源节点会回复 ASK重定向异常。格式如下:(error) ASK {slot} {targetIP}:{targetPort}。客户端从ASK重定向异常提取出目标节点信息,发送asking命令到目标节点打开客户端连接标识,再执行键命令。如果存在则执行,不存在则返回不存在信息。ASK和MOVED区别ASK重定向说明集群正在进行slot数据迁移,客户端无法知道什么时候迁移完成,因此只能是临时性的重定向,客户端不会更新slots缓存。但是MOVED重定向说明键对应的槽已经明确指定到新的节点,因此需要更新slots缓存。故障发现当集群内某个节点出现问题时,需要通过一种健壮的方式保证识别出节点是否发生了故障。Redis集群内节点通过ping/pong消息实现节点通信,消息不但可以传播节点槽信息,还可以传播其他状态如:主从状态、节点故障等。因此故障发现也是通过消息传播机制实现的,主要环节包括:主观下线 (pfail):指某个节点认为另一个节点不可用,即下线状态,这个状态并不是最终的故障判定,只能代表一个节点的意见,可能存在误判情况。客观下线(fail):指标记一个节点真正的下线,集群内多个节点都认为该节点不可用,从而达成共识的结果。如果是持有槽的主节点故障,需要为该节点进行故障转移。主观下线集群中每个节点都会定期向其他节点发送ping消息,接收节点回复pong消息作为响应。如果在cluster-node-timeout时间内通信一直失败,则发送节点会认为接收节点存在故障,把接收节点标记为主观下线(pfail)状态。主观下线流程:节点a发送ping消息给节点b,如果通信正常将接收到pong消息,节点a更新最近一次与节点b的通信时间。如果节点a与节点b通信出现问题则断开连接,下次会进行重连。如果一直通信失败,则节点a记录的与节点b最后通信时间将无法更新。节点a内的定时任务检测到与节点b最后通信时间超过cluster-nodetimeout时,更新本地对节点b的状态为主观下线(pfail)。客观下线当半数以上持有槽的主节点都标记某节点主观下线。客观下线流程:当消息体内含有其他节点的pfail状态会判断发送节点的状态,如果发送节点是主节点则对报告的pfail状态处理,从节点则忽略。找到pfail对应的节点结构,更新clusterNode内部下线报告链表。根据更新后的下线报告链表告尝试进行客观下线。尝试客观下线首先统计有效的下线报告数量,如果小于集群内持有槽的主节点总数的一半则退出。当下线报告大于槽主节点数量一半时,标记对应故障节点为客观下线状态。向集群广播一条fail消息,通知所有的节点将故障节点标记为客观下线,fail消息的消息体只包含故障节点的ID。故障恢复故障节点变为客观下线后,如果下线节点是持有槽的主节点则需要在它的从节点中选出一个替换它,从而保证集群的高可用。下线主节点的所有从节点承担故障恢复的义务,当从节点通过内部定时任务发现自身复制的主节点进入客观下线时,将会触发故障恢复流程。检查资格每个从节点都要检查最后与主节点断线时间,判断是否有资格替换故障的主节点。如果从节点与主节点断线时间超过cluster-node-timeout * cluster-slave-validity-factor,则当前从节点不具备故障转移资格。参数cluster-slavevalidity-factor用于从节点的有效因子,默认为10。准备选举时间当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能执行后续流程。主节点b进入客观下线后,它的三个从节点根据自身复制偏移量设置延迟选举时间,如复制偏移量最大的节点slave b-1延迟1秒执行,保证复制延迟低的从节点优先发起选举。选举投票只有持有槽的主节点才会处理故障选举消息。投票过程其实是一个领导者选举的过程,如集群内有N个持有槽的主节点代表有N张选票。由于在每个配置纪元内持有槽的主节点只能投票给一个从节点,因此只能有一个从节点获得N/2+1的选票,保证能够找出唯一的从节点。Redis集群没有直接使用从节点进行领导者选举,主要因为从节点数必须大于等于3个才能保证凑够N/2+1个节点,将导致从节点资源浪费。使用集群内所有持有槽的主节点进行领导者选举,即使只有一个从节点也可以完成选举过程。当从节点收集到N/2+1个持有槽的主节点投票时,从节点可以执行替换主节点操作,例如集群内有5个持有槽的主节点,主节点b故障后还有4个, 当其中一个从节点收集到3张投票时代表获得了足够的选票可以进行替换主节点操作。替换主节点当从节点收集到足够的选票之后,触发替换主节点操作:当前从节点取消复制变为主节点。执行clusterDelSlot操作撤销故障主节点负责的槽,并执行clusterAddSlot把这些槽委派给自己。向集群广播自己的pong消息,表明已经替换了故障从节点。
Redis持久化什么是持久化Redis所有数据保存在内存中,对数据的更新将异步地保存到磁盘上。持久化的方式快照MySQL DumpRedis RDB日志MySQL binlogRedis AOFRDB什么是RDBRDB持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘。也是默认的持久化方式,这种方式是就是将内存中数据以快照的方式写入到二进制文件中,默认的文件名为dump.rdb。在 Redis 运行时, RDB 程序将当前内存中的数据库快照保存到磁盘文件中, 在 Redis 重启动时, RDB 程序可以通过载入 RDB 文件来还原数据库的状态。工作方式当 Redis 需要保存 dump.rdb 文件时, 服务器执行以下操作:Redis 调用forks。同时拥有父进程和子进程。子进程将数据集写入到一个临时 RDB 文件中。当子进程完成对新 RDB 文件的写入时,Redis 用新 RDB 文件替换原来的 RDB 文件,并删除旧的 RDB 文件。这种工作方式使得 Redis 可以从写时复制(copy-on-write)机制中获益。三种触发机制save命令save 命令执行一个同步操作,以RDB文件的方式保存所有数据的快照。127.0.0.1:6379> save OK需要注意的是save命令是同步命令。如果数据过多,会造成阻塞。另外需要注意的是,执行save命令会覆盖之前的RDB文件。bgsave命令bgsave 命令执行一个异步操作,以RDB文件的方式保存所有数据的快照。127.0.0.1:6379> bgsave Background saving startedRedis使用Linux系统的fock()生成一个子进程来将DB数据保存到磁盘,主进程继续提供服务以供客户端调用。如果操作成功,可以通过客户端命令LASTSAVE来检查操作结果。LASTSAVE 将返回最近一次 Redis 成功将数据保存到磁盘上的时间,以 UNIX 时间戳格式表示。127.0.0.1:6379> LASTSAVE (integer) 1609294414save与bgsave对比命令savebgsaveIO类型同步异步阻塞是是(阻塞发生在fock(),通常非常快)复杂度O(n)O(n)优点不会消耗额外的内存不阻塞客户端命令缺点阻塞客户端命令需要fock子进程,消耗内存自动保存我们可以通过配置文件对 Redis 进行设置, 让它在“ N 秒内数据集至少有 M 个改动”这一条件被满足时, 自动进行数据集保存操作。相关配置# RDB自动持久化规则 # 当 900 秒内有至少有 1 个键被改动时,自动进行数据集保存操作 save 900 1 # 当 300 秒内有至少有 10 个键被改动时,自动进行数据集保存操作 save 300 10 # 当 60 秒内有至少有 10000 个键被改动时,自动进行数据集保存操作 save 60 10000 # RDB持久化文件名 dbfilename dump-<port>.rdb # 数据持久化文件存储目录 dir /var/lib/redis # bgsave发生错误时是否停止写入,默认为yes stop-writes-on-bgsave-error yes # rdb文件是否使用压缩格式 rdbcompression yes # 是否对rdb文件进行校验和检验,默认为yes rdbchecksum yes优点适合大规模的数据恢复。如果业务对数据完整性和一致性要求不高,RDB是很好的选择。缺点数据的完整性和一致性不高,因为RDB可能在最后一次备份时宕机了。备份时占用内存,因为Redis 在备份时会独立创建一个子进程,将数据写入到一个临时文件,最后再将临时文件替换之前的备份文件。所以要考虑到大概两倍的数据膨胀性。针对RDB不适合实时持久化的问题,Redis提供了AOF持久化方式来解决。AOFAOF(append only file)持久化:以独立日志的方式记录每次写命令,重启时再重新执行AOF文件中命令达到恢复数据的目的。AOF的主要作用是解决了数据持久化的实时性,目前已经是Redis持久化的主流方式。AOF创建原理AOF恢复原理三种策略always每次有新命令追加到 AOF 文件时就执行一次 fsync ,非常慢,也非常安全。everysec每秒 fsync 一次:足够快,并且在故障时只会丢失 1 秒钟的数据。推荐(并且也是默认)为每秒 fsync 一次, 这种 fsync 策略可以兼顾速度和安全性。no将数据交给操作系统来处理,由操作系统来决定什么时候同步数据。三种对比命令alwayseverysecno优点不丢失数据每秒一次fsync,可能会丢失一秒数据省心缺点IO开销较大可能会丢失一秒数据不可控AOF重写因为 AOF 的运作方式是不断地将命令追加到文件的末尾, 所以随着写入命令的不断增加, AOF 文件的体积也会变得越来越大。所以Redis会将已经过期、重复的命令最终改写为生效的命令。可以在不打断服务客户端的情况下, 对 AOF 文件进行重建(rebuild)。执行 bgrewriteaof 命令, Redis 将生成一个新的 AOF 文件, 这个文件包含重建当前数据集所需的最少命令。AOF重写的作用减少磁盘占用量加速数据恢复AOF重写实现的两种方式BGREWRITEAOF执行一个AOF文件重写操作。重写会创建一个当前 AOF 文件的体积优化版本。即使BGREWRITEAOF执行失败,也不会有任何数据丢失,因为旧的 AOF 文件在BGREWRITEAOF成功之前不会被修改。重写操作只会在没有其他持久化工作在后台执行时被触发,也就是说:如果 Redis 的子进程正在执行快照的保存工作,那么 AOF 重写的操作会被预定(scheduled),等到保存工作完成之后再执行 AOF 重写。在这种情况下, BGREWRITEAOF 的返回值仍然是 OK 。如果已经有别的 AOF 文件重写在执行,那么BGREWRITEAOF 返回一个错误,并且这个新的 BGREWRITEAOF 请求也不会被预定到下次执行。从 Redis 2.4 开始, AOF 重写由 Redis 自行触发,BGREWRITEAOF 仅仅用于手动触发重写操作。127.0.0.1:6379> BGREWRITEAOF Background append only file rewriting startedAOF重写配置配置名称含义auto-aof-rewrite-min-sizeaof文件重写需要的大小auto-aof-rewrite-percentageaof文件增长率统计名称含义aof_current_sizeAOF文件当前尺寸(字节)aof_base_sizeAOF文件上次启动和重写时的尺寸(字节)自动触发时机,同时满足的情况下:aof_current_size > auto-aof-rewrite-min-size(aof_current_size - aof_base_size) * 100 / aof_base_size > auto-aof-rewrite-percentageAOF重写流程AOF相关配置# 开启AOF持久化方式 appendonly yes # AOF持久化文件名 appendfilename appendonly-<port>.aof # 每秒把缓冲区的数据同步到磁盘,同步策略 appendfsync everysec # 数据持久化文件存储目录 dir /var/lib/redis # 是否在执行重写时不同步数据到AOF文件 no-appendfsync-on-rewrite yes # 触发AOF文件执行重写的最小尺寸 auto-aof-rewrite-min-size 64mb # 触发AOF文件执行重写的增长率 auto-aof-rewrite-percentage 100AOF的优点AOF可以更好的保护数据不丢失,一般AOF会以每隔1秒,通过后台的一个线程去执行一次fsync操作,如果redis进程挂掉,最多丢失1秒的数据。AOF以appen-only的模式写入,所以没有任何磁盘寻址的开销,写入性能非常高。AOF日志文件的命令通过非常可读的方式进行记录,这个非常适合做灾难性的误删除紧急恢复。AOF的缺点对于同一份文件AOF文件比RDB数据快照要大。AOF开启后支持写的QPS会比RDB支持的写的QPS低,因为AOF一般会配置成每秒fsync操作,每秒的fsync操作还是很高的。数据恢复比较慢,不适合做冷备。RDB和AOF命令RDBAOF启动优先级低高体积小大恢复速度快慢数据安全性丢数据根据策略决定轻重重轻如何抉择不要仅仅使用RDB这样会丢失很多数据。也不要仅仅使用AOF,因为这会有两个问题,第一通过AOF做冷备没有RDB做冷备恢复的速度快;第二RDB每次简单粗暴生成数据快照,更加健壮。综合AOF和RDB两种持久化方式,用AOF来保证数据不丢失,作为恢复数据的第一选择;用RDB来做不同程度的冷备,在AOF文件都丢失或损坏不可用的时候,可以使用RDB进行快速的数据恢复。主从复制了解主从复制之前,我们先看看单机有什么问题?单机故障,比如CPU坏了,内存坏了,宕机。容量瓶颈。QPS瓶颈,虽然Redis官网说可以达到10w QPS,如果我们想要达到 100w QPS,单机显然是无法做到的。什么是主从复制主从复制,是用来建立一个和主数据库完全一样的数据库环境,称为从数据库,主数据库一般是准实时的业务数据库。在最常用的mysql数据库中,支持单项、异步赋值。在赋值过程中,一个服务器充当主服务器,而另外一台服务器充当从服务器;此时主服务器会将更新信息写入到一个特定的二进制文件中。并会维护文件的一个索引用来跟踪日志循环。这个日志可以记录并发送到从服务器的更新中去。当一台从服务器连接到主服务器时,从服务器会通知主服务器从服务器的日志文件中读取最后一次成功更新的位置。然后从服务器会接收从哪个时刻起发生的任何更新,然后锁住并等到主服务器通知新的更新。主从复制的作用确保数据安全;做数据的热备,作为后备数据库,主数据库服务器故障后,可切换到从数据库继续工作,避免数据的丢失。提升I/O性能;随着日常生产中业务量越来越大,I/O访问频率越来越高,单机无法满足,此时做多库的存储,有效降低磁盘I/O访问的频率,提高了单个设备的I/O性能。读写分离;使数据库能支持更大的并发。同样也支持一主多从。简单演示:小总结一个master可以有多个slave一个slave只能有一个master数据流向是单向的,master到slave主从复制的两种实现方式slaveof命令127.0.0.1:6379>slaveof ip port取消复制需要注意的是断开主从复制后,仍然会保留master之前给它同步的数据。127.0.0.1:6379>slaveof no one配置文件#配置主节点的ip和port slaveof ip port #从节点只读,避免主从数据不一致 slave-read-only yes两种方式对比方式命令配置优点无需重启统一配置缺点不便于管理需要重启实操首先准备两台centos,我这里主节点是: 192.168.3.155从节点:192.168.3.156我们使用配置文件的方式,在redis.conf中配置 slaveof 192.168.3.155 6379。注意防火墙开启6379端口。启动主和从节点后,我们可以使用 info replication查看。192.168.3.155:6379> info replication # Replication role:master connected_slaves:1 slave0:ip=192.168.3.156,port=6379,state=online,offset=15,lag=1 master_repl_offset:15 repl_backlog_active:1 repl_backlog_size:1048576 repl_backlog_first_byte_offset:2 repl_backlog_histlen:14192.168.3.156:6379> info replication # Replication role:slave master_host:192.168.3.155 master_port:6379 master_link_status:up master_last_io_seconds_ago:1 master_sync_in_progress:0 slave_repl_offset:15 slave_priority:100 slave_read_only:1 connected_slaves:0 master_repl_offset:0 repl_backlog_active:0 repl_backlog_size:1048576 repl_backlog_first_byte_offset:0 repl_backlog_histlen:0我们接着可以测试一下,在主节点加入测试数据,看看从节点是否可以获取。192.168.3.155:6379> set jack hello OK 192.168.3.156:6379> get jack "hello"全量复制用于初次复制或其它无法进行部分复制的情况,将主节点中的所有数据都发送给从节点,是一个IE非常重型的操作,当数据量较大时,会对主从节点和网络造成很大的开销。Redis 内部会发出一个同步命令,刚开始是 psync 命令,psync ? -1表示要求 master 主机同步数据。主机会向从机发送 runid (redis-cli info server)和 offset,因为 slave 并没有对应的 offset,所以是全量复制。从机会保存主机的基本信息save masterinfo。主节点收到全量复制的命令后,执行bgsave(异步执行),在后台生成RDB文件(快照),并使用一个缓冲区(称为复制缓冲区)记录从现在开始执行的所有命令。主机send RDB发送RDB文件给从机。发送缓冲区数据。刷新旧的数据,从节点在载入主节点的数据之前要先将老数据清除。加载RDB文件将数据库状态更新至主节点执行bgsave时的数据库状态和缓冲区数据加载。复制偏移量从节点(slave)每秒钟上报自身的复制偏移量给主节点,因为主节点也会保存从节点的复制偏移量,slave_repl_offset 指标。统计指标如下:192.168.3.156:6379> info replication # Replication role:slave master_host:192.168.3.155 master_port:6379 master_link_status:up master_last_io_seconds_ago:1 master_sync_in_progress:0 slave_repl_offset:15 slave_priority:100 slave_read_only:1 connected_slaves:0 master_repl_offset:0 repl_backlog_active:0 repl_backlog_size:1048576 repl_backlog_first_byte_offset:0 repl_backlog_histlen:0参与复制的主从节点都会维护自身复制偏移量。主节点(master)在处理完写入命令后,会把命令的字节长度做累加记录,统计信息会在info replication 中的master_repl_offset指标中。slave0记录了从节点信息。192.168.3.155:6379> info replication # Replication role:master connected_slaves:1 slave0:ip=192.168.3.156,port=6379,state=online,offset=15,lag=1 master_repl_offset:15 repl_backlog_active:1 repl_backlog_size:1048576 repl_backlog_first_byte_offset:2 repl_backlog_histlen:14从节点在接收到主节点发送的命令后,也会累加记录自身的偏移量。统计信息在info replication中的slave_repl_offset。部分复制如果网络抖动(连接断开 connection lost)主机master 还是会写 replbackbuffer(复制缓冲区)从机slave 会继续尝试连接主机从机slave 会把自己当前 runid 和偏移量传输给主机 master,并且执行 pysnc 命令同步如果 master 发现你的偏移量是在缓冲区的范围内,就会返回 continue 命令同步了 offset 的部分数据,所以部分复制的基础就是偏移量 offset。如何选择从节点将offset发送给主节点后,主节点根据offset和缓冲区大小决定能否执行部分复制。如果offset偏移量之后的数据,仍然都在复制积压缓冲区里,则执行部分复制。如果offset偏移量之后的数据已不在复制积压缓冲区中(数据已被挤压),则执行全量复制。主从节点初次复制时,主节点将自己的runid发送给从节点,从节点将这个runid保存起来,当断线重连时,从节点会将这个runid发送给主节点,主节点根据runid判断能否进行部分复制。如果从节点保存的runid与主节点现在的runid相同,说明主从节点之前同步过,主节点会继续尝试使用部分复制(到底能不能部分复制还要看offset和复制积压缓冲区的情况)。如果从节点保存的runid与主节点现在的runid不同,说明从节点在断线前同步的Redis节点并不是当前的主节点,只能进行全量复制。runid可以通过 info server命令来查看。192.168.3.156:6379> info server # Server redis_version:3.0.7 redis_git_sha1:00000000 redis_git_dirty:0 redis_build_id:3fdf3aafcf586962 redis_mode:standalone os:Linux 3.10.0-1127.el7.x86_64 x86_64 arch_bits:64 multiplexing_api:epoll gcc_version:4.8.5 process_id:11306 run_id:116ef394d1999f8807f1d30d1bf0dc79aa8d865d tcp_port:6379 uptime_in_seconds:3442 uptime_in_days:0 hz:10 lru_clock:14271160 config_file:/usr/local/redis-3.0.7/redis.conf主从复制的问题主从复制,主挂掉后需要手工来操作麻烦。写能力和存储能力受限 (主从复制只是备份,单节点存储能力) 。如果这个时候Master断掉了,那么主从复制也就断掉了。那么这个时候写就失败了。在这个时候我们只能选择一个从节点执行slaveof no one。然后让其它从节点选择新的主节点。Redis Sentinel 架构在主从复制的基础上,增加了多个 Redis Sentinel 节点,这些Sentinel节点不存储数据。在Redis发生故障时候会自动进行故障转移处理,然后通知客户端。一套Redis Sentinel集群可以监控多套Redis主从,每一套Redis主从通过master-name作为标识。客户端不直接连接Redis服务,而连接Redis Sentinel。在Redis Sentinel中清楚哪个是master节点。故障转移过程多个Sentinel发现并确认master有问题。选举出一个Sentinel作为领导。选出一个slave作为master。通知其余slave成为新的master的salve。通知客户端主从变化。等待老的master复活成为新的master的slave。安装与配置sentinel monitor mymaster 127.0.0.1 6379 2 sentinel down-after-milliseconds mymaster 60000 sentinel failover-timeout mymaster 180000 sentinel parallel-syncs mymaster 1在master节点增加 daemonize yes配置后。我们进行启动redis-sentinel sentinel.conf我们执行查看命令是否已经启动。可以看到26379端口已经启动。[root@localhost redis]# ps -ef | grep redis-sentinel root 11056 1 0 18:38 ? 00:00:00 redis-sentinel *:26379 [sentinel] root 11064 9916 0 18:41 pts/0 00:00:00 grep --color=auto redis-sentinel随后我们进行连接,可以使用info命令查看信息 [root@localhost redis]# redis-cli -p 26379 127.0.0.1:26379> info # Server redis_version:3.0.7 redis_git_sha1:00000000 redis_git_dirty:0 redis_build_id:311215fe18f833b6 redis_mode:sentinel os:Linux 3.10.0-1127.el7.x86_64 x86_64 arch_bits:64 multiplexing_api:epoll gcc_version:4.8.5 process_id:11056 run_id:855df973568ff3604a9a373a799c24601b15822a tcp_port:26379 uptime_in_seconds:260 uptime_in_days:0 hz:17 lru_clock:14279821 config_file:/usr/local/redis-3.0.7/sentinel.conf # Sentinel sentinel_masters:1 # 一个master sentinel_tilt:0 sentinel_running_scripts:0 sentinel_scripts_queue_length:0 master0:name=mymaster,status=ok,address=127.0.0.1:6379,slaves=2,sentinels=1 #两个从节点我们再去查看sentinel的配置文件,可以看到已经发生变化,从节点已经配置在里面。sentinel monitor mymaster 127.0.0.1 6379 2 sentinel known-slave mymaster 192.168.3.156 6379 sentinel known-slave mymaster 192.168.3.157 6379然后我们在其它的从节点上配置sentinel.conf文件增加下面代码:daemonize yes # 配置master信息 sentinel monitor mymaster 192.168.3.155 6379 2然后启动。再执行info命令可以查看现在已经有三个sentinels节点。master0:name=mymaster,status=ok,address=127.0.0.1:6379,slaves=2,sentinels=3客户端连接既然已经实现高可用为什么不直接直连?高可用涉及的是服务高可用、完成自动的故障转移;故障转移后客户端无法感知将无法保证正常的使用。需要保证的是服务高可用 和 客户端高可用。客户端实现基本原理获取所有的Sentinel的节点和MasterName,遍历Sentinel集合得到一个可用的Sentinel节点。向可用的Sentinel节点发送sentinel的get-master-addr-by-name的请求,参数masterName,获取master节点信息。客户端获取得到master节点后会执行一次role或者role replication来验证是否是master节点。master节点发生变化,sentinel是感知的(所有的故障发现、转移是由sentinel做的)。sentinel怎么通知client的呢?内部是一个发布订阅的模式,client订阅sentinel的某一个频道,该频道里由谁是master的信息,假如由变化sentinel就在频道里publish一条消息,client订阅就可以获取到信息,通过新的master信息进行连接。完整流程图如下:JedisSentinelPool sentinelPool = new JedisSentinelPool(masterName, sentinelSet, poolConfig, timeout); Jedis jedis = null; try { jedis = redisSentinelPool.getResource(); } catch(Exception e) { logger.error(e.getMessage(), e); }finally { if(jedis != null) { jedis.close(); } }jedisJedisSentinelPool不是连接Sentinel节点集合的连接池。本质上还是连接master。只是跟JedisPool进行区分。故障转移演练目前还是ip 192.168.3.155主节点。156和157是从节点,启动了三个sentinel。/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2020/12/31 * @Description: */ public class RedisSentinelTest { private static Logger logger = LoggerFactory.getLogger(RedisSentinelTest.class); public static void main(String[] args) { String masterName = "mymaster"; Set<String> sentinels = new HashSet<>(); sentinels.add("192.168.3.155:26379"); sentinels.add("192.168.3.156:26379"); sentinels.add("192.168.3.157:26379"); JedisSentinelPool jedisSentinelPool = new JedisSentinelPool(masterName, sentinels); int count = 0; while (true) { count++; Jedis jedis = null; try { jedis = jedisSentinelPool.getResource(); int index = new Random().nextInt(10000); String key = "k-" + index; String value = "v-" + index; jedis.set(key, value); if (count % 100 == 0) { logger.info("{} value is {}", key, jedis.get(key)); } TimeUnit.MILLISECONDS.sleep(10); } catch (Exception e) { logger.error(e.getMessage(), e); } finally { if (jedis != null) { jedis.close(); } } } } }我们先正常的启动。11:06:55.050 [main] INFO RedisSentinelTest - k-6041 value is v-6041 11:06:56.252 [main] INFO RedisSentinelTest - k-3086 value is v-3086 11:06:57.467 [main] INFO RedisSentinelTest - k-3355 value is v-3355 11:06:58.677 [main] INFO RedisSentinelTest - k-6767 value is v-6767这个时候我们直接强制停止master节点,也就是155节点。192.168.3.155:6379> info server # Server redis_version:3.0.7 redis_git_sha1:00000000 redis_git_dirty:0 redis_build_id:3fdf3aafcf586962 redis_mode:standalone os:Linux 3.10.0-1127.el7.x86_64 x86_64 arch_bits:64 multiplexing_api:epoll gcc_version:4.8.5 process_id:4129 run_id:3212ce346ed95794f31dc30d87ed2a4020d3b252 tcp_port:6379 uptime_in_seconds:1478 uptime_in_days:0 hz:10 lru_clock:15496249 config_file:/usr/local/redis-3.0.7/redis.conf获得process_id:4129 我们直接kill -9 4129,杀掉此进程之后我们查看是否还存在此进程。ps -ef | grep redis-server | grep 6379发现没有后,我们查看Java控制台。在一定时间后,就完成故障转移。程序还是可以正常执行。Caused by: redis.clients.jedis.exceptions.JedisConnectionException: java.net.ConnectException: Connection refused (Connection refused) at redis.clients.jedis.Connection.connect(Connection.java:207) at redis.clients.jedis.BinaryClient.connect(BinaryClient.java:93) at redis.clients.jedis.BinaryJedis.connect(BinaryJedis.java:1767) at redis.clients.jedis.JedisFactory.makeObject(JedisFactory.java:106) at org.apache.commons.pool2.impl.GenericObjectPool.create(GenericObjectPool.java:868) at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:435) at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:363) at redis.clients.util.Pool.getResource(Pool.java:49) ... 2 common frames omitted Caused by: java.net.ConnectException: Connection refused (Connection refused) at java.net.PlainSocketImpl.socketConnect(Native Method) at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:350) at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:206) at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:188) at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:392) at java.net.Socket.connect(Socket.java:589) at redis.clients.jedis.Connection.connect(Connection.java:184) ... 9 common frames omitted 十二月 31, 2020 11:14:13 上午 redis.clients.jedis.JedisSentinelPool initPool 信息: Created JedisPool to master at 192.168.3.156:6379 11:14:13.146 [main] INFO RedisSentinelTest - k-7996 value is v-7996 11:14:14.339 [main] INFO RedisSentinelTest - k-9125 value is v-9125 11:14:15.597 [main] INFO RedisSentinelTest - k-2589 value is v-2589这样就自动完成故障转移了。主观下线和客观下线主观下线:每个sentinel节点对redis节点失败的看法。sentinel down-after-millseconds masterName timeout每个sentinel节点每秒会对redis节点进行ping,当超过timeout毫秒之后还没得到pong,则认为redis节点下线。客观下线:所有sentinel节点对redis节点失败达成共识。sentinel monitor masterName ip port quorum大于等于quorum个sentinel主观认为redis节点失败下线。通过 sentinel is-master-down-by-addr提出自己认为redis master下线。领导者选举原因:只有sentinel节点完成故障转移选举:通过sentinel is-master-down-by-addr命令希望成功领导者。每个主观下线的节点向其它的sentinel节点发送命令,要求将它设置为领导者。收到命令的sentinel节点如果没有同意过其它节点发送的命令,那么将同意该节点,否则拒绝。如果该sentinel节点发现自己的票数已经超过sentinel集合半数且超过quorum,那么将成为领导者。如果此过程多个sentinel成为领导者,那么将等待一段时间后重新进行选举。故障转移故障转移就是当master宕机,选择一个合适的slave节点来升级为master节点的操作,sentinel会自动完成这个,不需要手动实现。具体步骤如下:在从节点列表中选出一个节点作为新的主节点,选择方法如下:过滤不健康(主观下线、断线)、5秒内没有回复过Sentinel节点、与主节点失联超过down-after-milliseconds设置的。选择slave-priority(从节点优先级)最高的节点列表,如果存在则返回,不存在则继续。选择复制偏移量最大的从节点(复制的最完整),如果存在则返回,不存在则继续。选择runid最小的从节点。sentinel领导者节点会对第一步选出来的从节点执行slaveof no one命令让其成为主节点。sentinel领导者节点会向剩余的从节点发送命令,让它们成为新主节点的从节点,复制规则和parallel-syncs参数有关。sentinel节点集合会将原来的主节点更新为从节点,并保持着对其关注,当其恢复后命令它去复制新的主节点。高可用读写分离我们先了解一下目前从节点的作用:当主节点出现故障时,作为主节点的后备“顶”上来实现故障转移,Redis Sentinel已经实现了该功能的自动化,实现了真正的高可用。扩展主节点的读能力,尤其是在读多写少的场景非常适用。但是目前模型中,从节点并不是高可用的。如果slave-1节点出现故障,首先客户端client-1将与其失联,其次sentinel节点只会对该节点做主观下线,因为Redis Sentinel的故障转移是针对主节点的。所以很多时候,Redis Sentinel中的从节点仅仅是作为主节点一个热备,不让它参与客户端的读操作,就是为了保证整体高可用性,但实际上这种使用方法还是有一些浪费,尤其是在有很多从节点或者确实需要读写分离的场景,所以如何实现从节点的高可用是非常有必要的。思路Redis Sentinel在对各个节点的监控中,如果有对应事件的发生,都会发出相应的事件消息。+switch-master:切换主节点(原来的从节点晋升为主节点)。+convert-to-slave:切换从节点(原来的主节点降级为从节点)。+sdown:主观下线,说明可能某个从节点可能不可用(因为对从节点不会做客观下线),所以在实现客户端时可以采用自身策略来实现类似主观下线的功能。+reboot:重新启动了某个节点,如果它的角色是slave,那么说明添加了某个从节点。所以在设计Redis Sentinel的从节点高可用时,只要能够实时掌握所有从节点的状态,把所有从节点看做一个资源池,无论是上线还是下线从节点,客户端都能及时感知到(将其从资源池中添加或者删除),这样从节点的高可用目标就达到了。
Redis 客户端使用Java 客户端:JedisJedis 是 Redis 官方首选的 Java 客户端开发包。集成了 redis 的一些命令操作,封装了 redis 的 java 客户端。提供了连接池管理。Jedis Maven 依赖包<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>2.9.0</version> <type>jar</type> <scope>compile</scope> </dependency>简单使用/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2020/12/29 * @Description: Redis简单实用 */ public class RedisTest { public static void main(String[] args) { // 1.生成一个Jedis对象,这个对象负责和指定Redis节点进行通信 Jedis jedis = new Jedis("127.0.0.1", 6379); // 2.执行string操作 jedis.set("hello", "world"); String hello = jedis.get("hello"); System.out.println(hello); // world jedis.set("count", "1"); // 自增 jedis.incr("count"); System.out.println(jedis.get("count")); // 2 //3.执行hash操作 jedis.hset("myHash", "f1", "v1"); jedis.hset("myHash", "f2", "v2"); System.out.println(jedis.hgetAll("myHash").toString()); // {f2=v2, f1=v1} //4. list jedis.rpush("myList", "1", "2", "3"); System.out.println(jedis.lrange("myList", 0, -1)); // [1, 2, 3] //5. set jedis.sadd("mySet", "a", "b", "c"); System.out.println(jedis.smembers("mySet")); // [a, c, b] //6. zset jedis.zadd("myzset", 10, "Jack"); jedis.zadd("myzset", 20, "Rose"); jedis.zadd("myzset", 30, "Michelle"); System.out.println(jedis.zrange("myzset", 0, -1)); //[Jack, Rose, Michelle] } }Jedis 连接池使用Jedis 直连Jedis 连接池方案优点缺点直连简单方便,适用于少量长期连接的场景。存在每次新建/关闭 TCP 开销,资源无法控制,存在泄露的可能。Jedis 对象线程不安全。连接池Jedis 预先生成,减低开销使用。连接池的形式保护和控制资源的使用相对于直连,使用相对麻烦,尤其在资源的管理上需要很多参数来保证。一旦规划不合理就会出现问题。/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2020/12/29 * @Description: Redis连接池使用 */ public class RedisPoolTest { // 初始化Jedis连接池,通常来讲JedisPool是单例的. private final static GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig(); private final static JedisPool jedisPool = new JedisPool(poolConfig, "127.0.0.1", 6379); public static void main(String[] args) { Jedis jedis = null; try { jedis = jedisPool.getResource(); jedis.set("hello", "world"); String hello = jedis.get("hello"); System.out.println(hello); // world jedis.set("count", "1"); // 自增 jedis.incr("count"); System.out.println(jedis.get("count")); // 2 } catch (Exception e) { e.printStackTrace(); } finally { if(jedis != null){ //归还资源 jedis.close(); } } } }Redis 其他功能慢查询生命周期生命周期两点说明:慢查询只发生在第 3 阶段。客户端超时不一定是慢查询,但慢查询是客户端超时的一个可能因素。两个配置slowlog-max-len此参数表示慢查询最大保存个数,慢查询日志都是保存在队列中。先进先出。固定长度。保存在内存中(服务器断电会导致慢查询数据丢失)。slowlog-log-slower-than此参数表示慢查询阈值(单位:微妙),默认配置 10000 微妙。slowlog-log-slower-than=0, 表示记录所有命令。slowlog-log-slower-than<0,表示不记录任何命令。动态配置:127.0.0.1:6379> config get slowlog-max-len #查看默认配置 1) "slowlog-max-len" 2) "128" 127.0.0.1:6379> config set slowlog-max-len 1000 #动态修改配置 OK 127.0.0.1:6379> config get slowlog-max-len 1) "slowlog-max-len" 2) "1000" 127.0.0.1:6379> config get slowlog-log-slower-than #查看默认配置 1) "slowlog-log-slower-than" 2) "10000" 127.0.0.1:6379> config set slowlog-log-slower-than 1200 #动态修改配置 OK 127.0.0.1:6379> config get slowlog-log-slower-than 1) "slowlog-log-slower-than" 2) "1200"三个命令slowlog get [n] :获取慢查询队列slowlog len :获取慢查询队列长度slowlog reset : 清空慢查询队列127.0.0.1:6379> slowlog get 10 (empty list or set) 127.0.0.1:6379> slowlog len (integer) 0 127.0.0.1:6379> slowlog reset OK运维经验slowlog-max-len 不要设置的过大,默认 10ms,通常设置 1ms。slowlog-log-slower-than不要设置过小,通常设置 1000 左右。理解命令生命周期。定期持久化慢查询。Pipeline什么是流水线1 次网络命令通信模型批量网络命令通信模型什么是流水线流水线的作用命令N 个命令操作1 次 pipeline(N 个命令)时间N 次网络+N 次命令1 次网络+N 次命令数据量1 条命令N 条命令注意Redis 命令执行时间是微妙级别的。pipeline 每次批量命令条数需要控制(注意网络传输)。Pipeline-Jedis 客户端实现没有使用 Pipeline用时:29707/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2020/12/29 * @Description: */ public class PipelineRedisTest { // 初始化Jedis连接池,通常来讲JedisPool是单例的. private final static GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig(); private final static JedisPool jedisPool = new JedisPool(poolConfig, "47.110.41.15", 6379); public static void main(String[] args) { long start = System.currentTimeMillis(); Jedis jedis = null; try { jedis = jedisPool.getResource(); for (int i = 0; i < 1000; i++) { jedis.hset("hashkey", "field_" + i, "value_" + i); } } catch (Exception e) { e.printStackTrace(); } finally { if (jedis != null) { //归还资源 jedis.close(); } } long end = System.currentTimeMillis(); System.out.println(end - start); //29707 } }使用 Pipeline用时:3161/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2020/12/29 * @Description: */ public class PipelineRedisTest { // 初始化Jedis连接池,通常来讲JedisPool是单例的. private final static GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig(); private final static JedisPool jedisPool = new JedisPool(poolConfig, "47.110.41.15", 6379); public static void main(String[] args) { long start = System.currentTimeMillis(); Jedis jedis = null; try { jedis = jedisPool.getResource(); for (int i = 0; i < 100; i++) { Pipeline pipeline = jedis.pipelined(); for (int j = i * 100; j < (i + 1) * 100; j++) { pipeline.hset("pipelinekey", "field_" + j, "value_" + j); } pipeline.syncAndReturnAll(); } } catch (Exception e) { e.printStackTrace(); } finally { if (jedis != null) { //归还资源 jedis.close(); } } long end = System.currentTimeMillis(); System.out.println(end - start); //3161 } }与原生 M 操作对比mset、mget、hmset、hmget 等都是原子操作。pipeline 命令是非原子操作,但是命令返回顺序能够保证。使用建议注意每次 pipeline 携带的数据量pipeline 每次只能作用在一个 Redis 节点上M 命令操作与 pipeline 的区别mset、mget、hmget、hmset 等命令是:n 次网络时间+n 次命令时间。pipeline 操作命令是:1 次网络时间+n 次命令时间。发布订阅发布者(publisher)订阅者(subscriber)频道(channel)模型多个订阅者订阅一个频道发布者 publisher 只要发布了消息,所有订阅了这个频道 channel 的订阅者都能收到消息。一个订阅者可以订阅多个频道一个订阅者可以订阅多个频道,当发布者发布不同消息到多个频道,订阅者可以接受多个频道消息。发布订阅与消息队列Redis 还可以用作消息队列,所有消息订阅者是去抢队列里面的消息。相关 APIsubscribe首先订阅频道。127.0.0.1:6379> subscribe baidu Reading messages... (press Ctrl-C to quit) 1) "subscribe" 2) "baidu" 3) (integer) 1 1) "message" 2) "baidu" 3) "hello" 1) "message" 2) "baidu" 3) "world" 1) "message" 2) "baidu" 3) "java" 1) "message" 2) "baidu" 3) "python" 1) "message" 2) "baidu" 3) "go"publish发送消息。127.0.0.1:6379> publish baidu hello (integer) 1 127.0.0.1:6379> publish baidu world (integer) 1 127.0.0.1:6379> publish baidu java (integer) 1 127.0.0.1:6379> publish baidu python (integer) 1 127.0.0.1:6379> publish baidu go (integer) 1使用 unsubscribe 取消订阅频道127.0.0.1:6379> unsubscribe baidu 1) "unsubscribe" 2) "baidu" 3) (integer) 0其它 APIpsubscribe [pattern...] :订阅指定规则的频道。punsubscribe [pattern...] :退订指定的模式。pubsub channels:列出至少有一个订阅者的频道。pubsub numsub [channel...]:列出给定频道的订阅者数量。Bitmap位图位图并不是一种数据结构,其实就是一种普通的字符串,也可以说是 byte 数组。b 的 ASCII=98 对应的二进制为:01100010i 的 ASCII=105 对应的二进制为:01101001g 的 ASCII=103 对应的二进制为:01100111127.0.0.1:6379> set hello big OK 127.0.0.1:6379> getbit hello 0 (integer) 0 127.0.0.1:6379> getbit hello 1 (integer) 1setbit命令对 key 所储存的字符串值,设置或清除指定偏移量上的位(bit)。位的设置或清除取决于 value 参数,可以是 0 也可以是 1 。当 key 不存在时,自动生成一个新的字符串值。字符串会进行伸展(grown)以确保它可以将 value 保存在指定的偏移量上。当字符串值进行伸展时,空白位置以 0 填充。offset 参数必须大于或等于 0 ,小于 2^32 (bit 映射被限制在 512 MB 之内)。返回指定偏移量原来储存的位。getbit命令对 key 所储存的字符串值,获取指定偏移量上的位(bit)。当 offset 比字符串值的长度大,或者 key 不存在时,返回 0 。bitcount命令计算给定字符串中,被设置为 1 的比特位的数量。一般情况下,给定的整个字符串都会被进行计数,通过指定额外的 start 或 end 参数,可以让计数只在特定的位上进行。不存在的 key 被当成是空字符串来处理,因此对一个不存在的 key 进行 BITCOUNT 操作,结果为 0 。bitop命令对一个或多个保存二进制位的字符串 key 进行位元操作,并将结果保存到 destkey 上。operation 可以是 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种:BITOP AND destkey key [key ...] ,对一个或多个 key 求逻辑并,并将结果保存到 destkey 。BITOP OR destkey key [key ...] ,对一个或多个 key 求逻辑或,并将结果保存到 destkey 。BITOP XOR destkey key [key ...] ,对一个或多个 key 求逻辑异或,并将结果保存到 destkey 。BITOP NOT destkey key ,对给定 key 求逻辑非,并将结果保存到 destkey 。除了 NOT 操作之外,其他操作都可以接受一个或多个 key 作为输入。返回保存到 destkey 的字符串的长度,和输入 key 中最长的字符串长度相等。bitpos命令返回位图中第一个值为 bit 的二进制位的位置。在默认情况下, 命令将检测整个位图, 但用户也可以通过可选的 start 参数和 end 参数指定要检测的范围。独立用户统计使用 set 和 bitmap1 亿用户,5 千万独立数据类型每个 UserId 占用空间需要存储的用户量全部内存量set32 位50,000,00032 位 * 50,000,000 = 200MBbitmap1 位100,000,0001 位 * 100,000,000 = 12.5MB但是如果只有 10 万独立用户的话,结果就不一样了。数据类型每个 UserId 占用空间需要存储的用户量全部内存量set32 位100,00032 位 * 100,000 = 4MBbitmap1 位100,000,0001 位 * 100,000,000 = 12.5MB使用经验type=string 类型,最大 512MB。注意 setbit 时的偏移量,可能有较大耗时。位图不是绝对好,合理的场景使用合理的技术。HyperLogLogRedis HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基 数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。但是,因为 HyperLogLog 只会根据输入元素来计算基数,而不会储存输入元素本身,所以 HyperLogLog 不能像集合那样,返回输入的各个元素。PFADD命令将任意数量的元素添加到指定的 HyperLogLog 里面。作为这个命令的副作用, HyperLogLog 内部可能会被更新, 以便反映一个不同的唯一元素估计数量(也即是集合的基数)。如果 HyperLogLog 估计的近似基数(approximated cardinality)在命令执行之后出现了变化, 那么命令返回 1 , 否则返回 0 。 如果命令执行时给定的键不存在, 那么程序将先创建一个空的 HyperLogLog 结构, 然后再执行命令。如果给定键已经是一个 HyperLogLog , 那么这种调用不会产生任何效果。但如果给定的键不存在, 那么命令会创建一个空的 HyperLogLog , 并向客户端返回 1 。如果 HyperLogLog 的内部储存被修改了, 那么返回 1 , 否则返回 0 。API:PFADD key element [element …]127.0.0.1:6379> pfadd data 'java' 'python' 'go' (integer) 1 127.0.0.1:6379> pfcount data (integer) 3PFCOUNT命令当 PFCOUNT key [key …] 命令作用于单个键时, 返回储存在给定键的 HyperLogLog 的近似基数, 如果键不存在, 那么返回 0 。当 PFCOUNT key [key …] 命令作用于多个键时, 返回所有给定 HyperLogLog 的并集的近似基数, 这个近似基数是通过将所有给定 HyperLogLog 合并至一个临时 HyperLogLog 来计算得出的。命令返回的可见集合(observed set)基数并不是精确值, 而是一个带有 0.81% 标准错误(standard error)的近似值。返回给定 HyperLogLog 包含的唯一元素的近似数量。API:PFCOUNT key [key …]127.0.0.1:6379> pfadd data 'java' 'python' 'go' (integer) 1 127.0.0.1:6379> pfcount data (integer) 3PFCOUNT命令将多个 HyperLogLog 合并(merge)为一个 HyperLogLog , 合并后的 HyperLogLog 的基数接近于所有输入 HyperLogLog 的可见集合(observed set)的并集。合并得出的 HyperLogLog 会被储存在 destkey 键里面, 如果该键并不存在, 那么命令在执行之前, 会先为该键创建一个空的 HyperLogLog 。字符串回复:返回 OK 。API:PFMERGE destkey sourcekey [sourcekey …]127.0.0.1:6379> PFADD nosql "Redis" "MongoDB" "Memcached" (integer) 1 127.0.0.1:6379> PFADD RDBMS "MySQL" "MSSQL" "PostgreSQL" (integer) 1 127.0.0.1:6379> PFMERGE databases nosql RDBMS OK 127.0.0.1:6379> pfcount databases (integer) 6使用经验是否能容忍错误:HyperLogLog 错误率为:0.81%是否需要单条数据:如果需要单条数据,就不适合使用 HyperLogLog。GEOGEO 主要用于存储地理位置信息,并对存储的信息进行操作。GEOADD命令将给定的空间元素(纬度、经度、名字)添加到指定的键里面。 这些数据会以有序集合的形式被储存在键里面。有效的经度介于 -180 度至 180 度之间。有效的纬度介于 -85.05112878 度至 85.05112878 度之间。当用户尝试输入一个超出范围的经度或者纬度时, GEOADD 命令将返回一个错误。返回新添加到键里面的空间元素数量, 不包括那些已经存在但是被更新的元素。GEOPOS命令从键里面返回所有给定位置元素的位置(经度和纬度)。因为 GEOPOS 命令接受可变数量的位置元素作为输入, 所以即使用户只给定了一个位置元素, 命令也会返回数组回复。GEOPOS 命令返回一个数组, 数组中的每个项都由两个元素组成: 第一个元素为给定位置元素的经度, 而第二个元素则为给定位置元素的纬度。 当给定的位置元素不存在时, 对应的数组项为空值。GEODIST命令返回两个给定位置之间的距离。如果两个位置之间的其中一个不存在, 那么命令返回空值。指定单位的参数 unit 必须是以下单位的其中一个:m 表示单位为米。km 表示单位为千米。mi 表示单位为英里。ft 表示单位为英尺。如果用户没有显式地指定单位参数, 那么 GEODIST 默认使用米作为单位。GEODIST 命令在计算距离时会假设地球为完美的球形, 在极限情况下, 这一假设最大会造成 0.5% 的误差。计算出的距离会以双精度浮点数的形式被返回。 如果给定的位置元素不存在, 那么命令返回空值。GEORADIUS命令以给定的经纬度为中心, 返回键包含的位置元素当中, 与中心的距离不超过给定最大距离的所有位置元素。范围可以使用以下其中一个单位:m 表示单位为米。km 表示单位为千米。mi 表示单位为英里。ft 表示单位为英尺。在给定以下可选项时, 命令会返回额外的信息:WITHDIST : 在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回。 距离的单位和用户给定的范围单位保持一致。WITHCOORD : 将位置元素的经度和维度也一并返回。WITHHASH : 以 52 位有符号整数的形式, 返回位置元素经过原始 geohash 编码的有序集合分值。 这个选项主要用于底层应用或者调试, 实际中的作用并不大。命令默认返回未排序的位置元素。 通过以下两个参数, 用户可以指定被返回位置元素的排序方式:ASC : 根据中心的位置, 按照从近到远的方式返回位置元素。DESC : 根据中心的位置, 按照从远到近的方式返回位置元素。在默认情况下, GEORADIUS 命令会返回所有匹配的位置元素。 虽然用户可以使用 COUNT <count> 选项去获取前 N 个匹配元素, 但是因为命令在内部可能会需要对所有被匹配的元素进行处理, 所以在对一个非常大的区域进行搜索时, 即使只使用 COUNT 选项去获取少量元素, 命令的执行速度也可能会非常慢。 但是从另一方面来说, 使用 COUNT 选项去减少需要返回的元素数量, 对于减少带宽来说仍然是非常有用的。返回值GEORADIUS 命令返回一个数组, 具体来说:在没有给定任何 WITH 选项的情况下, 命令只会返回一个像 ["New York","Milan","Paris"] 这样的线性(linear)列表。在指定了 WITHCOORD 、 WITHDIST 、 WITHHASH 等选项的情况下, 命令返回一个二层嵌套数组, 内层的每个子数组就表示一个元素。在返回嵌套数组时, 子数组的第一个元素总是位置元素的名字。 至于额外的信息, 则会作为子数组的后续元素, 按照以下顺序被返回:以浮点数格式返回的中心与位置元素之间的距离, 单位与用户指定范围时的单位一致。geohash 整数。由两个元素组成的坐标,分别为经度和纬度。
什么是 RedisRedis 是完全开源的,遵守 BSD 协议,是一个高性能的 key-value 数据库。Redis 与其他 key - value 缓存产品有以下三个特点:Redis 支持数据的持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用。Redis 不仅仅支持简单的 key-value 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Redis 支持数据的备份,即 master-slave 模式的数据备份。Redis 的特性特性一:速度快官网声称可以达到:10 万 QPS数据是存在内存中的。使用 C 语言编写。单线程模型。Redis 速度快的主要原因就是内存从快到慢依次是:Register 寄存器L1 Cache 一级缓存L2 Cache 二级缓存Main Memory 主存Local Disk 本地磁盘Remote Disk 远程磁盘类型每秒读写次数随机读写延迟访问带宽内存千万级80ns5GBSSD 盘350000.1-02ms100~300MB机械盘100 左右10ms100MB 左右特性二:持久化Redis 所有数据保存在内存当中,对数据的更新将以异步方式保存到磁盘上。Redis 支持两种持久化方式:RDBAOF特性三:多种数据结构另外在 Redis 从 2.2.0 版本开始新增了三种数据结构:Bitmaps 和 HyperLogLog 其实本质上是字符串,并不算是真实的数据结构,而 GEO 本质上有序集合实现的。• BitMaps:可以实现对位的操作。可以把 Bitmaps 想象成一个以位为单位数组,数组中的每个单元只能存 0 或者 1,数组的下标在 bitmaps 中叫做偏移量。单个 bitmaps 的最大长度是 512MB,即 2^32 个比特位。• HyperLogLog:超小内存唯一值计数。特点是速度快,占用空间小。• GEO:地理信息定位。特性四:支持多种客户端语言JavaPHPPythonRubyLuaNode...特性五:功能丰富提供发布订阅功能。支持事务。支持 Lua 脚本。Pipeline。特性六:"简单"Redis 的单机核心代码只有 23.000 行左右。Redis 不依赖外部库。Redis 是单线程模型。特性七:主从复制主从复制是 Redis 保证高可用的基础Redis 提供了复制功能,实现了多个相同数据的 Redis 副本。特性八:高可用、分布式Redis 从 2.8 版本正式提供了高可用实现 Redis Sentinel,它能够保证 Redis 节点的故障发现和故障自动转移。Redis 从 3.0 版本正式提供了分布式实现 Redis Cluster,它是 Redis 真正的分布式实现,提供了高可用、读写和容量的扩展性。Redis 典型应用场景缓存系统计数器消息队列排行榜社交网络,例如:粉丝数、关注数、共同关注、时间轴列表实时系统,Redis 使用 Bitmap 位图来实现布隆过滤功能,例如:垃圾邮件过滤实时处理安装 Redis(Linux)wget http://download.redis.io/releases/redis-3.0.7.tar.gz tar -xzf redis-3.0.7.tar.gz ln -s redis-3.0.7 redis cd redis make && make installRedis 可执行文件说明进入 src 目录下:redis-server :Redis 服务器redis-cli :Redis 命令行客户端redis-benchmark :Redis 基准测试工具redis-check-aof :AOF 文件修复工具redis-check-dump:RDB 文件检查工具redis-sentinel :Sentinel 服务器三种启动方式最简启动:可以直接使用redis-server命令以默认参数进行启动。动态启动:redis-server –port 6380配置文件启动: redis-server configPath验证:ps -ef | grep redis:(检查 redis 进程是否启动)netstat -antpl | grep redis:(检查 redis 服务端口是否存在)redis-cli -h ip -p port ping:(检查 redis 客户端到 redis 服务端连通性)三种启动方式比较生产环境选择配置启动。单机多实例配置文件可以用端口区分。启动 Redis使用下面命令进行启动:redis-server连接 Redisredis-cli -h 127.0.0.1 -p 6379连接成功后我们就可以做一些操作。127.0.0.1:6379> set hello world OK 127.0.0.1:6379> get hello "world" 127.0.0.1:6379> ping PONG 127.0.0.1:6379>exitRedis API 使用和理解通用命令KEYSKEYS [pattern]查找所有符合给定模式 pattern 的 key , 比如说:KEYS * 匹配数据库中所有 key 。KEYS h?llo 匹配 hello , hallo 和 hxllo 等。KEYS h*llo 匹配 hllo 和 heeeeello 等。KEYS h[ae]llo 匹配 hello 和 hallo ,但不匹配 hillo 。keys 是 O(n)复杂度所以一般不在生产环境使用,因为 Redis 是单线程架构,使用不当会导致 Redis 阻塞。DBSIZE返回当前数据库的 key 的数量。在 Redis 内部有一个计数器来维护 Key 的数量,所以它的复杂度是 O(1)。EXISTS检查给定 key 是否存在。若 key 存在,返回 1 ,否则返回 0 。DEL删除给定的一个或多个 key 。返回被删除 key 的数量。EXPIRE为给定 key 设置生存时间,当 key 过期时(生存时间为 0 ),它会被自动删除。设置成功返回 1 。 当 key 不存在或者不能为 key 设置生存时间时,返回 0 。TTL以秒为单位,返回给定 key 的剩余生存时间(TTL, time to live)。当 key 不存在时,返回 -2 。 当 key 存在但没有设置剩余生存时间时,返回 -1 。 否则,以秒为单位,返回 key 的剩余生存时间。PERSIST移除给定 key 的生存时间,并变为永不过期的key。当生存时间移除成功时,返回 1 . 如果 key 不存在或 key 没有设置生存时间,返回 0 。TYPE返回 key 所储存的值的类型。返回值none (key 不存在)string (字符串)list (列表)set (集合)zset (有序集)hash (哈希表)stream (流)数据结构和内部编码redis 对外展现五种数据类型:stringhashsetzsetlist每种数据结构,redis 都提供了不同的内部编码实现方式(内部编码可以通过object encoding key查看),以便使用不同的场景。127.0.0.1:6379> object encoding hello "embstr"stringraw:大于 39 个字节的字符串,用简单动态字符串(SDS)来保存,将这个字符串的对象编码设置为 raw。int:8 个字节的长整型,如果一个字符串保存的类型是整数值,并且这个值可以用 long 类型来表示,name 字符串对象会将整数值保存在字符串对象结构的 ptr 属性里面,并将字符串对象的编码设置为 int。embstr:小于等于 39 个字节的字符串,embstr 编码是专门用于保存短字符串的优化编码方式。embstr 相较于 raw 有以下好处:embstr 编码将创建字符串对象所需的空间分配的次数从 raw 编码的两次降低为一次。释放 embstr 编码的字符串对象只需要调用一次内存释放函数,而释放 raw 编码对象的字符串对象需要调用两次内存释放函数因为 embstr 编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起 raw 编码的字符串对象能更好地利用缓存带来的优势。hashziplist(压缩列表):当哈希类型元素小于has-max-ziplist-entries配置(默认 512 个),同时所有值都小于hash-max-ziplist-value配置(默认 64 个字节)时,redis 会使用 ziplist 作为哈希的内部实现。ziplist 使用更加紧凑的结构实现多个元素的连续存储,所以在节省内存方面比 hashtable 更加优秀。hashtable(哈希表):当哈希类型无法满足 ziplist 的条件时,redis 会使用 hashtable 作为哈希的内部实现。因为 ziplist 的读写效率会下降,而 hashtable 的读写时间复杂度为 O(1)。listziplist(压缩列表):当列表类型元素个数小于 hash-max-ziplist-entries 配置(默认 512 个)同时所有值都小于 hash-max-ziplist-value 配置(默认 64 个字节)时,Redis 会使用 ziplist 作为列表的内部实现。linkedlist(链表):当列表类型无法满足条件的时候,redis 会使用 linkedlist 作为列表的内部实现。setintset(整数集合):当集合中的元素都是整数且元素个数小于set-max-intset-entries配置(默认 512 个)是,redis 会选 intset 作为集合的内部实现,从而减少内存使用。hashtable(哈希表):当集合元素无法满足 intset 的条件时,redis 会使用 hashtable 作为集合的内部实现。zsetziplist:当有序集合的元素个数小于zset-max-ziplist-entries配置(默认 128 个)同时每个元素的值小于zset-max-ziplist-value配置(默认 64 个字节)时,Redis 会用 ziplist 来作为有序集合的内部实现,ziplist 可以有效减少内存使用。skiplist(跳跃表):当 ziplist 条件不满足的时候,有序集合会使用 skiplist 作为内部 实现,因为 ziplist 的读写效率会下降字符串类型特点key 是唯一的,不能重复。value 数据类型可以是多种,比如:字符串、数字、二进制,只是内部把数字、二进制数据转化为字符串。value 也可以是一个 JSON 字符串。value 还可以是序列化对象。value 可以存储最大的数据大小为:512MB。常用的字符串场景缓存计数器分布式锁GET返回与键 key 相关联的字符串值。如果键 key 不存在, 那么返回特殊值 nil ; 否则, 返回键 key 的值。如果键 key 的值并非字符串类型, 那么返回一个错误, 因为 GET 命令只能用于字符串值。SET将字符串值 value 关联到 key 。如果 key 已经持有其他值, SET 就覆写旧值, 无视类型。当 SET 命令对一个带有生存时间(TTL)的键进行设置之后, 该键原有的 TTL 将被清除。DEL删除给定的一个或多个 key 。返回被删除 key 的数量。INCR为键 key 储存的数字值加上一。如果键 key 不存在, 那么它的值会先被初始化为 0 , 然后再执行 INCR 命令。如果键 key 储存的值不能被解释为数字, 那么 INCR 命令将返回一个错误。本操作的值限制在 64 位(bit)有符号数字表示之内。INCR 命令会返回键 key 在执行加一操作之后的值。DECR为键 key 储存的数字值减去一。如果键 key 不存在, 那么键 key 的值会先被初始化为 0 , 然后再执行 DECR 操作。如果键 key 储存的值不能被解释为数字, 那么 DECR 命令将返回一个错误。本操作的值限制在 64 位(bit)有符号数字表示之内。DECR 命令会返回键 key 在执行减一操作之后的值。INCRBY为键 key 储存的数字值加上增量 increment 。如果键 key 不存在, 那么键 key 的值会先被初始化为 0 , 然后再执行 INCRBY 命令。如果键 key 储存的值不能被解释为数字, 那么 INCRBY 命令将返回一个错误。本操作的值限制在 64 位(bit)有符号数字表示之内。在加上增量 increment 之后, 返回键 key 当前的值。DECRBY将键 key 储存的整数值减去减量 decrement 。如果键 key 不存在, 那么键 key 的值会先被初始化为 0 , 然后再执行 DECRBY 命令。如果键 key 储存的值不能被解释为数字, 那么 DECRBY 命令将返回一个错误。本操作的值限制在 64 位(bit)有符号数字表示之内。DECRBY 命令会返回键在执行减法操作之后的值。SETNX只在键 key 不存在的情况下, 将键 key 的值设置为 value 。若键 key 已经存在, 则 SETNX 命令不做任何动作。SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。命令在设置成功时返回 1 , 设置失败时返回 0 。SETEX将键 key 的值设置为 value , 并将键 key 的生存时间设置为 seconds 秒钟。如果键 key 已经存在, 那么 SETEX 命令将覆盖已有的值。SETEX 命令的效果和以下两个命令的效果类似:SET key value EXPIRE key seconds # 设置生存时间SETEX 和这两个命令的不同之处在于 SETEX 是一个原子性(atomic)操作, 它可以在同一时间内完成设置值和设置过期时间这两个操作, 因此 SETEX 命令在储存缓存的时候非常实用。命令在设置成功时返回 OK 。 当 seconds 参数不合法时, 命令将返回一个错误。API:SETEX key seconds value127.0.0.1:6379> setex jack 60 nihao OKMGET返回给定的一个或多个字符串键的值。如果给定的字符串键里面, 有某个键不存在, 那么这个键的值将以特殊值 nil 表示。MGET 命令将返回一个列表, 列表中包含了所有给定键的值。MSET同时为多个键设置值。如果某个给定键已经存在, 那么 MSET 将使用新值去覆盖旧值。MSET 是一个原子性(atomic)操作, 所有给定键都会在同一时间内被设置, 不会出现某些键被设置了但是另一些键没有被设置的情况。MSET 命令总是返回 OK 。MSETNX当且仅当所有给定键都不存在时, 为所有给定键设置值。即使只有一个给定键已经存在, MSETNX 命令也会拒绝执行对所有键的设置操作。MSETNX 是一个原子性(atomic)操作, 所有给定键要么就全部都被设置, 要么就全部都不设置, 不可能出现第三种状态。当所有给定键都设置成功时, 命令返回 1 ; 如果因为某个给定键已经存在而导致设置未能成功执行, 那么命令返回 0 。API:MSETNX key value [key value …]127.0.0.1:6379> msetnx jack1 h jack2 e jack3 l #对不存在的key设置value (integer) 1 127.0.0.1:6379> msetnx jack1 h jack2 e jack3 l #对存在的key设置value (integer) 0 127.0.0.1:6379> mget jack1 jack2 jack3 #批量获取key 1) "h" 2) "e" 3) "l" 127.0.0.1:6379>GETSET将键 key 的值设为 value , 并返回键 key 在被设置之前的旧值。如果键 key 没有旧值, 也即是说, 键 key 在被设置之前并不存在, 那么命令返回 nil 。当键 key 存在但不是字符串类型时, 命令返回一个错误。APPEND如果键 key 已经存在并且它的值是一个字符串, APPEND 命令将把 value 追加到键 key 现有值的末尾。如果 key 不存在, APPEND 就简单地将键 key 的值设为 value , 就像执行 SET key value 一样。追加 value 之后, 返回键 key 的值的长度。STRLEN返回键 key 储存的字符串值的长度。STRLEN 命令返回字符串值的长度。当键 key 不存在时, 命令返回 0 。当 key 储存的不是字符串值时, 返回一个错误。INCRBYFLOAT为键 key 储存的值加上浮点数增量 increment 。如果键 key 不存在, 那么 INCRBYFLOAT 会先将键 key 的值设为 0 , 然后再执行加法操作。如果命令执行成功, 那么键 key 的值会被更新为执行加法计算之后的新值, 并且新值会以字符串的形式返回给调用者。当以下任意一个条件发生时, 命令返回一个错误:键 key 的值不是字符串类型(因为 Redis 中的数字和浮点数都以字符串的形式保存,所以它们都属于字符串类型);键 key 当前的值或者给定的增量 increment 不能被解释(parse)为双精度浮点数。在加上增量 increment 之后, 返回键 key 的值。GETRANGE返回键 key 储存的字符串值的指定部分, 字符串的截取范围由 start 和 end 两个偏移量决定 (包括 start 和 end 在内)。负数偏移量表示从字符串的末尾开始计数, -1 表示最后一个字符, -2 表示倒数第二个字符, 以此类推。GETRANGE 命令会返回字符串值的指定部分。SETRANGE从偏移量 offset 开始, 用 value 参数覆写键 key 储存的字符串值。SETRANGE 命令会返回被修改之后, 字符串值的长度。Hash 类型哈希类型也是 key-value 结构,key 是字符串类型,其 value 分为两个部分:field 和 value。其中 field 部分代表属性,value 代表属性对应的值。例如上图中,user:1:info为 key,name,age,Date为 user 这个 key 的一些属性,value 是属性对应的值。在 hash 中,可以为 key 添加一个新的属性和新的值。比如使用下面的命令向user:1:info这个 key 添加一个新属性 viewCounter,属性对应的值为 100。hset user:1:info viewCounter 100特点:适合存储对象,并且可以像数据库中 update 一个属性一样只修改某一项属性值。field 不能相同,value 可以相同。适用场景:hash 变更的数据 user name age,尤其是是用户信息之类的,经常变动的信息。hash 更适合于对象的 存储,String 更加适合字符串存储!HGETHGET 命令在默认情况下返回给定域的值。如果给定域不存在于哈希表中, 又或者给定的哈希表并不存在, 那么命令返回 nil 。HSET将哈希表 hash 中域 field 的值设置为 value 。如果给定的哈希表并不存在, 那么一个新的哈希表将被创建并执行 HSET 操作。如果域 field 已经存在于哈希表中, 那么它的旧值将被新值 value 覆盖。当 HSET 命令在哈希表中新创建 field 域并成功为它设置值时, 命令返回 1 ; 如果域 field 已经存在于哈希表, 并且 HSET 命令成功使用新值覆盖了它的旧值, 那么命令返回 0 。HSETNX当且仅当域 field 尚未存在于哈希表的情况下, 将它的值设置为 value 。如果给定field已经存在于哈希表当中, 那么命令将放弃执行设置操作。如果哈希表 hash 不存在, 那么一个新的哈希表将被创建并执行 HSETNX 命令。HSETNX 命令在设置成功时返回 1 , 在给定域已经存在而放弃执行设置操作时返回 0 。HDEL删除哈希表 key 中的一个或多个指定域,不存在的域将被忽略。返回被成功移除的域的数量,不包括被忽略的域。HEXISTS检查给定域 field 是否存在于哈希表 hash 当中。HEXISTS 命令在给定域存在时返回 1 , 在给定域不存在时返回 0 。HLEN返回哈希表 key 中field的数量。返回哈希表中field的数量。当 key 不存在时,返回 0 。HMGET返回哈希表 key 中,一个或多个给定域的值。如果给定的域不存在于哈希表,那么返回一个 nil 值。因为不存在的 key 被当作一个空哈希表来处理,所以对一个不存在的 key 进行 HMGET 操作将返回一个只带有 nil 值的表。HMSET同时将多个 field-value (域-值)对设置到哈希表 key 中。此命令会覆盖哈希表中已存在的域。如果 key 不存在,一个空哈希表被创建并执行 HMSET 操作。如果命令执行成功,返回 OK 。当 key 不是哈希表(hash)类型时,返回一个错误。HGETALL返回哈希表 key 中,所有的域和值。在返回值里,紧跟每个域(field)之后是域的值(value),所以返回值的长度是哈希表大小的两倍。HVALS返回哈希表 key 中所有field的值。当 key 不存在时,返回一个空表。HKEYS返回哈希表 key 中的所有field。当 key 不存在时,返回一个空表。HINCRBY为哈希表 key 中的域 field 的值加上增量 increment 。增量也可以为负数,相当于对给定域进行减法操作。如果 key 不存在,一个新的哈希表被创建并执行 HINCRBY 命令。如果域 field 不存在,那么在执行命令前,域的值被初始化为 0 。对一个储存字符串值的域 field 执行 HINCRBY 命令将造成一个错误。本操作的值被限制在 64 位(bit)有符号数字表示之内。最终返回哈希表 key 中域 field 的新值。List 类型特点:链表(双向链表)可演化成栈,队列,在两边插入或者改动值,效率高。有序。可以重复。左右两边插入弹出。适用场景:最新消息排行等功能(比如朋友圈的时间线)。消息队列。RPUSH将一个或多个值 value 插入到列表 key 的表尾(最右边)。如果有多个 value 值,那么各个 value 值按从左到右的顺序依次插入到表尾。如果 key 不存在,一个空列表会被创建并执行 RPUSH 操作。当 key 存在但不是列表类型时,返回一个错误。执行 RPUSH 操作后,返回列表的长度。LPUSH将一个或多个值 value 插入到列表 key 的表头如果有多个 value 值,那么各个 value 值按从左到右的顺序依次插入到表头。如果 key 不存在,一个空列表会被创建并执行 LPUSH 操作。当 key 存在但不是列表类型时,返回一个错误。执行 LPUSH 操作后,返回列表的长度。LINSERT将值 value 插入到列表 key 当中,位于值 pivot 之前或之后。当 pivot 不存在于列表 key 时,不执行任何操作。当 key 不存在时, key 被视为空列表,不执行任何操作。如果 key 不是列表类型,返回一个错误。如果命令执行成功,返回插入操作完成之后,列表的长度。如果没有找到 pivot ,返回 -1 。 如果 key 不存在或为空列表,返回 0 。LPOP移除并返回列表 key 的头元素。执行成功返回列表的头元素。 当 key 不存在时,返回 nil 。RPOP移除并返回列表 key 的尾元素。执行成功返回列表的尾元素。 当 key 不存在时,返回 nil 。LREM根据参数 count 的值,移除列表中与参数 value 相等的元素。count 的值可以是以下几种:count > 0 : 从表头开始向表尾搜索,移除与 value 相等的元素,数量为 count 。count < 0 : 从表尾开始向表头搜索,移除与 value 相等的元素,数量为 count 。count = 0 : 移除表中所有与 value 相等的值。返回被移除元素的数量。 因为不存在的 key 被视作空表(empty list),所以当 key 不存在时,返回 0 。LTRIM对一个列表进行修剪,也就是说,让列表只保留指定区间内的元素,不在指定区间之内的元素都将被删除。当 key 不是列表类型时,返回一个错误。需要注意的是:如果 stop 下标比 end 下标还要大,Redis 将 stop 的值设置为 end 。下标从 0 开始。命令执行成功时,返回 ok 。LRANGE返回列表 key 中指定区间内的元素,区间以偏移量 start 和 stop 指定。需要注意的是:如果 stop 下标比 end 下标还要大,Redis 将 stop 的值设置为 end 。下标从 0 开始。包含start和stop。命令执行成功时,返回指定区间内的元素。LINDEX返回列表 key 中,下标为 index 的元素。以 0 表示列表的第一个元素,以 1 表示列表的第二个元素,以此类推。也可以使用负数下标,以 -1 表示列表的最后一个元素, -2 表示列表的倒数第二个元素,以此类推。LLEN返回列表 key 的长度。如果 key 不存在,则 key 被解释为一个空列表,返回 0 .如果 key 不是列表类型,返回一个错误。LSET将列表 key 下标为 index 的元素的值设置为 value 。当 index 参数超出范围,或对一个空列表( key 不存在)进行 LSET 时,返回一个错误。操作成功返回 ok ,否则返回错误。BLPOPBLPOP 是列表的阻塞式(blocking)弹出。当给定列表内没有任何元素可供弹出的时候,连接将被阻塞,直到等待超时或发现可弹出元素为止。当存在多个给定 key 时,按给定 key 参数排列的先后顺序,依次检查各个列表。超时参数 timeout 接受一个以秒为单位的数字作为值。超时参数设为 0 表示阻塞时间可以无限期延长(block indefinitely) 。API:BLPOP key [key …] timeout127.0.0.1:6379> blpop list1 list2 0 # 无限期阻塞 1) "list1" # 用另一个连接 lpush list1 a 2) "a" # 被弹出的值 (30.31s)BLPOP 保证返回的元素来自第一个非空的列表 ,因为它是按”查找 list1 -> 查找 list2 这样的顺序,第一个找到的非空列表。如果列表为空,返回一个 nil 。 否则,返回一个含有两个元素的列表,第一个元素是被弹出元素所属的 key ,第二个元素是被弹出元素的值。BRPOP 和 BLPOP 命令正好相反,就不演示了。List 的小技巧LPUSH + LPOP = StackLPUSH + RPOP = QueueLPUSH +BRPOP = Message QueueSet 类型左边为 key 是字符串类型,右边为 values,可以将一些字符串进行一些组合,可以向 value 中添加或者删除一个元素。特点:哈希表实现,元素不重复。添加、删除,查找的复杂度都是 O(1)。为集合提供了求交集、并集、差集等操作。无序。适用场景:共同好友。利用唯一性,统计访问网站的所有独立 ip。好友推荐时,根据 tag 求交集,大于某个阈值就可以推荐。SADD将一个或多个 member 元素加入到集合 key 当中,已经存在于集合的 member 元素将被忽略。假如 key 不存在,则创建一个只包含 member 元素作成员的集合。当 key 不是集合类型时,返回一个错误。返回被添加到集合中的新元素的数量,不包括被忽略的元素。SREM移除集合 key 中的一个或多个 member 元素,不存在的 member 元素会被忽略。当 key 不是集合类型,返回一个错误。返回被成功移除的元素的数量,不包括被忽略的元素。SCARD返回集合 key 的基数(集合中元素的数量)。集合的基数。 当 key 不存在时,返回 0 。API:SCARD key127.0.0.1:6379> sadd myset a b c d e f (integer) 6 127.0.0.1:6379> scard myset (integer) 6SISMEMBER判断 member 元素是否集合 key 的成员。如果 member 元素是集合的成员,返回 1 。 如果 member 元素不是集合的成员,或 key 不存在,返回 0 。API:SISMEMBER key member127.0.0.1:6379> sismember myset a #存在 (integer) 1 127.0.0.1:6379> sismember myset z #不存在 (integer) 0SRANDMEMBER如果命令执行时,只提供了 key 参数,那么返回集合中的一个随机元素。从 Redis 2.6 版本开始, 接受可选的 count 参数:如果 count 为正数,且小于集合基数,那么命令返回一个包含 count 个元素的数组,数组中的元素各不相同。如果 count 大于等于集合基数,那么返回整个集合。如果 count 为负数,那么命令返回一个数组,数组中的元素可能会重复出现多次,而数组的长度为 count 的绝对值。只提供 key 参数时,返回一个元素;如果集合为空,返回 nil 。 如果提供了 count 参数,那么返回一个数组;如果集合为空,返回空数组。API:SRANDMEMBER key [count]127.0.0.1:6379> srandmember myset #不填count 随机返回一个 "b" 127.0.0.1:6379> srandmember myset 2 # count为正数 1) "e" 2) "f" 127.0.0.1:6379> srandmember myset -2 # count为负数 1) "e" 2) "a" 127.0.0.1:6379> srandmember myset -2 # count为负数 1) "f" 2) "f"SMEMBERS返回集合 key 中的所有成员。不存在的 key 被视为空集合。API:SMEMBERS key127.0.0.1:6379> smembers myset 1) "a" 2) "e" 3) "d" 4) "f" 5) "c" 6) "b"SPOP移除并返回集合中的一个随机元素。返回被移除的随机元素。 当 key 不存在或 key 是空集时,返回 nil 。API:SPOP key127.0.0.1:6379> spop myset "d"SINTER返回一个集合的全部成员,该集合是所有给定集合的交集。不存在的 key 被视为空集。当给定集合当中有一个空集时,结果也为空集(根据集合运算定律)。API:SINTER key [key …]127.0.0.1:6379> smembers set1 1) "a" 2) "c" 3) "d" 4) "b" 5) "e" 127.0.0.1:6379> smembers set2 1) "d" 2) "b" 3) "e" 127.0.0.1:6379> sinter set1 set2 #获取两遍都存在的数据 1) "d" 2) "b" 3) "e"SUNION返回一个集合的全部成员,该集合是所有给定集合的并集。不存在的 key 被视为空集。API:SUNION key [key …]127.0.0.1:6379> sunion set1 set2 #获取并集 1) "a" 2) "e" 3) "d" 4) "c" 5) "b"SDIFF返回一个集合的全部成员,该集合是所有给定集合之间的差集。不存在的 key 被视为空集。API:SDIFF key [key …]127.0.0.1:6379> sdiff set1 set2 #获取差集 1) "a" 2) "c"ZSet 类型左边为 key,是字符串类型。右边为 value,由两部分组成:score 和 value score 表示分值,表示 value 在有序集合中的位置。集合与有序集合的区别都没有重复元素。集合无序,有序集合是有序的。集合中只有 member,有序集合中有 member+score。列表与有序集合的区别列表可以有重复元素,有序集合没有重复元素。列表有序,有序集合有序。列表中只有 member,有序集合中有 member+score。特点:将 Set 中的元素增加一个权重参数 score,元素按 score 有序排列,天然排序。适用场景:1、排行榜。2、带权重的消息队列。ZADD将一个或多个 member 元素及其 score 值加入到有序集 key 当中。如果某个 member 已经是有序集的成员,那么更新这个 member 的 score 值,并通过重新插入这个 member 元素,来保证该 member 在正确的位置上。score 值可以是整数值或双精度浮点数。如果 key 不存在,则创建一个空的有序集并执行 ZADD 操作。当 key 存在但不是有序集类型时,返回一个错误。返回被成功添加的新成员的数量,不包括那些被更新的、已经存在的成员。ZREM移除有序集 key 中的一个或多个成员,不存在的成员将被忽略。当 key 存在但不是有序集类型时,返回一个错误。返回被成功移除的成员的数量,不包括被忽略的成员。ZSCORE返回有序集 key 中,成员 member 的 score 值。如果 member 元素不是有序集 key 的成员,或 key 不存在,返回 nil 。ZINCRBY为有序集 key 的成员 member 的 score 值加上增量 increment 。可以通过传递一个负数值 increment ,让 score 减去相应的值。当 key 不存在,或 member 不是 key 的成员时, ZINCRBY key increment member 等同于 ZADD key increment member 。当 key 不是有序集类型时,返回一个错误。score 值可以是整数值或双精度浮点数。member 成员的新 score 值,以字符串形式表示。ZCARD当 key 存在且是有序集类型时,返回有序集的基数。 当 key 不存在时,返回 0 。ZRANGE返回有序集 key 中,指定区间内的成员。其中成员的位置按 score 值递增(从小到大)来排序。如果需要从大到小可以使用ZREVRANGE命令。下标从 0 开始,stop 参数的值比有序集的最大下标还要大,那么 Redis 将 stop 当作最大下标来处理。可以通过使用 WITHSCORES 选项,来让成员和它的 score 值一并返回。API:ZRANGE key start stop [WITHSCORES]127.0.0.1:6379> zadd zset 10 a 20 b 30 c 40 d 50 e (integer) 5 127.0.0.1:6379> zrange zset 0 2 1) "a" 2) "b" 3) "c" 127.0.0.1:6379> zrange zset 0 2 withscores 1) "a" 2) "10" 3) "b" 4) "20" 5) "c" 6) "30"ZRANGEBYSCORE返回有序集 key 中,所有 score 值介于 min 和 max 之间(包括等于 min 或 max )的成员。有序集成员按 score 值递增(从小到大)次序排列。可选的 LIMIT 参数指定返回结果的数量及区间(就像 SQL 中的 SELECT LIMIT offset, count ),注意当 offset 很大时,定位 offset 的操作可能需要遍历整个有序集,此过程最坏复杂度为 O(N) 时间。可以通过使用 WITHSCORES 选项,来让成员和它的 score 值一并返回。min 和 max 可以是 -inf 和 +inf ,这样一来,你就可以在不知道有序集的最低和最高 score 值的情况下,使用这类命令。API:ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]127.0.0.1:6379> ZRANGEBYSCORE zset 10 50 withscores 1) "a" 2) "10" 3) "b" 4) "20" 5) "c" 6) "30" 7) "d" 8) "40" 9) "e" 10) "50" 127.0.0.1:6379> ZRANGEBYSCORE zset -inf +inf 1) "a" 2) "b" 3) "c" 4) "d" 5) "e"ZCOUNT返回有序集 key 中, score 值在 min 和 max 之间(默认包括 score 值等于 min 或 max )的成员的数量。返回score 值在 min 和 max 之间的成员的数量。API:ZCOUNT key min max127.0.0.1:6379> zcount zset 10 30 (integer) 3ZREMRANGEBYRANK移除有序集 key 中,指定排名(rank)区间内的所有成员。区间分别以下标参数 start 和 stop 指出,包含 start 和 stop 在内。下标参数 start 和 stop 都以 0 为底,也就是说,以 0 表示有序集第一个成员,以 1 表示有序集第二个成员,以此类推。 你也可以使用负数下标,以 -1 表示最后一个成员, -2 表示倒数第二个成员,以此类推。返回被移除成员的数量。API:ZREMRANGEBYRANK key start stop127.0.0.1:6379> zrange zset 0 100 withscores 1) "a" 2) "10" 3) "b" 4) "20" 5) "c" 6) "30" 7) "d" 8) "40" 9) "e" 10) "50" 127.0.0.1:6379> ZREMRANGEBYRANK zset 0 1 (integer) 2 127.0.0.1:6379> zrange zset 0 100 withscores 1) "c" 2) "30" 3) "d" 4) "40" 5) "e" 6) "50"ZREMRANGEBYSCORE移除有序集 key 中,所有 score 值介于 min 和 max 之间(包括等于 min 或 max )的成员。返回被移除成员的数量。API:ZREMRANGEBYSCORE key min max127.0.0.1:6379> zremrangebyscore zset 40 50 (integer) 2ZINTERSTORE计算给定的一个或多个有序集的交集,其中给定 key 的数量必须以 numkeys 参数指定,并将该交集(结果集)储存到 destination 。默认情况下,结果集中某个成员的 score 值是所有给定集下该成员 score 值之和。返回保存到 destination 的结果集的基数。API:ZINTERSTORE destination numkeys key [key …] [WEIGHTS weight [weight …]] [AGGREGATE SUM|MIN|MAX]127.0.0.1:6379> zadd test1 10 a 20 b 30 c 40 d 50 e (integer) 5 127.0.0.1:6379> zadd test2 10 a 20 b 30 c 40 d 50 e (integer) 5 127.0.0.1:6379> ZINTERSTORE sum_point 2 test1 test2 (integer) 5 127.0.0.1:6379> zrange sum_point 0 -1 withscores 1) "a" 2) "20" 3) "b" 4) "40" 5) "c" 6) "60" 7) "d" 8) "80" 9) "e" 10) "100"ZUNIONSTORE计算给定的一个或多个有序集的并集,其中给定 key 的数量必须以 numkeys 参数指定,并将该并集(结果集)储存到 destination 。默认情况下,结果集中某个成员的 score 值是所有给定集下该成员 score 值之 和 。WEIGHTS使用 WEIGHTS 选项,你可以为 每个 给定有序集 分别 指定一个乘法因子(multiplication factor),每个给定有序集的所有成员的 score 值在传递给聚合函数(aggregation function)之前都要先乘以该有序集的因子。如果没有指定 WEIGHTS 选项,乘法因子默认设置为 1 。AGGREGATE使用 AGGREGATE 选项,你可以指定并集的结果集的聚合方式。默认使用的参数 SUM ,可以将所有集合中某个成员的 score 值之 和 作为结果集中该成员的 score 值;使用参数 MIN ,可以将所有集合中某个成员的 最小 score 值作为结果集中该成员的 score 值;而参数 MAX 则是将所有集合中某个成员的 最大 score 值作为结果集中该成员的 score 值。返回保存到 destination 的结果集的基数。127.0.0.1:6379> zrange test3 0 -1 withscores 1) "a" 2) "10" 3) "b" 4) "20" 127.0.0.1:6379> zrange test4 0 -1 withscores 1) "a" 2) "10" 3) "b" 4) "20" 5) "c" 6) "30" 7) "d" 8) "40" 127.0.0.1:6379> zunionstore test5 2 test4 test5 weights 1 3 (integer) 4 127.0.0.1:6379> zrange test5 0 -1 withscores 1) "a" 2) "10" 3) "b" 4) "20" 5) "c" 6) "30" 7) "d" 8) "40"
大数据归档-冷热数据分离虽然之前我们的数据是分散在不同的分片中,但是日积月累分片中的数据越来越多,数据迁移的成本就大大提高,所以能不能将数据进行分离。我们可以将很少使用到的数据,从分片中归档到归档数据库中。InnoDB 写入慢的原因因为 InnoDB 本身使用的是 BTree 索引,正因为如此,每次写入都需要用 IO 进行索引树的重排。特别是当数据量特别大的时候,效率并不够高。什么是 TokuDBTokuDB 是一个支持事务的“新”引擎,有着出色的数据压缩功能,由美国 TokuTek 公司(现在已经被 Percona 公司收购)研发。拥有出色的数据压缩功能,如果我们的数据写多读少,而且数据量比较大,我们就可以使用 TokuDB,以节省空间成本,并大幅度降低存储使用量和 IOPS 开销,不过相应的会增加 CPU 的压力。特点:高压缩比,高写入性能,(可以达到压缩比 1:12,写入速度是 InnoDB 的 9~20 倍)在线创建索引和字段支持事务支持主从同步安装 TokuDB在之前的文章(一)中单独安装过 percona 数据库,我们现在不再重新安装 Percona 数据库。安装 jemalloc 库yum install -y jemalloc修改 my.cnfvim /etc/my.cnf在mysqld_safe节点下增加 malloc-lib。…… [mysqld_safe] malloc-lib=/usr/lib64/libjemalloc.so.1 ……然后启动 MySQL 服务。systemctl restart mysqld开启 Linux 大页内存为了保证 TokuDB 的写入性能,我们需要关闭 linux 系统的大页内存管理,默认情况下 linux 的大页内存管理是在系统启动时预先分配内存,系统运行时不再改变了。echo never > /sys/kernel/mm/transparent_hugepage/enabled echo never > /sys/kernel/mm/transparent_hugepage/defrag安装 TokuDB版本必须和 Percona 的版本一致,我们前面安装的是 Percona5.7,所以此处也需要安装 toku5.7,否则提示版本冲突。yum install -y Percona-Server-tokudb-57.x86_64输入 Mysql 的 root 帐号密码,完成启动。ps-admin --enable -uroot -p启动完成之后重启一下 mysqlsystemctl restart mysqld重启之后再激活一次 tokudb,重新执行一下命令ps-admin --enable -uroot -p查看 TokuDB 引擎是否安装成功进入 MySQL:mysql -u root -p执行 show engines;show engines;成功之后,另一台虚拟机也是同样步骤。使用 TokuDB 引擎如果是 sql 语句建表,只需要在语句的结尾加上ENGINE = TokuDB ,注意只能使用 sql 语句创建表才有效。CREATE TABLE student( ......... ) ENGINE = TokuDB;归档库的双机热备我们选用两个 Percona 数据库节点组成 Replication 集群,这两个节点配置成双向同步,因为 Replication 集群的主从同步是单向的,如果配置成单向的主从同步,主库挂掉以后,我们还可以向从库写入数据,但是主库恢复之后主库是不会像从库那同步数据的,所以两个节点的数据不一致,如果我们配置成双向同步,无论哪一个节点宕机了,在上线的时候他都会从其他的节点同步数据。这就可以保证每一个节点的数据是一致的。当然这个一致性是弱一致性,跟 PXC 集群的强一致性有本质区别的。由于已经启动了 8 台虚拟机了,为了节省硬件资源,每个 PXC 节点我只启动一个 PXC 节点,和一个 MyCat 实例,这样就只有三台虚拟机同时运行。另外别忘记了,在 MyCat 的配置文件中需要将 balance=0 ,然后将用不到的 PXC 节点删除掉。vim schema.xml<?xml version="1.0"?> <!DOCTYPE mycat:schema SYSTEM "schema.dtd"> <mycat:schema xmlns:mycat="http://io.mycat/"> <!--配置数据表--> <schema name="test" checkSQLschema="false" sqlMaxLimit="100"> <table name="t_test" dataNode="dn1,dn2" rule="mod-long" /> <table name="t_user" dataNode="dn1,dn2" rule="mod-long" /> <table name="t_customer" dataNode="dn1,dn2" rule="sharding-customer"> <childTable name="t_orders" primaryKey="ID" joinKey="customer_id" parentKey="id"/> </table> </schema> <!--配置分片关系--> <dataNode name="dn1" dataHost="cluster1" database="test" /> <dataNode name="dn2" dataHost="cluster2" database="test" /> <!--配置连接信息--> <dataHost name="cluster1" maxCon="1000" minCon="10" balance="0" writeType="1" dbType="mysql" dbDriver="native" switchType="1" slaveThreshold="100"> <heartbeat>select user()</heartbeat> <writeHost host="W1" url="192.168.3.137:3306" user="admin" password="Abc_123456"> </writeHost> </dataHost> <dataHost name="cluster2" maxCon="1000" minCon="10" balance="0" writeType="1" dbType="mysql" dbDriver="native" switchType="1" slaveThreshold="100"> <heartbeat>select user()</heartbeat> <writeHost host="W1" url="192.168.3.141:3306" user="admin" password="Abc_123456"> </writeHost> </dataHost> </mycat:schema>最后重启 MyCat。./mycat start配置 Replication 集群Replication 集群同步原理当我们在 Master 节点写入数据,Master 会把这次操作会记录到 binlog 日志里边,Slave 节点会有专门的线程去请求 Master 发送 binlog 日志,然后 Slave 节点上的线程会把收到的 Master 日志记录在本地 realy_log 日志文件中,slave 节点通过执行 realy_log 日志来实现数据的同步,最后把执行过的操作写到本地的 binlog 日志里。通过上图我们能总结出 Replication 集群的数据同步是单向的,我们在 Master 上写入数据,在 slave 上可以同步到这些数据,但是反过来却不行,所以要实现双向同步,两个数据库节点互为主从关系才行。创建同步账户我们给两个节点的数据库都创建上一个同步数据的账户。CREATE USER 'backup'@'%' IDENTIFIED BY 'Abc_123456' ;GRANT super, reload, replication slave ON *.* TO 'backup'@'%' ;FLUSH PRIVILEGES ;修改两个 TokuDB 的配置文件vim /etc/my.cnf[mysqld] server_id = 101 log_bin = mysql_bin relay_log = relay_bin ……[mysqld] server_id = 102 log_bin = mysql_bin relay_log = relay_bin重启 MySQLsystemctl restart mysqld配置主从同步我的 A 节点为:192.168.3.151B 节点为:192.168.3.152我们先在 B 节点上关闭主从同步的服务。#关闭同步服务 stop slave; #设置同步的Master节点 change master to master_host="192.168.3.151",master_port=3306,master_user="backup", master_password="Abc_123456"; #启动同步服务 start slave; #查看同步状态 show slave status\G;如果看到下图 Slave_IO_Running 和 Slave_SQL_Running都为 yes 即说明配置成功。然后我们去 A 节点进行上述配置,将master_host="192.168.3.152"改为 152 即可。这样我们就实现了双向同步。创建归档表因为是双向同步,我们在哪一个节点创建归档表,另一个节点都会同步到数据。CREATE DATABASE test; use test; CREATE TABLE t_purchase_202011 ( id INT UNSIGNED PRIMARY KEY, purchase_price DECIMAL(10,2) NOT NULL, purchase_num INT UNSIGNED NOT NULL, purchase_sum DECIMAL (10,2) NOT NULL, purchase_buyer INT UNSIGNED NOT NULL, purchase_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, company_id INT UNSIGNED NOT NULL, goods_id INT UNSIGNED NOT NULL, KEY idx_company_id(company_id), KEY idx_goods_id(goods_id) )engine=TokuDB;搭建 Haproxy之前文章已经安装过 Haproxy 了,这里就不在啰嗦了。两个节点一样的步骤。yum install -y haproxy修改配置文件:vim /etc/haproxy/haproxy.cfgglobal log 127.0.0.1 local2 chroot /var/lib/haproxy pidfile /var/run/haproxy.pid maxconn 4000 user haproxy group haproxy daemon # turn on stats unix socket stats socket /var/lib/haproxy/stats defaults mode http log global option httplog option dontlognull option http-server-close option forwardfor except 127.0.0.0/8 option redispatch retries 3 timeout http-request 10s timeout queue 1m timeout connect 10s timeout client 1m timeout server 1m timeout http-keep-alive 10s timeout check 10s maxconn 3000 listen admin_stats bind 0.0.0.0:4001 mode http stats uri /dbs stats realm Global\ statistics stats auth admin:abc123456 listen proxy-mysql bind 0.0.0.0:4002 mode tcp balance roundrobin #日志格式 option tcplog server backup_1 192.168.3.151:3306 check port 3306 maxconn 2000 server backup_2 192.168.3.152:3306 check port 3306 maxconn 2000 #使用keepalive检测死链 option tcpka开启防火墙的 VRRP 协议,开启 4001 端口和 4002 端口。firewall-cmd --direct --permanent --add-rule ipv4 filter INPUT 0 --protocol vrrp -j ACCEPT firewall-cmd --zone=public --add-port=4001/tcp --permanent firewall-cmd --zone=public --add-port=4002/tcp --permanent firewall-cmd --reload启动 Haproxyservice haproxy start浏览器访问如下地址即可访问:http://192.168.3.151:4001/dbs如下图:即说明成功,两个节点都看一下。搭建 Keepalived还是在两台节点上一样的操作。yum install -y keepalived编辑配置文件:vim /etc/keepalived/keepalived.confvrrp_instance VI_1 { state MASTER interface enp0s3 virtual_router_id 51 priority 100 advert_int 1 authentication { auth_type PASS auth_pass 123456 } virtual_ipaddress { 192.168.3.177 } }配置说明:state MASTER:定义节点角色为 master,当角色为 master 时,该节点无需争抢就能获取到 VIP。集群内允许有多个 master,当存在多个 master 时,master 之间就需要争抢 VIP。为其他角色时,只有 master 下线才能获取到 VIPinterface enp0s3:定义可用于外部通信的网卡名称,网卡名称可以通过ip addr命令查看virtual_router_id 51:定义虚拟路由的 id,取值在 0-255,每个节点的值需要唯一,也就是不能配置成一样的priority 100:定义权重,权重越高就越优先获取到 VIPadvert_int 1:定义检测间隔时间为 1 秒authentication:定义心跳检查时所使用的认证信息auth_type PASS:定义认证类型为密码auth_pass 123456:定义具体的密码virtual_ipaddress:定义虚拟 IP(VIP),需要为同一网段下的 IP,并且每个节点需要一致完成以上配置后,启动 keepalived 服务:service keepalived start我们 ping 一下我们的虚拟地址试试,也是 OK 的!使用数据库连接工具测试,也是 OK 的!现在即使哪一台挂掉,都不会影响高可用。上一篇文章中已经演示了,这里就不再演示了。准备归档数据我们在 PXC 的两个分片中,创建一个进货表,注意这里创建进货表时没指定 tokuDB 引擎。(如果不知道两个 PXC 分片的话查看之前文章)use test; CREATE TABLE t_purchase ( id INT UNSIGNED PRIMARY KEY, purchase_price DECIMAL(10,2) NOT NULL, purchase_num INT UNSIGNED NOT NULL, purchase_sum DECIMAL (10,2) NOT NULL, purchase_buyer INT UNSIGNED NOT NULL, purchase_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, company_id INT UNSIGNED NOT NULL, goods_id INT UNSIGNED NOT NULL, KEY idx_company_id(company_id), KEY idx_goods_id(goods_id) )修改 MyCat 配置文件增加了表之后,我们需要在 MyCat 的配置文件中增加该表:<table name="t_purchase" dataNode="dn1,dn2" rule="mod-long" />然后重启 MyCat。./mycat restart然后我使用 Java 代码来进行批量插入数据,这次没有使用 LoadData,大家可以对比一下 10 万条数据,和 1000 万条数据的时间差。/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2020/11/27 * @Description: */ public class InsertDB { public static void main(String[] args) throws SQLException { DriverManager.registerDriver(new Driver()); String url = "jdbc:mysql://192.168.3.146:8066/test"; String username = "admin"; String password = "Abc_123456"; Connection connection = DriverManager.getConnection(url, username, password); String sql = "insert into t_purchase(id, purchase_price, purchase_num, purchase_sum, purchase_buyer, purchase_date, company_id, goods_id)" + " VALUES (?,?,?,?,?,?,?,?)"; connection.setAutoCommit(false); PreparedStatement pst = connection.prepareStatement(sql); for (int i = 0; i < 100000; i++) { pst.setObject(1, i); pst.setObject(2, 5.0); pst.setObject(3, 100); pst.setObject(4, 500.0); pst.setObject(5, 12); pst.setObject(6, "2020-11-27"); pst.setObject(7, 20); pst.setObject(8, 9); pst.addBatch(); if (i % 2000 == 0) { pst.executeBatch(); connection.commit(); } } pst.close(); connection.close(); System.out.println("执行结束"); } }安装归档工具Percona 公司为我们提供了一套非常便捷的工具包 Percona-Toolkit,这个工具包包含了用于数据归档的pt-archiver,用这个归档工具我们可以轻松的完成数据的归档。pt-archiver 的用途导出线上数据,到线下数据作处理清理过期数据,并把数据归档到本地归档表中,或者远端归档服务器下载:yum install -y percona-toolkit --nogpgcheck执行归档pt-archiver --source h=192.168.3.146,P=8066,u=admin,p=Abc_123456,D=test,t=t_purchase --dest h=192.168.3.188,P=3306,u=root,p=123456,D=test,t=t_purchase_202011 --no-check-charset --where 'purchase_date="2020-11-27 00:00:00"' --progress 5000 --bulk-delete --bulk-insert --limit=10000 --statistics--source h=192.168.3.146, P=8066, u=admin, p=Abc_123456, D=test, t=t_purchase 代表的是取哪个服务器的哪个数据库的哪张表的--dest h=192.168.3.188, P=3306, u=root, p=123456, D=test, t=t_purchase_202011 代表的是归档库的连接信息--no-check-charset 代表的是归档过程中我们不检查数据的字符集--where 归档数据的判断条件--progress 5000 每归档 5000 条数据往控制台打印一下状态信息--bulk-delete 批量的删除归档数据--bulk-insert 批量的新增归档数据limit=10000 批量一万条数据进行一次归档--statistics 打印归档的统计信息执行完成后,可以看到在 MyCat 节点进行查询已经没有数据了。在我们的 ToKuDB 的 Haproxy 虚拟节点上查询可以看到数据已经都过来了。小结使用 TokuDB 引擎保存归档数据,拥有高速写入特性使用双机热备方案搭建归档库,具备高可用性使用 pt-archiver 执行归档数据,简便易行数据分区什么是表分区表分区就是按照特定的规则,将数据分成许多小块,存储在磁盘中不同的区域,通过提升磁盘 IO 能力来加快查询的速度。分区不会更改数据表的结构,发生变化的只是存储方式。从逻辑上看还是一张表,但底层是由多个物理分区来组成的。我们可以按照不同的方式来进行切分。表分区的优缺点优点:表分区的数据可以分布在不同的物理设备上,从而高效地利用多个硬件设备单表可以存储更多的数据数据写入和读取的效率提高,汇总函数计算速度也变快了不会出现表锁,只会锁住相关分区缺点:不支持存储过程、存储函数和某些特殊函数不支持按位运算符分区键不能子查询创建分区后,尽量不要修改数据库模式为什么在集群中引入表分区当热数据过多的情况下,我们增加一个 PXC 分片,就要花费三台服务器。而且还要数据迁移。如果我们使用表分区的话,我们可以给每个分区增加一个硬盘。这样我们就可以用少量的分片就可以存储更多的热数据。挂载硬盘这里我们还是使用每个 PXC 集群中一个节点。给虚拟机增加两个硬盘这里就不再演示了,根据不同的虚拟机进行配置就好。完成后 执行命令可以看到,三块硬盘已经被识别。fdisk -l然后我们进行分区fdisk /dev/sdbn:创建新分区d:删除分区p:列出分区表w:把分区表写入硬盘并退出q:退出而不保存然后根据图中的步骤即可:然后格式化分区mkfs -t ext4 /dev/sdb1接下来修改文件进行挂载。vim /etc/fstab最下面追加内容:/dev/sdb1/ /mnt/p0 ext4 defaults 0 0执行完成后进行重启reboot查看是否挂载成功。没有问题,之后我们就把数据存储在 data 文件夹中。之后再创建第三块硬盘分区fdisk /dev/sdc然后格式化分区mkfs -t ext4 /dev/sdc1接下来修改文件进行挂载。vim /etc/fstab最下面追加内容:/dev/sdc1/ /mnt/p1 ext4 defaults 0 0执行完成后进行重启reboot查看是否挂载成功。cd /mnt/p1 mkdir data这样第一个 PXC 节点的就完成了,另外一台 PXC 节点也是一样的步骤。就不再演示了。完成后分配权限在两台节点上。chown -R mysql:mysql /mnt/p0/data chown -R mysql:mysql /mnt/p1/dataPXC 节点使用表分区vim /etc/my.cnf设置为宽容模式,还有一种模式就是严厉模式:DISABLEDpxc_strict_mode=PERMISSIVE表分区类型RANGE:根据连续区间值切分数据LIST:根据枚举值切分数据HASH:对整数求模切分数据KEY:对任何数据类型求模切分数据Range 分区Range 分区是按照主键值范围进行切分的,比如有 4 千万条数据。根据下面语句我们创建分区别,根据 ID 进行切分,p0 名字是可以随便起的,切分规则就是小于 1 千万条的数据切分到 p0 中,1 千万到 2 千万条在 p1 中,以此类推。CREATE TABLE t_range_1( id INT UNSIGNED PRIMARY KEY , name VARCHAR(200) NOT NULL ) PARTITION BY RANGE(ID)( PARTITION p0 VALUES LESS THAN (10000000), PARTITION p1 VALUES LESS THAN (20000000), PARTITION p2 VALUES LESS THAN (30000000), PARTITION p3 VALUES LESS THAN (40000000) );最后我们在查询的时候可以根据分区进行查询,就会提高效率。SELECT * FROM t_range_1 PARTITION(p0);由于 MySQL 只支持整数类型切分,如果想使用日期类型的话,我们就要比如提取月份,进行分区。需要注意的是:分区字段必须是主键、联合主键的一部分,否则会报如下错误:A PRIMARY KEY must include all columns in the table's partitioning functionCREATE TABLE t_range_2( id INT UNSIGNED , name VARCHAR(200) NOT NULL, birthday DATE NOT NULL, PRIMARY KEY(id,birthday) ) PARTITION BY RANGE(MONTH(birthday))( PARTITION p0 VALUES LESS THAN (3), PARTITION p1 VALUES LESS THAN (6), PARTITION p2 VALUES LESS THAN (9), PARTITION p3 VALUES LESS THAN (12) );如果我们想把表分区映射到不同的磁盘,需要使用下面的 SQL,p0 和 p1 就是刚才我们创建的分区。CREATE TABLE t_range_2( id INT UNSIGNED , name VARCHAR(200) NOT NULL, birthday DATE NOT NULL, PRIMARY KEY(id,birthday) ) PARTITION BY RANGE(MONTH(birthday))( PARTITION p0 VALUES LESS THAN (6) DATA DIRECTORY='/mnt/p0/data', PARTITION p1 VALUES LESS THAN (12) DATA DIRECTORY='/mnt/p1/data' );我们在 PXC 某个节点上演示一下表分区。use test; CREATE TABLE t_range_2( id INT UNSIGNED , name VARCHAR(200) NOT NULL, birthday DATE NOT NULL, PRIMARY KEY(id,birthday) ) PARTITION BY RANGE(MONTH(birthday))( PARTITION p0 VALUES LESS THAN (6) DATA DIRECTORY='/mnt/p0/data', PARTITION p1 VALUES LESS THAN (12) DATA DIRECTORY='/mnt/p1/data' );创建完成后我们写入一些数据:我们去 PXC 节点的服务器上查询一下,可以看到我们的数据已经切分过来了。cd /mnt/p0/data/test然后我们看一下 p1 的分区,也是没有问题的。如果想查看每个分区保存了多少条数据的话,可以使用下面的 SQL:select PARTITION_NAME, #分区名称 PARTITION_METHOD,#分区方式 PARTITION_EXPRESSION,#分区字段 PARTITION_DESCRIPTION,#分区条件 TABLE_ROWS #数据量 from information_schema.PARTITIONS where TABLE_SCHEMA = SCHEMA() and TABLE_NAME = 't_range_2';测试完成之后,我们将创建表的语句和分区在另一台 PXC 节点上创建一下。因为我们是使用 MyCat 来进行切分数据,但我们还没有规定 MyCat 切分规则,所以把我们刚才创建的两条测试数据删除掉。完成后我们打开 MyCat 节点虚拟机。vim /usr/local/mycat/conf/schema.xml增加我们新建的表:<table name="t_test" dataNode="dn1,dn2" rule="mod-long" />进入 bin 目录重启 MyCat:./mycat restart然后我们连接 MyCat 节点,插入一些数据。insert into t_range_2 (id,name,birthday) values (1,'A','2020-01-01'); insert into t_range_2 (id,name,birthday) values (2,'B','2020-10-10');之后我们可以看到,数据被 MyCat 求模切分到了不同的 PXC 节点,随后又进行了日期的切分存储了不同的硬盘上,由此我们可以知道是可以共存的。List 分区LIST 分区和 RANGE 分区非常的相似,主要区别在于 LIST 是枚举值列表的集合,RANGE 是连续的区间值的集合。二者在语法方面非常的相似。同样建议 LIST 分区列是非 null 列,否则插入 null 值如果枚举列表里面不存在 null 值会插入失败,这点和其它的分区不一样,RANGE 分区会将其作为最小分区值存储,HASH\KEY 分为会将其转换成 0 存储,主要 LIST 分区只支持整形,非整形字段需要通过函数转换成整形;5.5 版本之后可以不需要函数转换使用 LIST COLUMN 分区支持非整形字段。我们在第一个 PXC 节点上创建表,大体的代码还是没有太大区别,只是规则从范围变成了固定的值。create table t_list_1( id int unsigned, name varchar(200) not null, province_id int unsigned, primary key (id,province_id) ) partition by list(province_id)( partition p0 values in (1,2,3,4) data directory = '/mnt/p0/data', partition p1 values in (5,6,7,8) data directory = '/mnt/p1/data' );然后在另一个 PXC 节点上创建,记得更改值。create table t_list_1( id int unsigned, name varchar(200) not null, province_id int unsigned, primary key (id,province_id) ) partition by list(province_id)( partition p0 values in (9,10,11,12) data directory = '/mnt/p0/data', partition p1 values in (13,14,15,16) data directory = '/mnt/p1/data' );随后我们进入 MyCat 的虚拟机,修改配置文件。vim /usr/local/mycat/conf/rule.xml增加如下代码:<tableRule name="sharding-province"> <rule> <columns>province_id</columns> <algorithm>province-hash-int</algorithm> </rule> </tableRule> <function name="province-hash-int" class="io.mycat.route.function.PartitionByFileMap"> <property name="mapFile">province-hash-int.txt</property> </function>保存退出后我们创建这个文件 province-hash-int.txt。这个文件创建在mycat/conf文件中1=0 2=0 3=0 4=0 5=0 6=0 7=0 8=0 9=1 10=1 11=1 12=1 13=1 14=1 15=1 16=1保存后我们编辑 schema.xml,增加我们新加的表:<table name="t_list_1" dataNode="dn1,dn2" rule="sharding-province" />随后重启 MyCat。在 MyCat 节点上插入数据,我们看看是什么效果:insert into t_list_1 (id,name,province_id) values (1,'A',1); insert into t_list_1 (id,name,province_id) values (2,'B',5); insert into t_list_1 (id,name,province_id) values (3,'A',10); insert into t_list_1 (id,name,province_id) values (4,'B',16);这里我就用图解的方式了:Hash 分区基于给定的分区个数,将数据分配到不同的分区,HASH 分区只能针对整数进行 HASH,对于非整形的字段只能通过表达式将其转换成整数。表达式可以是 mysql 中任意有效的函数或者表达式,对于非整形的 HASH 往表插入数据的过程中会多一步表达式的计算操作,所以不建议使用复杂的表达式这样会影响性能。语法如下,基本上没有太大区别,只是我们按照 2 取余数进行分区,如果等于 0 切分到 p0 如果等于 1 切分到 p1。create table t_hash_1( id int unsigned primary key , name varchar(200) not null, province_id int unsigned not null ) partition by hash(id) partitions 2( partition p0 data directory = '/mnt/p0/data', partition p1 data directory = '/mnt/p1/data' );然后我们还是进入 MyCat,编辑 schema.xml,增加我们新加的表,切分算法还是使用我们刚才创建的。<table name="t_hash_1" dataNode="dn1,dn2" rule="sharding-province" />随后重启 MyCat。我们插入测试数据:insert into t_hash_1 (id,name,province_id) values (1,'A',1); insert into t_hash_1 (id,name,province_id) values (2,'B',5);首先会根据 province_id 去进行 MyCat 切分,1-8 的 province_id 都会被存储在一个 PXC 节点上。随后进行 Hash 分区,我们查询一下看看是否分配到了两个硬盘上。select PARTITION_NAME, #分区名称 PARTITION_METHOD,#分区方式 PARTITION_EXPRESSION,#分区字段 PARTITION_DESCRIPTION,#分区条件 TABLE_ROWS #数据量 from information_schema.PARTITIONS where TABLE_SCHEMA = SCHEMA() and TABLE_NAME = 't_hash_1';执行结果也是没有问题,正确的分配到了不同的硬盘上。Key 分区KEY 分区和 HASH 分区相似,但是 KEY 分区支持除 text 和 BLOB 之外的所有数据类型的分区,而 HASH 分区只支持数字分区,KEY 分区不允许使用用户自定义的表达式进行分区,KEY 分区使用系统提供的 HASH 函数进行分区。当表中存在主键或者唯一键时,如果创建 KEY 分区时没有指定字段系统默认会首选主键列作为分区字列,如果不存在主键列会选择非空唯一键列作为分区列,注意唯一列作为分区列时唯一列不能为 NULL。在两个 PXC 分片中执行 sql。create table t_key_1( id int unsigned not null , name varchar(200) not null, job varchar(200) not null , primary key (id,job) ) partition by key(job) partitions 2( partition p0 data directory = '/mnt/p0/data', partition p1 data directory = '/mnt/p1/data' );MyCat 配置schema.xml,增加配置:<table name="t_key_1" dataNode="dn1,dn2" rule="mod-long" />然后重启 MyCat。之后我们测试数据:insert into t_key_1 (id,name,job) values (1,'A','管理员'); insert into t_key_1 (id,name,job) values (2,'B','保洁'); insert into t_key_1 (id,name,job) values (3,'C','网管');执行之后,我们 id 为 1、3 的求模切分到了一个 PXC 分片中。然后我们执行查询看看 Key 是怎么分区的。select PARTITION_NAME, #分区名称 PARTITION_METHOD,#分区方式 PARTITION_EXPRESSION,#分区字段 PARTITION_DESCRIPTION,#分区条件 TABLE_ROWS #数据量 from information_schema.PARTITIONS where TABLE_SCHEMA = SCHEMA() and TABLE_NAME = 't_key_1';我们可以看到管理员和网管都被分到了一个硬盘中。管理表分区管理 Range 表分区我们之前创建的 Range 表分区是到 4 千万,我们想扩展的话,可以使用下面的语句:alter table t_range_1 add partition ( partition p4 values less than (50000000) );如果想删除表分区,可以使用下面的语句:alter table t_range_1 drop partition p3,p4;如果想拆分某一个区域的话可以使用下面的语句:alter table t_range_1 reorganize partition p0 into ( partition s0 values less than (5000000), partition s1 values less than (10000000) )对应的合并就是下面的语句:alter table t_range_1 reorganize partition s0,s1 into ( partition p0 values less than (10000000) )移除表分区,并不会丢失数据,而是将数据放到主分区中。alter table t_range_1 remove partitioning ;对应的其他类型的分区语法都差不多,这里就不再演示了。
MySQL 数据库集群-PXC 方案(三)什么是基准测试基准测试是针对系统的一种压力测试,但基准测试不关心业务逻辑,更加简单、直接、易于测试,不要求数据的真实性和逻辑关系。基准测试的指标Sysbench 简介Sysbench 是一个模块化的、跨平台、多线程基准测试工具,主要用于测试系统及数据库的性能。它主要包括以下几种方式的测试:CPU 性能(系统级别)磁盘 IO 性能(系统级别)调度程序性能(系统级别)内存分配及传输速度(系统级别)POSIX 线程性能(系统级别)数据库性能(OLTP 基准测试)目前 Sysbench 主要支持 MySQL,pgsql,oracle 这 3 种数据库。安装 Sysbenchcurl -s https://packagecloud.io/install/repositories/akopytov/sysbench/script.rpm.sh | sudo bash yum -y install sysbench安装完成后查看是否安装成功sysbench --versionSysbench 基本语法sysbench script [option] [command]option 连接信息参数参数名称功能意义--mysql-hostIP 地址--mysql-port端口号--mysql-user用户名--mysql-password密码option 执行参数参数名称功能意义--oltp-test-mode执行模式(simple、nontrx、complex)--oltp-tables-count测试表的数量--oltp-table-size测试表的记录数--threads并发连接数--time测试执行时间(秒)--report-interval生成报告单的间隔时间(秒)执行模式:simple: 测试查询 不测试写入nontrx:测试无事务的增删改查complex:测试有事务的增删改查command 命令命令名称功能意义prepare准备测试数据run执行测试cleanup清除测试数据准备测试数据在准备之前我们先修改一下haproxy.cfg文件,之前我们配置的是 MyCat 集群的负载均衡,现在改为某一个分片的 PXC 集群即可。vim /etc/haproxy/haproxy.cfgserver mysql_1 192.168.3.137:3306 check port 3306 weight 1 maxconn 2000 server mysql_1 192.168.3.138:3306 check port 3306 weight 1 maxconn 2000 server mysql_1 192.168.3.139:3306 check port 3306 weight 1 maxconn 2000保存之后执行命令重启service haproxy restart可以看到下图没有问题:之后我们新建一个测试逻辑库sbtest接着我们创建测试数据sysbench /usr/share/sysbench/tests/include/oltp_legacy/oltp.lua --mysql-host=192.168.3.146 --mysql-port=3306 --mysql-user=admin --mysql-password=Abc_123456 --oltp-tables-count=10 --oltp-table-size=100000 prepare/usr/share/sysbench/tests/include/oltp_legacy/oltp.lua : 生成测试数据的脚本 sysbench 自带--mysql-host :数据库连接地址--mysql-port : 端口--mysql-user:用户名--mysql-password:密码--oltp-tables-count:测试 10 个数据表--oltp-table-size:每张表 10 万条数据prepare:准备测试数据创建完成后我们执行测试sysbench /usr/share/sysbench/tests/include/oltp_legacy/oltp.lua --mysql-host=192.168.3.146 --mysql-port=3306 --mysql-user=admin --mysql-password=Abc_123456 --oltp-test-mode=complex --threads=10 --time=300 --report-interval=10 run >> /home/mysysbench.log--oltp-test-mode=complex:测试有事务的增删改查--threads:并发连接数--time:测试时长,测试的时长更长一些,比如 24 小时,测试的结果会更加准确--report-interval=10 :每隔 10 秒回报一次数据run >> /home/mysysbench.log:输入测试日志报告文件位置等待 5 分钟执行完成后,我们查看 /home/mysysbench.log。queries performed : 执行测试的次数read : 读操作执行了 442176 次write : 写操作执行了 117484 次other:其他操作执行了 66275 次total:总共执行了 625935 次transcations(TPS):执行的事务次数 28415 次,PXC 集群每秒可以执行的事务操作 94.67 次queries(QPS):处理的请求书 625935,PXC 集群每秒钟可以执行 2085.35 次增删改查操作ignored errors: 忽略的错误数量 3169,每秒钟平均错误数量 10.56 次,可能是节点之间冲突造成reconnects:数据库重新连接的次数,0 代表没有发生数据库连接断开的情况清理测试数据:sysbench /usr/share/sysbench/tests/include/oltp_legacy/oltp.lua --mysql-host=192.168.3.146 --mysql-port=3306 --mysql-user=admin --mysql-password=Abc_123456 --oltp-tables-count=10 cleanup小结基准测试是对单张表进行的读写测试,因为不涉及表连接外键约束,索引等操作,所以单纯体现的是数据库硬件性能。如果想知道数据库集群在真实业务中的实际性能,需要使用压力测试。tpcc-mysql 简介tpcc-mysql 是 percona 基于 tpcc 规范衍生出来的产品,专门用于 mysql 压力测试 。tpcc 是一种测试标准,明确规定了数据模型和检测是指标,而且检测的标准对数据库集群来说很苛刻,tpcc-mysql 的测试库能覆盖大多数的业务场景,测试的结果也能反映出真实业务中数据库的实际性能。tpcc 测试问题tpcc 的检测标准是针对单节点的 mysql 数据库,对 sql 的执行时间有严格的规定,我们要测试的 PXC 集群是以牺牲插入速度为代价换取的同步强一致性,假如 tpcc 要执行一个 insert 语句不能超过 100ms,但是 PXC 集群只是写入速度慢,插入执行了 300ms。这个检测点就没有通过,所以拿 tpcc 测试 PXC 集群不太适合,检测的标准太苛刻了一些,但是因为数据库集群在真实业务下实际的读写性能,每秒钟能执行多少次读操作,多少次写操作。至于由于插入速度慢测试报告中测试没有通过,可以不予理会。测试方案我们还是以 haproxy+三个 mysql 节点的 PXC 集群来进行测试。准备工作(一)关闭我们的 PXC 集群。对应关闭操作在第二篇文章中写的很清楚。然后打开 vim /etc/my.cnf把 PXC_strict_mode 的值改成 DISABLED原来默认的参数值是不允许我们执行不符合规范的操作,比如创建出没有主键的数据表,tpcc 数据库脚本里边有一个表没有主键,所以为了 tpcc 测试能进行下去我们要修改 PXC_strict_mode 的值。三个 PXC 节点都需要修改修改完 my.cnf 文件之后再重新启动 PXC 集群。准备工作(二)Haproxy 对应的文件进行修改,由于我们之前已经修改过了这里就不用再修改了。server mysql_1 192.168.3.137:3306 check port 3306 weight 1 maxconn 2000 server mysql_1 192.168.3.138:3306 check port 3306 weight 1 maxconn 2000 server mysql_1 192.168.3.139:3306 check port 3306 weight 1 maxconn 2000准备工作(三)安装环境包。yum install -y gcc yum install -y mysql-devel安装 tpcc-mysql下载安装包。https://codeload.github.com/Percona-Lab/tpcc-mysql/zip/master解压然后上传到服务器。进入 src 目录,再使用 make 命令编译。cd src make创建测试库到 PXC 集群的节点上创建数据库tpcc,我们在 Haproxy 的节点上创建,那么 PXC 集群的 mysql 库也会自动同步。然后我们进入 tpcc-mysql-master 目录下执行:ls *.sqlcreate_table.sql 是创建表的 sql 文件add_fkey_idx.sql 是索引等约束文件我们将这两个文件复制出来,然后在我们新建的 tpcc 库中运行。准备测试数据./tpcc_load -h 192.168.3.146 -d tpcc -u admin -p Abc_123456 -w 1-h 192.168.3.146 数据库 ip 地址-d tpcc 数据库名字-u admin 用户名-p Abc_123456 密码-w 1 仓库数量,由于数量庞大,插入时间较长,所以这里使用 1 个仓库数量,如果使用多个仓库,耗时很长。执行测试./tpcc_start -h 192.168.3.146 -d tpcc -u admin -p Abc_123456 -w 1 -c 5 -r 300 -l 600 - >tpcc-outpit.log-h 192.168.3.146 数据库 ip 地址-d tpcc 数据库名字-u admin 用户名-p Abc_123456 密码-w 1 仓库数量-c 5 并发线程数-r 300 数据库预热时间 单位秒-l 600 测试时间单位秒tpcc-outpit.log 测试结果输出到文件为了真实性可以将-r 和-l 时间设置长一些,比如预热 1 个小时,测试 24 小时。查看日志日志出现了大量的死锁异常,执行压力测试的时候,事务执行的时间太久,没有及时提交事务,于是出现了锁冲突。让 PXC 集群的锁冲突降到最低,将并发的线程数改为 1。./tpcc_start -h 192.168.3.146 -d tpcc - admin -p Abc_123456 -w 1 -c 1 -r 300 -l 600 - >tpcc-outpit.log查看测试结果sc: 成功执行的次数lt: 超时执行的次数rt: 重试执行的次数fl: 失败执行的次数第一行是新订单执行的测试结果,tpcc 在规定时间内成功执行了 0 条记录,由于 tpcc 测试指标是非常苛刻的,虽然我们执行了操作,但是执行时间没有达到 tpcc 的要求,所以不能算为执行成功,只能算做是超时执行。PXC 集群是以牺牲速度为代价换取数据同步的强一致性。虽热增删改成都能执行,但是达不到 tpcc 的指标。rt(重试执行的次数)和 fl(失败执行的次数)是 0 次。第二行是支付业务的测试结果。成功执行了 0 次,超时执行了 3587 次,重试执行 0 次,失败 0 次。第三行是订单状态的测试结果。成功执行了 195 次,超时执行了 163 次,重试执行 0 次,失败 0 次。第四行是发货业务的测试结果。成功执行了 0 次,超时执行了 358 次,重试执行 0 次,失败 0 次第五行是库存业务的测试结果。成功执行了 0 次,超时执行了 359 次,重试执行 0 次,失败 0 次。尽管达不到 tpcc 的测试指标,但是没有失败执行的。以上是各种业务下的增删改查操作。下面这个是事务的操作状态,测试报告也是通过的。响应时间的一个测试,NG 代表没有通过,OK 代表的是测试通过。用 tpcc 测试 PXC 集群有些不合理,tpcc 测试是按照单节点 mysql 读写速度和响应时间来制定的指标。所以 PXC 集群很难能达到 tpcc 的指标,所以这里很多响应时间的指标都是 NG 没通过的。最后的 TpmC 是,每分钟 PXC 集群执行的事务数量。binlog 简介MySQL 的二进制日志 binlog 可以说是 MySQL 最重要的日志,它记录了所有的 DDL 和 DML 语句(除了数据查询语句 select、show 等),以事件形式记录,还包含语句所执行的消耗的时间,MySQL 的二进制日志是事务安全型的。binlog 的主要目的是复制和恢复。binlog 日志的两个最重要的使用场景:MySQL 主从复制数据恢复binlog 文件种类二进制日志索引文件(文件名后缀为.index)用于记录所有有效的的二进制文件二进制日志文件(文件名后缀为.00000\*)记录数据库所有的 DDL 和 DML 语句事件binlog 是一个二进制文件集合,每个 binlog 文件以一个 4 字节的魔数开头,接着是一组 Events:魔数:0xfe62696e 对应的是 0xfebin;Event:每个 Event 包含 header 和 data 两个部分;header 提供了 Event 的创建时间,哪个服务器等信息,data 部分提供的是针对该 Event 的具体信息,如具体数据的修改;第一个 Event 用于描述 binlog 文件的格式版本,这个格式就是 event 写入 binlog 文件的格式;其余的 Event 按照第一个 Event 的格式版本写入;最后一个 Event 用于说明下一个 binlog 文件;binlog 的索引文件是一个文本文件,其中内容为当前的 binlog 文件列表当遇到以下 3 种情况时,MySQL 会重新生成一个新的日志文件,文件序号递增:MySQL 服务器停止或重启时使用 flush logs 命令当 binlog 文件大小超过 max_binlog_size 变量的值时max_binlog_size 的最小值是 4096 字节,最大值和默认值是 1GB (1073741824 字节)。事务被写入到 binlog 的一个块中,所以它不会在几个二进制日志之间被拆分。因此,如果你有很大的事务,为了保证事务的完整性,不可能做切换日志的动作,只能将该事务的日志都记录到当前日志文件中,直到事务结束,你可能会看到 binlog 文件大于 max_binlog_size 的情况。Binlog 的日志格式记录在二进制日志中的事件的格式取决于二进制记录格式。支持三种格式类型:STATEMENT:基于 SQL 语句的复制(statement-based replication, SBR)ROW:基于行的复制(row-based replication, RBR)MIXED:混合模式复制(mixed-based replication, MBR)在 MySQL 5.7.7 之前,默认的格式是 STATEMENT,在 MySQL 5.7.7 及更高版本中,默认值是 ROW。日志格式通过 binlog-format 指定,如 binlog-format=STATEMENT、binlog-format=ROW、binlog-format=MIXED。ROW 模式注:我是在本地环境下测试。输入命令打开我们的 mysql 配置文件vim /etc/my.cnf增加如下配置:binlog_format = row log_bin=mysql_bin重启服务后可以看到如下:接着我在本地表中增加了两条测试数据:打开 mysql_bin.index可以看到内容很简单就是记录了有哪些文件:./mysql_bin.000001 ./mysql_bin.000002在 mysql 中使用下面命令去查看都有哪些日志文件。show master logs;之后我们挑选一个文件来进行查看:show binlog events in 'mysql_bin.000001';这里记录的不是 sql 语句,我们可以看到开启了一个 session 然后开启事务然后写入操作最后提交事务。每条记录的变化都会写入到日志中。5.1.5 版本的 MySQL 才开始支持 row level 的复制,它不记录 sql 语句上下文相关信息,仅保存哪条记录被修改。PXC 节点默认的日志模式就是 row 模式优点:清晰的记录了每条记录的细节数据同步安全可靠同步时出现行锁的更少缺点:日志体积太大,浪费存储空间数据同步频繁速度慢注:将二进制日志格式设置为 ROW 时,有些更改仍然使用基于语句的格式,包括所有 DDL 语句,例如 CREATE TABLE, ALTER TABLE,或 DROP TABLE。STATEMENT 模式每一条会修改数据的 sql 都会记录在 binlog 中我把 /etc/my.cnf修改成 statement 模式,然后删除mysql_bin.index和mysql_bin_00000相关文件最后重启 mysql。binlog_format = statement之后我们新建一条数据后查看我们的日志:show master logs; show binlog events in 'mysql_bin.000001';可以看到在事务中是记录的 sql 语句。优点:日志文件体积小节省 I/O 和存储资源集群节点同步速度快缺点:某些函数和主键自增长会出现同步数据不一致另外 mysql 的复制,像一些特定函数的功能,slave 与 master 要保持一致会有很多相关问题。MIXED 模式从 5.1.8 版本开始,MySQL 提供了 Mixed 格式,实际上就是 Statement 与 Row 的结合。在 Mixed 模式下,一般的语句修改使用 statment 格式保存 binlog,如一些函数,statement 无法完成主从复制的操作,则采用 row 格式保存 binlog,MySQL 会根据执行的每一条具体的 sql 语句来区分对待记录的日志形式,也就是在 Statement 和 Row 之间选择一种。还是之前的步骤,我们修改 binlog_format = mixed再增加两条数据,普通插入一条和使用函数增加一条。insert into student(id,name) values (5,UUID());之后我们查看日志:show master logs; show binlog events in 'mysql_bin.000001';可以看到在事务中是记录有 row 模式和 statement 模式。MySQL 的 5 种特殊设计1.MySQL+分布式 Proxy 扩展MySQL+分布式 Proxy 扩展分好多种情况:PXC 集群PXC 集群牺牲读写速度的代价保证数据的强一致性,在保证数据强一致性的业务中才推荐使用 PXC 集群,比如与钱相关的业务必须使用 PXC 集群,数据不一致导致的后果很难承担。由于 PXC 集群是牺牲写入速度保证数据的强一致性,增强 PXC 集群性能可以使用数据分片,比如使用 MyCat 分片,通过 MyCat 分发之后每个分片写入的压力就减少了很多,性能就能提升不少。另外在业务设计上也要避免瞬时写入的压力。Replication 集群说完了数据强一致性的 PXC 集群,我们再说一下弱一致性的 Replication 集群。用 Replication 搭建集群之后也可以使用数据分片,也可以使用 MyCat 来进行管理,也可以使用 Haproxy 和 Keepalived。PXC 集群+Replication 集群面对复杂的业务,系统会同时面对强一致性和弱一致性。我们可以将两种集群整合在一起,我们根据水平拆分的原则,把需要强一致性的数据表建立在 PXC 集群,不需要强一致性的数据表建立在 Replication 集群里。关键性的数据写在 PXC,非关键性的数据写在 RP 里。这就兼顾了强一致性,弱一致性,读写速度的矛盾。在 CRUD 语句里边最复杂的是查询语句,单表查询还好,表连接要是查询不同集群中的数据表这就会很复杂。应对这种查询方式有两种,第一种是由于 Replication 集群写入速度比 PXC 集群速度更快,我们可以使用同步中间件将 PXC 集群中的数据同步到 Replication 集群。然后在 Replication 集群里边查询表连接操作。这样就能查到你想要的数据结果。另一种方案是ETL 中间件,先把数据从不同的集群中抽取出来,然后再做表连接去查询,比如知名的 ETL 中间件Kettle。PXC 集群+Replication 集群+缓存集群说完了 PXC 集群和 Replication 集群的混合方案,如果系统对读写速度要求更高,我们还可以引入 mongodb,redis 等 NoSQL 缓存数据库。这里就要关注一下数据库集群的事务,有些人会想到 XA 事务,但是 PXC 集群不支持 XA 事务,所以这个方案并不可行。阿里巴巴有一个 GTS 的中间件,他可以把各种数据库纳入到一个事务之下,但是 GTS 只能运行在阿里云上。还有一种方案是利用消息中间件,去模拟分布式的事务,把 PXC 集群、Replication 集群、缓存集群纳入到事务之内。2.数据归档,冷热数据分离随着是数据的增加,无论是单节点的 mysql 还是 mysql 的集群,都要做冷热数据分离,冷数据可以存放到归档表,可以使用 MongoDB,也可以使用 TokuDB 来保存归档数据。mongodb 大家都熟悉,这里主要讲解一下 TokuDB。TokuDB 是 mysql 的一种存储引擎,可以高速的写入数据,写入速度是 innodb 引擎的 9 倍,压缩比是 innodb 的 14 倍,跟 mongdb 相比丝毫不逊色,TokuDB 的写入性能是 MongoDB 的 4 倍,而且还是带事务的写入。3.MySQL+缓存(Redis)高并发架构比如以发红包的案例,用户 A 发红包,把红包数据存入缓存,用户 B,C,D 抢完红包之后,再把红包数据写入到数据库中。4.MySQL+小文件系统我们可以将一些用户的图片上传到服务器,在数据库中只存储图片路径。而并非在数据库中存储 blog 类型的数据。5.MySQL+Inforbright 统计分析架构这一种 mysql 设计方案跟数据分析有关,对于数据库而言,通常是第二天之后才会有结果汇总统计分析的需求, 这类 OLAP 执行频率较低,但是每次统计的信息太多,消耗的资源很大,如果在 OLTP 系统上运行会造成两大业务的相互影响,所以我们应该把 OLAP 给独立出去,通过数据流转把 OLTP 的数据传输个 OLAP 系统,有很多成熟的 OLAP 系统比如 Inforbright 系统,在几百万到几十亿数据的规模下查询速度是 mysql 的 5-60 倍。相对而言 Inforbright 是轻量级的,而分布式的 mpp 数据仓库可以支撑更大海量数据的统计分析,所以有数据分析的系统不防试一试这种架构。向集群导入大量数据如果我们使用的 sql 文件我们可以使用 source test.sql 命令进行导入数据,在数据量不多的时候我们可以使用,如果数据量过大时间就会很长。如果要导入 100 万次的数据。mysql 要进行多少次词法分析和优化。所以说是非常的耗时。如果数据量过大,我们可以使用 LOAD DATA 来进行导入,30 万的数据大概只需要 5 秒就可以导入。因为 LOAD DATA 是从文本文档里导入纯数据,没有词法分析和优化,只需要解析每条记录的格式,数据就直接写入到表里了。比如有几十个 G 的数据,由于 LOAD DATA 是单线程写入,我们可以将文件切分成多个文件,然后交给多线程来执行,速度大大提高。导入测试数据说完了使用什么来导入数据,但是我们数据从哪里来呢,我们可以使用 Java 来生成数据。我们生成 1000 万条数据:public class Test { public static void main(String[] args) throws IOException { FileWriter writer = new FileWriter("/Users/jack/Downloads/data.txt"); BufferedWriter bufferedWriter = new BufferedWriter(writer); for (int i = 0; i < 10000000; i++) { bufferedWriter.write(i + ",测试数据\n"); } bufferedWriter.close(); writer.close(); } }配置数据文件之后将文件上传到 linux。上传完成之后我们查看一下:然后我们使用下面命令进行切分成十份,按 100 万一份切割。split -l 1000000 -d data.txt-l :按行切分-d:文件名名带排序执行成功之后我们就可以看到下图这样:接着我们在每个 PXC 分片只开启一个节点,这样就不会同步,引发数据限流。修改 PXC 节点文件,然后重启 PXC 服务innodb_flush_log_at_trx_commit = 0 innodb_flush_method = O_DIRECT innodb_buffer_pool_size = 200M接着我们在两个 PXC 节点中创建表:CREATE TABLE t_test( id INT UNSIGNED PRIMARY KEY, name VARCHAR(200) NOT NULL );修改 MyCat 配置文件我们先关闭 MyCat。vim schema.xml增加我们新建的表:<table name="t_test" dataNode="dn1,dn2" rule="mod-long" />由于我们现在只开启了两个分片中的一个节点,所以删除掉别的节点的配置信息,balance = 0 关闭读写分离。<!--配置连接信息--> <dataHost name="cluster1" maxCon="1000" minCon="10" balance="0" writeType="1" dbType="mysql" dbDriver="native" switchType="1" slaveThreshold="100"> <heartbeat>select user()</heartbeat> <writeHost host="W1" url="192.168.3.137:3306" user="admin" password="Abc_123456"> </writeHost> </dataHost> <dataHost name="cluster2" maxCon="1000" minCon="10" balance="0" writeType="1" dbType="mysql" dbDriver="native" switchType="1" slaveThreshold="100"> <heartbeat>select user()</heartbeat> <writeHost host="W1" url="192.168.3.141:3306" user="admin" password="Abc_123456"> </writeHost> </dataHost>保存启动 MyCat./mycat start写入 MySQL之后我们编写 Java 代码/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2020/11/26 * @Description: LoadData */ public class LoadData { private static int num = 0; private static int end = 0; private static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(1, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue(200)); public static void main(String[] args) throws SQLException { DriverManager.registerDriver(new Driver()); File folder = new File("/home/data"); File[] files = folder.listFiles(); end = files.length; for (File file : files) { Task task = new Task(); task.file = file; poolExecutor.execute(task); } } public static synchronized void updateNum() { num++; if (num == end) { poolExecutor.shutdown(); System.out.println("执行结束"); } } }/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2020/11/26 * @Description: Task */ public class Task implements Runnable { File file; @Override public void run() { String url = "jdbc:mysql://192.168.3.146:8066/test"; String username = "admin"; String password = "Abc_123456"; try { Connection connection = DriverManager.getConnection(url, username, password); String sql = " load data local infile '/home/data/" + file.getName() + "' ignore into table t_test \n" + " character set 'utf8' \n" + " fields terminated by ',' optionally enclosed by '\\\"' \n" + " lines terminated by '\\n' (id,name); "; PreparedStatement pst = connection.prepareStatement(sql); pst.execute(); connection.close(); LoadData.updateNum(); } catch (SQLException e) { e.printStackTrace(); } } }完成后打包拷贝到服务器上,执行命令运行 Java 代码java -jar 名字.jar成功后,我们执行 SQL 查看一下:select count(*) from t_test;写入完成后,我们停止两个 PXC 节点,将刚才添加语句的删除掉。systemctl stop mysql@bootstrap.servicevim /etc/my.cnf删除下面配置:innodb_flush_log_at_trx_commit = 0 innodb_flush_method = O_DIRECT innodb_buffer_pool_size = 200M然后我们把其他的 PXC 节点启动,会自动进行全量同步。service mysql start接着把我们刚才在 MyCat 配置文件中删除的其他 PXC 节点信息加回去,进入 conf 目录下:vim schema.xml<schema name="test" checkSQLschema="false" sqlMaxLimit="100"> <table name="t_test" dataNode="dn1,dn2" rule="mod-long" /> <table name="t_user" dataNode="dn1,dn2" rule="mod-long" /> <table name="t_customer" dataNode="dn1,dn2" rule="sharding-customer"> <childTable name="t_orders" primaryKey="ID" joinKey="customer_id" parentKey="id"/> </table> </schema> <!--配置分片关系--> <dataNode name="dn1" dataHost="cluster1" database="test" /> <dataNode name="dn2" dataHost="cluster2" database="test" /> <!--配置连接信息--> <dataHost name="cluster1" maxCon="1000" minCon="10" balance="2" writeType="1" dbType="mysql" dbDriver="native" switchType="1" slaveThreshold="100"> <heartbeat>select user()</heartbeat> <writeHost host="W1" url="192.168.3.137:3306" user="admin" password="Abc_123456"> <readHost host="W1R1" url="192.168.3.138:3306" user="admin" password="Abc_123456" /> <readHost host="W1R2" url="192.168.3.139:3306" user="admin" password="Abc_123456" /> </writeHost> <writeHost host="W2" url="192.168.3.138:3306" user="admin" password="Abc_123456"> <readHost host="W2R1" url="192.168.3.137:3306" user="admin" password="Abc_123456" /> <readHost host="W2R2" url="192.168.3.139:3306" user="admin" password="Abc_123456" /> </writeHost> </dataHost> <dataHost name="cluster2" maxCon="1000" minCon="10" balance="2" writeType="1" dbType="mysql" dbDriver="native" switchType="1" slaveThreshold="100"> <heartbeat>select user()</heartbeat> <writeHost host="W1" url="192.168.3.141:3306" user="admin" password="Abc_123456"> <readHost host="W1R1" url="192.168.3.143:3306" user="admin" password="Abc_123456" /> <readHost host="W1R2" url="192.168.3.144:3306" user="admin" password="Abc_123456" /> </writeHost> <writeHost host="W2" url="192.168.3.143:3306" user="admin" password="Abc_123456"> <readHost host="W2R1" url="192.168.3.141:3306" user="admin" password="Abc_123456" /> <readHost host="W2R2" url="192.168.3.144:3306" user="admin" password="Abc_123456" /> </writeHost> </dataHost> </mycat:schema>启动 MyCat,进入 bin 目录下./mycat start启动之后我们查询一下别的 PXC 分片,看看数据是否同步过去:select count(*) from t_test可以看到 500 万条数据,没问题。MySQL 数据库设计核心原则不在数据库做运算CPU 计算必须在业务层执行控制字段数量平衡范式与冗余拒绝大 SQL 语句、拒绝大事务、拒绝大批量用恰当的数据类型字符转化为数字(节约空间,提高查询性能)避免使用 NULL 字段(NULL 很难查询优化,索引需要额外的空间,而且复合索引无效)避免使用 text 类型索引设计原则合理使用索引长字符串必须建前缀索引不在索引列做运算不用外键(使用逻辑外键)SQL 设计原则SQL 语句尽可能简单尽可能使用简单的事务避免使用触发器和存储过程OR 改写为 INOR 改写为 UNION
MySQL 数据库集群-PXC 方案(二)集群状态信息PXC 集群信息可以分为队列信息、复制信息、流控信息、事务信息、状态信息。这些信息可以通过 SQL 查询到。每种信息的详细意义可以在官网查看。show status like '%wsrep%';复制信息举例说明几个重要的信息:状态描述wsrep_replicated被其他节点复制的次数wsrep_replicated_bytes被其他节点复制的数据次数wsrep_received从其他节点处收到的写入请求总数wsrep_received_bytes从其他节点处收到的写入数据总数wsrep_last_applied同步应用次数wsrep_last_committed事务提交次数队列信息队列是一种很好的缓存机制,如果 PXC 正在满负荷工作,没有线程去执行数据的同步,同步请求会缓存到队列中,然后空闲线程从队列中取出任务,执行同步的请求,有了队列 PXC 就能用少量的线程应对瞬时大量的同步请求。状态描述wsrep_local_send_queue发送队列的长度(瞬时同步的请求数量)wsrep_local_send_queue_max发送队列的最大长度wsrep_local_send_queue_min发送队列的最小长度wsrep_local_send_queue_avg发送队列的平均长度wsrep_local_recv_queue接收队列的长度wsrep_local_recv_queue_max接收队列的最大长度wsrep_local_recv_queue_min接收队列的最小长度wsrep_local_recv_queue_avg接收队列的平均长度当发送队列的平均长度(wsrep_local_send_queue_avg)值很大,发送队列的长度(wsrep_local_send_queue)也很大的时候,说明 PXC 集群同步数据的速度已经很慢了,队列里边积压了大量的同步请求,这个时候就要检查一下网速是不是正常,或者同步的线程数量是不是太少。当接收队列的平均长度(wsrep_local_recv_queue_avg)值很大,接收队列的长度(wsrep_local_recv_queue)也很大的时候,这说明本地没有足够的线程去执行持久化的操作,增加线程就可以解决这个问题。流量控制信息流量控制就是 PXC 集群在同步速度较慢的情况下,为了避免同步速度跟不上写入速度而推出的一种限速机制,就是限制数据的写入,直到同步队列的长度变小,同步速度变快为止,才会解除流量控制。流量控制的后果很严重,而且一个很小的操作就会引发流量控制。状态说明wsrep_flow_control_paused_ns流控暂停状态下花费的总时间(纳秒)wsrep_flow_control_paused流量控制暂停时间的占比(0~1)wsrep_flow_control_sent发送的流控暂停事件的数量wsrep_flow_control_recv接收的流控暂停事件的数量wsrep_flow_control_interval流量控制的下限和上限。上限是队列中允许的最大请求数。如果队列达到上限,则拒绝新的请求。当处理现有请求时,队列会减少,一旦到达下限,将再次允许新的请求wsrep_flow_control_status流量控制状态 OFF:关闭 0N: 开启流控的主要原因节点之间同步的速度慢,队列积压了大量的请求,这才是流控的主要原因。流控解决办法:改善网速,提高带宽,更换交换机,千兆网卡更换成万兆网卡增加线程,线程多了执行的速度也就快了。队列里边就不会积压大量的请求提升硬件性能,比如升级 CPU,内存以及更换光纤硬盘等等都可以提高写入速度第一项和第三项属于更换硬件的方法,主要说一下第二个方法增加线程数提升同步速度。在 PXC 的配置文件加上wsrep_slave_threads参数。代表的是本地执行队列的线程数量,一般这个数是 CPU 线程数的 1-1.5 倍。比如服务器 CPU 是 8 核 16 线程的,这里就可以写 16-24 就可以。wsrep_slave_threads=16节点与集群的状态信息状态说明wsrep_local_state_comment节点状态wsrep_cluster_status集群状态(Primary:正常状态、Non-Primary:出现了脑裂请求、Disconnected:不能提供服务,出现宕机)wsrep_connected节点是否连接到集群wsrep_ready集群是否正常工作wsrep_cluster_size节点数量wsrep_desync_count延时节点数量wsrep_incoming_addresses集群节点 IP 地址事务相关信息状态说明wsrep_cert_deps_distance事务执行并发数wsrep_apply_oooe接收队列中事务的占比wsrep_apply_oool接收队列中事务乱序执行的频率wsrep_apply_window接收队列中事务的平均数量wsrep_commit_oooe发送队列中事务的占比wsrep_commit_oool无任何意义,不存在本地的乱序提交wsrep_commit_window发送队列中事务的平均数量PXC 节点的安全下线操作节点用什么命令启动,就用对应的关闭命令去关闭。主节点的管理命令(第一个启动的 PXC 节点)systemctl start mysql@bootstrap.service systemctl stop mysql@bootstrap.service systemctl restart mysql@bootstrap.service非主节点的管理命令(非第一个启动的 PXC 节点)service mysql start service mysql stop service mysql restart如果最后关闭的 PXC 节点是安全退出的,那么下次启动要最先启动这个节点,而且要以主节点启动。如果最后关闭的 PXC 节点不是安全退出的,那么要先修改/var/lib/mysql/grastate.dat 文件,把其中的safe_to_bootstrap属性值设置为 1,再按照主节点启动。意外下线部分节点安全下线节点不会让剩下的节点宕机,如果节点意外退出,集群的规模不会缩小,意外退出的节点超过半数,比如三个节点意外退出了 2 个节点,那么剩下的节点就不能够读写了。其他节点按照普通节点启动上线即可恢复 pxc 集群。意外下线全部节点,不同时退出如果三个节点都意外退出,那么查看/var/lib/mysql/grastate.dat文件,看看哪个文件的safe_to_bootstarp的值是 1,那么那个节点是最后意外关闭的,再按照safe_to_bootstarp的值启动 pxc 集群。意外下线全部节点,同时退出如果三个节点同时意外退出,我们需要修改配置文件,挑选一个节点作为主节点,修改safe_to_bootstarp的值设置为 1,那么这个节点可以以主节点启动。配置 MyCat 负载均衡准备工作(一)我们需要创建两个 PXC 集群,充当两个分片。上文中已经创建出来了一个分片,参考步骤然后创建出来第二个分片。最后会有 6 个 centos 虚拟机。我的第一个如下:主:192.168.3.137从:192.168.3.138从:192.168.3.139我的第二个如下:主:192.168.3.141从:192.168.3.143从:192.168.3.144MyCat : 192.168.3.146准备工作(二)在 192.168.3.146 Centos 服务器上进行如下操作:由于 MyCat 是依赖 jdk 的所以我们先安装 jdk 环境。yum install -y java-1.8.0-openjdk-devel.x86_64配置 JAVA_HOME 环境变量ls -lrt /etc/alternatives/java vim /etc/profile source /etc/profileexport JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk-1.8.0.272.b10-1.el7_9.x86_64/输入 java -version 如下图就完成配置。准备工作(三)下载 mycathttp://dl.mycat.org.cn/1.6.5/上传 MyCat 压缩包到虚拟机,并解压。开放防火墙 8066 和 9066 端口:firewall-cmd --zone=public --add-port=8066/tcp --permanent firewall-cmd --zone=public --add-port=9066/tcp --permanent firewall-cmd --reload关闭 SELINUXvim /etc/selinux/config把 SELINUX 属性值设置成 disabled,之后保存。重启。reboot修改 MyCat 的 bin 目录中所有.sh 文件的权限:chmod -R 777 ./*.shMyCat 启动与关闭启动MyCat: ./mycat start 查看启动状态: ./mycat status 停止: ./mycat stop 重启: ./mycat restart准备工作(四)修改配置文件:修改 server.xml 文件,设置 MyCat 帐户和虚拟逻辑库<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mycat:server SYSTEM "server.dtd"> <mycat:server xmlns:mycat="http://io.mycat/"> <system> <property name="nonePasswordLogin">0</property> <property name="useHandshakeV10">1</property> <property name="useSqlStat">0</property> <property name="useGlobleTableCheck">0</property> <property name="sequnceHandlerType">2</property> <property name="subqueryRelationshipCheck">false</property> <property name="processorBufferPoolType">0</property> <property name="handleDistributedTransactions">0</property> <property name="useOffHeapForMerge">1</property> <property name="memoryPageSize">64k</property> <property name="spillsFileBufferSize">1k</property> <property name="useStreamOutput">0</property> <property name="systemReserveMemorySize">384m</property> <property name="useZKSwitch">false</property> </system> <!--这里是设置的admin用户和虚拟逻辑库--> <user name="admin" defaultAccount="true"> <property name="password">Abc_123456</property> <property name="schemas">test</property> </user> </mycat:server>修改 schema.xml 文件,设置数据库连接和虚拟数据表<?xml version="1.0"?> <!DOCTYPE mycat:schema SYSTEM "schema.dtd"> <mycat:schema xmlns:mycat="http://io.mycat/"> <!--配置数据表--> <schema name="test" checkSQLschema="false" sqlMaxLimit="100"> <table name="t_user" dataNode="dn1,dn2" rule="mod-long" /> </schema> <!--配置分片关系--> <dataNode name="dn1" dataHost="cluster1" database="test" /> <dataNode name="dn2" dataHost="cluster2" database="test" /> <!--配置连接信息--> <dataHost name="cluster1" maxCon="1000" minCon="10" balance="2" writeType="1" dbType="mysql" dbDriver="native" switchType="1" slaveThreshold="100"> <heartbeat>select user()</heartbeat> <writeHost host="W1" url="192.168.3.137:3306" user="admin" password="Abc_123456"> <readHost host="W1R1" url="192.168.3.138:3306" user="admin" password="Abc_123456" /> <readHost host="W1R2" url="192.168.3.139:3306" user="admin" password="Abc_123456" /> </writeHost> <writeHost host="W2" url="192.168.3.138:3306" user="admin" password="Abc_123456"> <readHost host="W2R1" url="192.168.3.137:3306" user="admin" password="Abc_123456" /> <readHost host="W2R2" url="192.168.3.139:3306" user="admin" password="Abc_123456" /> </writeHost> </dataHost> <dataHost name="cluster2" maxCon="1000" minCon="10" balance="2" writeType="1" dbType="mysql" dbDriver="native" switchType="1" slaveThreshold="100"> <heartbeat>select user()</heartbeat> <writeHost host="W1" url="192.168.3.141:3306" user="admin" password="Abc_123456"> <readHost host="W1R1" url="192.168.3.143:3306" user="admin" password="Abc_123456" /> <readHost host="W1R2" url="192.168.3.144:3306" user="admin" password="Abc_123456" /> </writeHost> <writeHost host="W2" url="192.168.3.143:3306" user="admin" password="Abc_123456"> <readHost host="W2R1" url="192.168.3.141:3306" user="admin" password="Abc_123456" /> <readHost host="W2R2" url="192.168.3.144:3306" user="admin" password="Abc_123456" /> </writeHost> </dataHost> </mycat:schema>修改 rule.xml 文件,把 mod-long 的 count 值修改成 2<function name="mod-long" class="io.mycat.route.function.PartitionByMod"> <property name="count">2</property> </function>重启 MyCat。测试在两个分片中都创建 t_user 表:CREATE TABLE t_user( id INT UNSIGNED PRIMARY KEY, username VARCHAR(200) NOT NULL, password VARCHAR(2000) NOT NULL, tel CHAR(11) NOT NULL, locked TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, INDEX idx_username(username) USING BTREE, UNIQUE INDEX unq_username(username) USING BTREE );远程连接 mycat。向 t_user 表写入数据,感受数据的切分。USE test; select * from t_user; #第一条记录被切分到第二个分片 INSERT INTO t_user(id,username,password,tel,locked) VALUES(1,"Jack",HEX(AES_ENCRYPT('123456','HelloWorld')),'1333222111',false); #第二条记录被切分到第一个分片 INSERT INTO t_user(id,username,password,tel,locked) VALUES(2,"Rose",HEX(AES_ENCRYPT('123456','HelloWorld')),'1335555111',false);可以查看对应的库中都是没有问题的。数据切分切分算法适用场合备注主键求模切分数据增长缓慢,难于增加分片有明确主键值枚举值切分归类存储数据,适用于大多数业务主键范围切分数据快速增长,容易增加分片有明确主键值日期切分数据快速增长,容易增加分片主键求模切分上面的示例中,使用的就是主键求模切分,其特点如下:主键求模切分适合用在初始数据很大,但是数据增长不快的场景。例如,地图产品、行政数据、企业数据等。主键求模切分的弊端在于扩展新分片难度大,迁移的数据太多。如果需要扩展分片数量,建议扩展后的分片数量是原有分片的 2n 倍。例如,原本是两个分片,扩展后是四个分片。主键范围切分主键范围切分适合用在数据快速增长的场景。容易增加分片,需要有明确的主键列。日期切分日期切分适合用在数据快速增长的场景。容易增加分片,需要有明确的日期列。枚举值切分枚举值切分适合用在归类存储数据的场景,适合大多数业务。枚举值切分按照某个字段的值(数字)与mapFile配置的映射关系来切分数据。枚举值切分的弊端在于分片存储的数据不够均匀。在rule.xml中增加配置如下:<!-- 定义分片规则 --> <tableRule name="sharding-customer"> <rule> <!-- 定义使用哪个列作为分片列 --> <columns>sharding_id</columns> <algorithm>customer-hash-int</algorithm> </rule> </tableRule> <!-- 定义分片算法 --> <function name="customer-hash-int" class="io.mycat.route.function.PartitionByFileMap"> <!-- 定义mapFile的文件名,位于conf目录下 --> <property name="mapFile">customer-hash-int.txt</property> </function>在conf目录下创建customer-hash-int.txt文件,定义区号与分片索引的对应关系:0 代表第一个分片,1 代表第二个分片。101=0 102=0 103=0 104=1 105=1 106=1配置schema.xml,增加一个逻辑表,并将其分片规则设置为sharding-customer:<schema name="test" checkSQLschema="true" sqlMaxLimit="100" randomDataNode="dn1"> <table name="t_user" dataNode="dn1,dn2" rule="mod-long"/> <table name="t_customer" dataNode="dn1,dn2" rule="sharding-customer"/> </schema>进入 MyCat 中执行热加载语句,该语句的作用可以使 Mycat 不用重启就能应用新的配置:reload @@config_all;在两个分片中分别执行如下建表语句:USE test; CREATE TABLE t_customer( id INT UNSIGNED PRIMARY KEY, username VARCHAR(200) NOT NULL, sharding_id INT NOT NULL );之后我们在 MyCat 中进行查询看看:也是 ok 的。接着我们增加一条数据:insert into t_customer(id,username,sharding_id) values (1,'Michelle',101); insert into t_customer(id,username,sharding_id) values (2,'Jack',102);查看第一个分片可以看到数据已经切分过来了如果我们再增加一条 SQL:insert into t_customer(id,username,sharding_id) values (3,'Smith',105);很显然,数据被分到了第二个分片中。父子表当有关联的数据存储在不同的分片时,就会遇到表连接的问题,在 MyCat 中是不允许跨分片做表连接查询的。为了解决跨分片表连接的问题,MyCat 提出了父子表这种解决方案。父子表规定父表可以有任意的切分算法,但与之关联的子表不允许有切分算法,即子表的数据总是与父表的数据存储在一个分片中。父表不管使用什么切分算法,子表总是跟随着父表存储。例如,用户表与订单表是有关联关系的,我们可以将用户表作为父表,订单表作为子表。当 A 用户被存储至分片 1 中,那么 A 用户产生的订单数据也会跟随着存储在分片 1 中,这样在查询 A 用户的订单数据时就不需要跨分片了。如下图所示:配置父子表在t_customer中增加 childTable。需要注意的是一个 table 可以有多个 childTable。<table name="t_customer" dataNode="dn1,dn2" rule="sharding-customer"> <childTable name="t_orders" primaryKey="ID" joinKey="customer_id" parentKey="id"/> </table>进入 MyCat 中执行热加载语句,该语句的作用可以使 Mycat 不用重启就能应用新的配置:reload @@config_all;在两个分片中执行建表 SQL:USE test; CREATE TABLE t_orders( id INT UNSIGNED PRIMARY KEY, customer_id INT NOT NULL, datetime TIMESTAMP DEFAULT CURRENT_TIMESTAMP );执行成功后,我们插入些数据测试:USE test; insert into t_orders(id,customer_id) values (1,1); insert into t_orders(id,customer_id) values (2,1); insert into t_orders(id,customer_id) values (3,1); insert into t_orders(id,customer_id) values (4,2); insert into t_orders(id,customer_id) values (5,3);我们在第一个分片中增加了两个用户 id 为 1 、2 的,在第二个分片中增加了一个用户 id 为 3 的。所以第一个分片会有 4 条记录,而第二个分片中有一条。由于父子表的数据都是存储在同一个分片,所以在 MyCat 上进行关联查询也是没有问题的:组建双机热备的高可用 MyCat 集群在之前的示例中,我们可以看到对后端数据库集群的读写操作都是在 MyCat 上进行的。MyCat 作为一个负责接收客户端请求,并将请求转发到后端数据库集群的中间件,不可避免的需要具备高可用性。否则,如果 MyCat 出现单点故障,那么整个数据库集群也就无法使用了,这对整个系统的影响是十分巨大的。所以我们现在将要演示如何去构建一个高可用的 MyCat 集群,为了搭建 MyCat 高可用集群,除了要有两个以上的 MyCat 节点外,还需要引入 Haproxy 和 Keepalived 组件。其中 Haproxy 作为负载均衡组件,位于最前端接收客户端的请求并将请求分发到各个 MyCat 节点上,用于保证 MyCat 的高可用。而 Keepalived 则用于实现双机热备,因为 Haproxy 也需要高可用,当一个 Haproxy 宕机时,另一个备用的 Haproxy 能够马上接替。也就说同一时间下只会有一个 Haproxy 在运行,另一个 Haproxy 作为备用处于等待状态。当正在运行中的 Haproxy 因意外宕机时,Keepalived 能够马上将备用的 Haproxy 切换到运行状态。Keepalived 是让主机之间争抢同一个虚拟 IP(VIP)来实现高可用的,这些主机分为 Master 和 Backup 两种角色,并且 Master 只有一个,而 Backup 可以有多个。最开始 Master 先获取到 VIP 处于运行状态,当 Master 宕机后,Backup 检测不到 Master 的情况下就会自动获取到这个 VIP,此时发送到该 VIP 的请求就会被 Backup 接收到。这样 Backup 就能无缝接替 Master 的工作,以实现高可用。引入这些组件后,最终我们的集群架构将演变成这样子:Haproxy包括以下一些特征:根据静态分配的cookie分配HTTP请求。分配负载到各个服务器,同时保证服务器通过使用HTTP Cookie实现连接保持。当主服务器宕机时切换到备服务器,允许特殊端口的服务监控。做维护时通过配置可以保证业务的连续性,更加人性化。添加修改删除HTTP Request和Respone头。通过特定表达式Block HTTP请求。根据应用的cookie做连接保持。常有用户验证的详细的HTML监控报告。Haproxy的负载均衡算法现在具体有如下8种:roundrobin:简单的轮询。static-rr:权重轮询。leastconn:最少连接者优先。source:根据请求源IP,这个跟Nginx的ip_hash机制类似。ri:根据请求的URI。rl_param:表示根据请求的URI参数。hdr(name):根据HTTP请求头来锁定每一次HTTP请求。rdp-cookie(name):根据cookie来锁定并哈希每一次TCP请求。这里就不再演示如何搭建第二台 Mycat 环境了。这里再说一下目前我的集群:第一个 PXC 分片:主:192.168.3.137从:192.168.3.138从:192.168.3.139第二个 PXC 分片:主:192.168.3.141从:192.168.3.143从:192.168.3.144第一台 MyCat : 192.168.3.146第二台 MyCat: 192.168.3.147安装 Haproxy由于我电脑只有 8G 内存,有点吃不消所以我的 Haproxy 都在 Mycat 服务器上。开放防火墙 3306 和 4001 端口:端口作用3306TCP/IP 转发端口4001监控界面端口firewall-cmd --zone=public --add-port=3306/tcp --permanent firewall-cmd --zone=public --add-port=4001/tcp --permanent firewall-cmd --reload关闭 SELINUXvim /etc/selinux/config把 SELINUX 属性值设置成 disabled,之后保存。重启。reboot接着我们安装 haproxyyum install -y haproxy安装完成后我们修改对应的配置文件vim /etc/haproxy/haproxy.cfg记得修改为自己的 MyCat 的 IP。global log 127.0.0.1 local2 chroot /var/lib/haproxy pidfile /var/run/haproxy.pid maxconn 4000 user haproxy group haproxy daemon # turn on stats unix socket stats socket /var/lib/haproxy/stats defaults mode http log global option httplog option dontlognull option http-server-close option forwardfor except 127.0.0.0/8 option redispatch retries 3 timeout http-request 10s timeout queue 1m timeout connect 10s timeout client 1m timeout server 1m timeout http-keep-alive 10s timeout check 10s maxconn 3000 listen admin_stats # 绑定的ip及监听的端口 bind 0.0.0.0:4001 # 访问协议 mode http # URI 相对地址 stats uri /dbs # 统计报告格式 stats realm Global\ statistics # 用于登录监控界面的账户密码 stats auth admin:abc123456 listen proxy-mysql # 绑定的ip及监听的端口 bind 0.0.0.0:3306 # 访问协议 mode tcp # 负载均衡算法 balance roundrobin #日志格式 option tcplog # 需要被负载均衡的主机 server mycat_1 192.168.3.146:8066 check port 8066 weight 1 maxconn 2000 server mycat_2 192.168.3.147:8066 check port 8066 weight 1 maxconn 2000 #使用keepalive检测死链 option tcpka配置完成之后我们进行启动service haproxy start我们在浏览器输入 IP 测试:http://192.168.3.146:4001/dbs输入我们配置的账号密码后就可以看到如下图:Haproxy 的监控界面提供的监控信息也比较全面,在该界面下,我们可以看到每个主机的连接信息及其自身状态。当主机无法连接时,Status一栏会显示DOWN,并且背景色也会变为红色。正常状态下的值则为UP,背景色为绿色。另一个 Haproxy 节点也是使用以上的步骤进行安装和配置,这里就不再重复了。测试 Haproxy我们远程连接一下试试:可以发现新增都是没有问题的。我们搭建 Haproxy 是为了让 MyCat 具备高可用的,所以最后测试一下 MyCat 是否已具备有高可用性,我们将 147 的 MyCat 停掉。此时,从 Haproxy 的监控界面中,可以看到mycat_2这个节点已经处于下线状态了:接着我们再去增加一条数据试试。从测试结果可以看到,插入和查询语句依旧是能正常执行的。也就是说即便此时关掉一个 MyCat 节点整个数据库集群还能够正常使用,说明现在 MyCat 集群是具有高可用性了。利用 Keepalived 实现 Haproxy 的高可用实现了 MyCat 集群的高可用之后,我们还得实现 Haproxy 的高可用,因为现在的架构已经从最开始的 MyCat 面向客户端变为了 Haproxy 面向客户端。而同一时间只需要存在一个可用的 Haproxy,否则客户端就不知道该连哪个 Haproxy 了。这也是为什么要采用 VIP 的原因,这种机制能让多个节点互相接替时依旧使用同一个 IP,客户端至始至终只需要连接这个 VIP。所以实现 Haproxy 的高可用就要轮到 Keepalived 出场了。先开启防火墙的 VRRP 协议:#开启VRRP firewall-cmd --direct --permanent --add-rule ipv4 filter INPUT 0 --protocol vrrp -j ACCEPT #应用设置 firewall-cmd --reload安装 Keepalivedyum install -y keepalived编辑配置文件vim /etc/keepalived/keepalived.confvrrp_instance VI_1 { state MASTER interface enp0s3 virtual_router_id 51 priority 100 advert_int 1 authentication { auth_type PASS auth_pass 123456 } virtual_ipaddress { 192.168.3.177 } }配置说明:state MASTER:定义节点角色为 master,当角色为 master 时,该节点无需争抢就能获取到 VIP。集群内允许有多个 master,当存在多个 master 时,master 之间就需要争抢 VIP。为其他角色时,只有 master 下线才能获取到 VIPinterface ens32:定义可用于外部通信的网卡名称,网卡名称可以通过ip addr命令查看virtual_router_id 51:定义虚拟路由的 id,取值在 0-255,每个节点的值需要唯一,也就是不能配置成一样的priority 100:定义权重,权重越高就越优先获取到 VIPadvert_int 1:定义检测间隔时间为 1 秒authentication:定义心跳检查时所使用的认证信息auth_type PASS:定义认证类型为密码auth_pass 123456:定义具体的密码virtual_ipaddress:定义虚拟 IP(VIP),需要为同一网段下的 IP,并且每个节点需要一致完成以上配置后,启动 keepalived 服务:service keepalived start我们 ping 一下我们的虚拟地址试试,也是 OK 的!另外一台步骤一模一样,这里就不演示了!测试 Keepalived以上我们完成了 Keepalived 的安装与配置,最后我们来测试 Haproxy 是否已具有高可用性。连接成功后,执行一些语句测试能否正常插入、查询数据:最后测试一下 Haproxy 的高可用性,将其中一个 Haproxy 节点上的 keepalived 服务给关掉。service keepalived stop然后再次执行执行一些语句测试能否正常插入、查询数据,如下能正常执行代表 Haproxy 节点已具有高可用性:大功告成!
MySQL 数据库集群-PXC 方案(一)什么是 PXCPXC 是一套 MySQL 高可用集群解决方案,与传统的基于主从复制模式的集群架构相比 PXC 最突出特点就是解决了诟病已久的数据复制延迟问题,基本上可以达到实时同步。而且节点与节点之间,他们相互的关系是对等的。PXC 最关注的是数据的一致性,对待事物的行为时,要么在所有节点上执行,要么都不执行,它的实现机制决定了它对待一致性的行为非常严格,这也能非常完美的保证 MySQL 集群的数据一致性;PXC 的特点完全兼容 MySQL。同步复制,事务要么在所有节点提交或不提交。多主复制,可以在任意节点进行写操作。在从服务器上并行应用事件,真正意义上的并行复制。节点自动配置,数据一致性,不再是异步复制。故障切换:因为支持多点写入,所以在出现数据库故障时可以很容易的进行故障切换。自动节点克隆:在新增节点或停机维护时,增量数据或基础数据不需要人工手动备份提供,galera cluster 会自动拉取在线节点数据,集群最终会变为一致。PXC 的优缺点优点:服务高可用。数据同步复制(并发复制),几乎无延迟。多个可同时读写节点,可实现写扩展,不过最好事先进行分库分表,让各个节点分别写不同的表或者库,避免让 galera 解决数据冲突。新节点可以自动部署,部署操作简单。数据严格一致性,尤其适合电商类应用。完全兼容 MySQL。缺点:复制只支持InnoDB 引擎,其他存储引擎的更改不复制。写入效率取决于节点中最弱的一台,因为 PXC 集群采用的是强一致性原则,一个更改操作在所有节点都成功才算执行成功。所有表都要有主键。不支持 LOCK TABLE 等显式锁操作。锁冲突、死锁问题相对更多。PXC 集群节点越多,数据同步的速度就越慢。PXC 与 Replication 的区别ReplicationPXC数据同步是单向的,master 负责写,然后异步复制给 slave;如果 slave 写入数据,不会复制给 master数据同步时双向的,任何一个 mysql 节点写入数据,都会同步到集群中其它的节点异步复制,从和主无法保证数据的一致性同步复制,事务在所有集群节点要么同时提交,要么同时不提交PXC 常用端口3306:数据库对外服务的端口号。4444:请求 SST 的端口。4567:组成员之间进行沟通的一个端口号。4568:用于传输 IST。SST(State Snapshot Transfer): 全量传输IST(Incremental state Transfer):增量传输MySQL 衍生版选择MariaDB起初 MySQL 之父 Monty 在 1979 年写下 MySQL 的第一行代码,后来逐渐创建起 MySQL 公司,后将其以 10 亿美金卖给 Sun,结果 Sun 又把 MySQL 转手卖给 Oracle,Monty 愤而出走,以 MySQL5.5 为基础创造了 MariaDB 数据库,这样就诞生出了 MySQL 分支里知名度最高的一个衍生版。Percona ServerPercona Server 是 MySQL 咨询公司 Percona 发布的性能最接近 MySQL 企业版的 MySQL 产品。Percona 公司在 MySQL 数据库优化方面做了非常多的工作,以至于 Percona Server 数据库是 MySQL 众多分支中,在高负载、高并发情况下表现非常突出,乃至阿里巴巴的 OceanBase 数据库都要借鉴 Percona Server。XtraDB 引擎是 Percona 公司开发设计的,与 MySQL5.1 内置的 InnoDB 相比,单位时间执行事务数量是后者的 2.7 倍。而且在 Percona Server 上面默认使用的也是 XtraDB 引擎。所以说 MariaDB 和 Percona Server 在正常情况下的性能基本持平。但是在高并发和高负载的条件下,Percona Server 的表现更好一些。所以本文采用 Percona Server 来搭建。安装 Percona Server首先打开 https://www.percona.com/downl...选择对应版本,由于 Percona Server 只支持 Linux 所以我们就选择对应版本即可。此时我们也需要下载 jemalloc 下载地址:https://cbs.centos.org/koji/t...最下方选择 jemalloc-3.6.0-8.el7.centos.x86_64.rpm 。下载完成后,我们将文件拷贝到 linux 的 home 目录下:执行下面命令进行安装:yum localinstall *.rpm执行完成后我们启动数据库:systemctl start mysqld开放 3306 端口:firewall-cmd --zone=public --add-port=3306/tcp --permanent firewall-cmd --reload接下来要修改 MySQL 配置文件:vim /etc/my.cnf [mysqld] character_set_server = utf8 bind-address = 0.0.0.0 #跳过DNS解析 skip-name-resolve完成之后重启 mysqlsystemctl restart mysqld禁止开机启动 MySQL,防止 pxc 集群假如有一个节点宕机了,重启后会自动与一个 pxc 集群进行数据同步。如果宕机时间过长,同步数据非常多,这个时候会限制其它写入操作。所以正确操作,应该是拷贝数据文件再启动 mysql。chkconfig mysqld off查看 MySQL 初始密码cat /var/log/mysqld.log | grep "A temporary password"修改 MySQL 密码mysql_secure_installation登录 mysql 并赋予远程登录权限mysql -u root -pGRANT ALL PRIVILEGES ON *.* TO 'root'@'%' IDENTIFIED BY '123456' WITH GRANT OPTION; FLUSH PRIVILEGES;搭建 PXC 集群Percona XtraDB Cluster (简称 PXC)集群是基于 Galera 2.x library,事务型应用下的通用的多主同步复制插件,主要用于解决强一致性问题,使得各个节点之间的数据保持实时同步以及实现多节点同时读写。提高了数据库的可靠性,也可以实现读写分离,是 MySQL 关系型数据库中大家公认的集群优选方案之一。删除 MariaDB 程序包yum -y remove mari*开放防火墙端口,PXC 集群使用的四个端口firewall-cmd --zone=public --add-port=3306/tcp --permanent firewall-cmd --zone=public --add-port=4444/tcp --permanent firewall-cmd --zone=public --add-port=4567/tcp --permanent firewall-cmd --zone=public --add-port=4568/tcp --permanent firewall-cmd --reload关闭 SELINUXvim /etc/selinux/config把 SELINUX 属性值设置成 disabled,之后保存。重启reboot下载 PXC 安装包安装 PXC 里面集成了 Percona Server 数据库,所以不需要安装 Percona Server 数据库。https://www.percona.com/downl...https://www.percona.com/downl...下载 qpress-11-1.el7.x86_64.rpmhttps://repo.percona.com/yum/...最后上传到 centos 服务器中。执行下面命令进行安装yum localinstall *.rpm之后还是对 Percona Server 数据库的初始化:修改/etc/my.cnf我们查看之后可以看到里面内容不是之前 mysql 的配置信息我们进入 etc/percona-xtradb-cluster.conf.d/文件夹下可以看到有三个文件:mysql 的常用信息都写在了 mysqld.cnf 文件,wsrep.cnf 文件配置的是 pxc 集群的信息。我们可以简化一下配置文件,将 mysqld.cnf 文件和 wsrep.cnf 文件的内容复制到/etc/my.cnf 文件中,把所有配置信息写到一个文件中,并添加字符集等配置信息。最终/etc/my.cnf 内容如下:[client] socket=/var/lib/mysql/mysql.sock [mysqld] server-id=1 datadir=/var/lib/mysql socket=/var/lib/mysql/mysql.sock log-error=/var/log/mysqld.log pid-file=/var/run/mysqld/mysqld.pid log-bin log_slave_updates expire_logs_days=7 character_set_server = utf8 bind-address = 0.0.0.0 #跳过DNS解析 skip-name-resolve # Disabling symbolic-links is recommended to prevent assorted security risks symbolic-links=0然后我们启动 mysqlsystemctl start mysqld查看默认密码cat /var/log/mysqld.log | grep "A temporary password"修改 MySQL 密码:这里密码就修改为 Abc_123456mysql_secure_installation进入 mysqlmysql -u root -p创建用户修改权限:CREATE USER 'admin'@'%' IDENTIFIED BY 'Abc_123456'; GRANT all privileges ON *.* TO 'admin'@'%'; FLUSH PRIVILEGES;之后我们 exit 退出,停止 mysql 服务systemctl stop mysqld在/etc/my.cnf 中配置集群信息:[client] socket=/var/lib/mysql/mysql.sock [mysqld] # PXC集群中MySQL实例的唯一ID,不能重复,必须是数字 server-id=1 datadir=/var/lib/mysql socket=/var/lib/mysql/mysql.sock log-error=/var/log/mysqld.log pid-file=/var/run/mysqld/mysqld.pid log-bin log_slave_updates expire_logs_days=7 character_set_server = utf8 bind-address = 0.0.0.0 # 跳过DNS解析 skip-name-resolve # Disabling symbolic-links is recommended to prevent assorted security risks symbolic-links=0 # 集群信息 wsrep_provider=/usr/lib64/galera3/libgalera_smm.so # PXC集群的名称 wsrep_cluster_name=pxc-cluster # PXC集群的所有ip wsrep_cluster_address=gcomm://192.168.3.137,192.168.3.138,192.168.3.139 # 当前节点的名称 wsrep_node_name=pxc1 # 当前节点的IP wsrep_node_address=192.168.3.137 # 同步方法(mysqldump、rsync、xtrabackup) wsrep_sst_method=xtrabackup-v2 # 同步使用的帐户 wsrep_sst_auth= admin:Abc_123456 # 同步严厉模式 pxc_strict_mode=ENFORCING # 基于ROW复制(安全可靠) binlog_format=ROW # 默认引擎 default_storage_engine=InnoDB # 主键自增长不锁表 innodb_autoinc_lock_mode=2以上步骤在其余两台虚拟机重复此步骤,my.cnf 的文件中 server-id,wsrep_node_name,wsrep_node_address 这三个参数是不能重复的值。启动命令 主节点的管理命令(第一个启动的 PXC 节点)systemctl start mysql@bootstrap.service systemctl stop mysql@bootstrap.service systemctl restart mysql@bootstrap.service非主节点的管理命令(非第一个启动的 PXC 节点)service mysql start service mysql stop service mysql restart连接测试查看 PXC 集群状态信息,在任意一个节点执行以下命令:show status like 'wsrep_cluster%';wsrep_cluster_size 参数是说明 pxc 集群是几个数据库节点的集群。说明集群搭建成功。测试数据我们在 137 节点下创建数据库 test 看看其它两个是否同步过去。可以看到没有问题:我们再创建张表增加一条数据试试:可以看到也是没有问题的。另外不仅使用哪个节点操作数据也会被同步到其它集群中。
ShardingSphere 分库分表什么是 ShardingSphereApache ShardingSphere 是一套开源的分布式数据库中间件解决方案组成的生态圈,它由 JDBC、Proxy 和 Sidecar(规划中)这 3 款相互独立,却又能够混合部署配合使用的产品组成。 它们均提供标准化的数据分片、分布式事务和数据库治理功能,可适用于如 Java 同构、异构语言、云原生等各种多样化的应用场景。一套开源的分布式数据库中间件解决方案。有三个产品:JDBC、Proxy、Sidecar。什么是分库分表当我们使用读写分离、索引、缓存后,数据库的压力还是很大的时候,这就需要使用到数据库拆分了。数据库拆分简单来说,就是指通过某种特定的条件,按照某个维度,将我们存放在同一个数据库中的数据分散存放到多个数据库(主机)上面以达到分散单库(主机)负载的效果。分库分表之垂直拆分专库专用。一个数据库由很多表的构成,每个表对应着不同的业务,垂直切分是指按照业务将表进行分类,分布到不同的数据库上面,这样也就将数据或者说压力分担到不同的库上面。如下图:优点:拆分后业务清晰,拆分规则明确。系统之间整合或扩展容易。数据维护简单。缺点:部分业务表无法 join,只能通过接口方式解决,提高了系统复杂度。受每种业务不同的限制存在单库性能瓶颈,不易数据扩展跟性能提高。事务处理复杂。分库分表之水平切分垂直拆分后遇到单机瓶颈,可以使用水平拆分。相对于垂直拆分的区别是:垂直拆分是把不同的表拆到不同的数据库中,而水平拆分是把同一个表拆到不同的数据库中。相对于垂直拆分,水平拆分不是将表的数据做分类,而是按照某个字段的某种规则来分散到多个库之中,每个表中包含一部分数据。简单来说,我们可以将数据的水平切分理解为是按照数据行的切分,就是将表中的某些行切分到一个数据库,而另外的某些行又切分到其他的数据库中,主要有分表,分库两种模式。如下图:优点:不存在单库大数据,高并发的性能瓶颈。对应用透明,应用端改造较少。按照合理拆分规则拆分,join 操作基本避免跨库。提高了系统的稳定性跟负载能力。缺点:拆分规则难以抽象。分片事务一致性难以解决。数据多次扩展难度跟维护量极大。跨库 join 性能较差。什么是 ShardingSphere-JDBC定位为轻量级 Java 框架,在 Java 的 JDBC 层提供的额外服务。 它使用客户端直连数据库,以 jar 包形式提供服务,无需额外部署和依赖,可理解为增强版的 JDBC 驱动,完全兼容 JDBC 和各种 ORM 框架。适用于任何基于 JDBC 的 ORM 框架,如:JPA, Hibernate, Mybatis, Spring JDBC Template 或直接使用 JDBC。支持任何第三方的数据库连接池,如:DBCP, C3P0, BoneCP, Druid, HikariCP 等。支持任意实现 JDBC 规范的数据库,目前支持 MySQL,Oracle,SQLServer,PostgreSQL 以及任何遵循 SQL92 标准的数据库。需要注意的是,分库分表并不是由 ShardingSphere-JDBC 来做,它是用来负责操作已经分完之后的 CRUD 操作。Sharding-JDBC 分表实操环境使用:Springboot 2.2.11 + MybatisPlus + ShardingSphere-JDBC 4.0.0-RC1 + Druid 连接池具体 Maven 依赖:<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.1.20</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>sharding-jdbc-spring-boot-starter</artifactId> <version>4.0.0-RC1</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.0.5</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> </dependencies>按照水平分表来创建数据库创建数据库 course_db创建表 course_1 、 course_2约定规则:如果添加的课程 id 为偶数添加到 course_1 中,奇数添加到 course_2 中。SQL 如下:create database course_db; use course_db; create table course_1 ( cid bigint(20) primary key , cname varchar(50) not null, user_id bigint(20) not null , status varchar(10) not null ) engine = InnoDB; create table course_2 ( cid bigint(20) primary key , cname varchar(50) not null, user_id bigint(20) not null , status varchar(10) not null ) engine = InnoDB;配置对应实体类以及 Mapper/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2020/11/19 * @Description: Course实体类 */ @Data public class Course { private Long cid; private String cname; private Long userId; private String status; }mapper:/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2020/11/19 * @Description: mapper */ @Repository @MapperScan("com.jack.shardingspherejdbc.mapper") public interface CourseMapper extends BaseMapper<Course> { }启动类配置 MapperScan@SpringBootApplication @MapperScan("com.jack.shardingspherejdbc.mapper") public class ShardingsphereJdbcDemoApplication { public static void main(String[] args) { SpringApplication.run(ShardingsphereJdbcDemoApplication.class, args); } }配置 Sharding-JDBC 分片策略application.properties 内容: # sharding-jdbc 水平分表策略 # 配置数据源,给数据源起别名 spring.shardingsphere.datasource.names=m1 # 一个实体类对应两张表,覆盖 spring.main.allow-bean-definition-overriding=true # 配置数据源的具体内容,包含连接池,驱动,地址,用户名,密码 spring.shardingsphere.datasource.m1.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.m1.driver-class-name=com.mysql.cj.jdbc.Driver spring.shardingsphere.datasource.m1.url=jdbc:mysql://localhost:3306/course_db?serverTimezone=GMT%2B8 spring.shardingsphere.datasource.m1.username=root spring.shardingsphere.datasource.m1.password=123456 # 指定course表分布的情况,配置表在哪个数据库里,表的名称都是什么 m1.course_1,m1.course_2 spring.shardingsphere.sharding.tables.course.actual-data-nodes=m1.course_$->{1..2} # 指定 course 表里面主键 cid 的生成策略 SNOWFLAKE spring.shardingsphere.sharding.tables.course.key-generator.column=cid spring.shardingsphere.sharding.tables.course.key-generator.type=SNOWFLAKE # 配置分表策略 约定 cid 值偶数添加到 course_1 表,如果 cid 是奇数添加到 course_2 表 spring.shardingsphere.sharding.tables.course.table-strategy.inline.sharding-column=cid spring.shardingsphere.sharding.tables.course.table-strategy.inline.algorithm-expression=course_$->{cid % 2 + 1} # 打开 sql 输出日志 spring.shardingsphere.props.sql.show=true测试代码运行@RunWith(SpringRunner.class) @SpringBootTest class ShardingsphereJdbcDemoApplicationTests { @Autowired private CourseMapper courseMapper; //添加课程 @Test public void addCourse() { Course course = new Course(); //cid由我们设置的策略,雪花算法进行生成 course.setCname("Java"); course.setUserId(100L); course.setStatus("Normal"); courseMapper.insert(course); } }运行结果我们查询一下看看:@Test public void findCourse() { QueryWrapper<Course> wrapper = new QueryWrapper<>(); wrapper.eq("cid", 536248443081850881L); courseMapper.selectOne(wrapper); }可以看到查询的表也是正确的。Sharding-JDBC 实现水平分库需求:创建两个数据库,edu_db_1、edu_db_2。每个库中包含:course_1、course_2。数据库规则:userid 为偶数添加到 edu_db_1 库,奇数添加到 edu_db_2。表规则:如果添加的 cid 为偶数添加到 course_1 中,奇数添加到 course_2 中。创建数据库和表结构create database edu_db_1; create database edu_db_2; use edu_db_1; create table course_1 ( `cid` bigint(20) primary key, `cname` varchar(50) not null, `user_id` bigint(20) not null, `status` varchar(10) not null ); create table course_2 ( `cid` bigint(20) primary key, `cname` varchar(50) not null, `user_id` bigint(20) not null, `status` varchar(10) not null ); use edu_db_2; create table course_1 ( `cid` bigint(20) primary key, `cname` varchar(50) not null, `user_id` bigint(20) not null, `status` varchar(10) not null ); create table course_2 ( `cid` bigint(20) primary key, `cname` varchar(50) not null, `user_id` bigint(20) not null, `status` varchar(10) not null );配置分片策略application.properties 内容:# sharding-jdbc 水平分库分表策略 # 配置数据源,给数据源起别名 # 水平分库需要配置多个数据库 spring.shardingsphere.datasource.names=m1,m2 # 一个实体类对应两张表,覆盖 spring.main.allow-bean-definition-overriding=true # 配置第一个数据源的具体内容,包含连接池,驱动,地址,用户名,密码 spring.shardingsphere.datasource.m1.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.m1.driver-class-name=com.mysql.cj.jdbc.Driver spring.shardingsphere.datasource.m1.url=jdbc:mysql://localhost:3306/edu_db_1?serverTimezone=GMT%2B8 spring.shardingsphere.datasource.m1.username=root spring.shardingsphere.datasource.m1.password=123456 # 配置第二个数据源的具体内容,包含连接池,驱动,地址,用户名,密码 spring.shardingsphere.datasource.m2.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.m2.driver-class-name=com.mysql.cj.jdbc.Driver spring.shardingsphere.datasource.m2.url=jdbc:mysql://localhost:3306/edu_db_2?serverTimezone=GMT%2B8 spring.shardingsphere.datasource.m2.username=root spring.shardingsphere.datasource.m2.password=123456 # 指定数据库分布的情况和数据表分布的情况 # m1 m2 course_1 course_2 spring.shardingsphere.sharding.tables.course.actual-data-nodes=m$->{1..2}.course_$->{1..2} # 指定 course 表里面主键 cid 的生成策略 SNOWFLAKE spring.shardingsphere.sharding.tables.course.key-generator.column=cid spring.shardingsphere.sharding.tables.course.key-generator.type=SNOWFLAKE # 指定分库策略 约定 user_id 值偶数添加到 m1 库,如果 user_id 是奇数添加到 m2 库 # 默认写法(所有的表的user_id) #spring.shardingsphere.sharding.default-database-strategy.inline.sharding-column=user_id #spring.shardingsphere.sharding.default-database-strategy.inline.algorithm-expression=m$->{user_id % 2 + 1} # 指定只有course表的user_id spring.shardingsphere.sharding.tables.course.database-strategy.inline.sharding-column=user_id spring.shardingsphere.sharding.tables.course.database-strategy.inline.algorithm-expression=m$->{user_id % 2 + 1} # 指定分表策略 约定 cid 值偶数添加到 course_1 表,如果 cid 是奇数添加到 course_2 表 spring.shardingsphere.sharding.tables.course.table-strategy.inline.sharding-column=cid spring.shardingsphere.sharding.tables.course.table-strategy.inline.algorithm-expression=course_$->{cid % 2 + 1} # 打开 sql 输出日志 spring.shardingsphere.props.sql.show=true测试代码运行@Test public void addCourse() { Course course = new Course(); //cid由我们设置的策略,雪花算法进行生成 course.setCname("python"); //分库根据user_id course.setUserId(100L); course.setStatus("Normal"); courseMapper.insert(course); course.setCname("c++"); course.setUserId(111L); courseMapper.insert(course); } 对应的我们 python 的 userId 为偶数所以添加到 edu_db_1 库中,而 c++是奇数所以添加到 edu_db_2 库中。运行结果看下对应的数据库数据,也是没有问题的。Sharding-JDBC 实现垂直分库需求:我们再额外创建一个 user_db 数据库。当我们查询用户信息就去 user_db,课程信息就去 edu_db_1、edu_db_2。创建数据库和表结构create database user_db; use user_db; create table t_user( `user_id` bigint(20) primary key, `username` varchar(100) not null, `status` varchar(50) not null );配置对应实体类和 Mapper实体类: /** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2020/11/20 * @Description:t_user 实体类 */ @Data @TableName("t_user") public class User { private Long userId; private String username; private String status; }mapper:/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2020/11/20 * @Description: UserMapper */ @Repository public interface UserMapper extends BaseMapper<User> { }配置分片策略application.properties 内容:# sharding-jdbc 水平分库分表策略 # 配置数据源,给数据源起别名 # 水平分库需要配置多个数据库 # m0为用户数据库 spring.shardingsphere.datasource.names=m1,m2,m0 # 一个实体类对应两张表,覆盖 spring.main.allow-bean-definition-overriding=true # 配置第一个数据源的具体内容,包含连接池,驱动,地址,用户名,密码 spring.shardingsphere.datasource.m1.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.m1.driver-class-name=com.mysql.cj.jdbc.Driver spring.shardingsphere.datasource.m1.url=jdbc:mysql://localhost:3306/edu_db_1?serverTimezone=GMT%2B8 spring.shardingsphere.datasource.m1.username=root spring.shardingsphere.datasource.m1.password=123456 # 配置第二个数据源的具体内容,包含连接池,驱动,地址,用户名,密码 spring.shardingsphere.datasource.m2.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.m2.driver-class-name=com.mysql.cj.jdbc.Driver spring.shardingsphere.datasource.m2.url=jdbc:mysql://localhost:3306/edu_db_2?serverTimezone=GMT%2B8 spring.shardingsphere.datasource.m2.username=root spring.shardingsphere.datasource.m2.password=123456 # 配置user数据源的具体内容,包含连接池,驱动,地址,用户名,密码 spring.shardingsphere.datasource.m0.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.m0.driver-class-name=com.mysql.cj.jdbc.Driver spring.shardingsphere.datasource.m0.url=jdbc:mysql://localhost:3306/user_db?serverTimezone=GMT%2B8 spring.shardingsphere.datasource.m0.username=root spring.shardingsphere.datasource.m0.password=123456 # 配置user_db数据库里面t_user 专库专表 spring.shardingsphere.sharding.tables.t_user.actual-data-nodes=m0.t_user # 配置主键的生成策略 spring.shardingsphere.sharding.tables.t_user.key-generator.column=user_id spring.shardingsphere.sharding.tables.t_user.key-generator.type=SNOWFLAKE # 指定分表策略 spring.shardingsphere.sharding.tables.t_user.table-strategy.inline.sharding-column=user_id spring.shardingsphere.sharding.tables.t_user.table-strategy.inline.algorithm-expression=t_user # 指定数据库分布的情况和数据表分布的情况 # m1 m2 course_1 course_2 spring.shardingsphere.sharding.tables.course.actual-data-nodes=m$->{1..2}.course_$->{1..2} # 指定 course 表里面主键 cid 的生成策略 SNOWFLAKE spring.shardingsphere.sharding.tables.course.key-generator.column=cid spring.shardingsphere.sharding.tables.course.key-generator.type=SNOWFLAKE # 指定分库策略 约定 user_id 值偶数添加到 m1 库,如果 user_id 是奇数添加到 m2 库 # 默认写法(所有的表的user_id) #spring.shardingsphere.sharding.default-database-strategy.inline.sharding-column=user_id #spring.shardingsphere.sharding.default-database-strategy.inline.algorithm-expression=m$->{user_id % 2 + 1} # 指定只有course表的user_id spring.shardingsphere.sharding.tables.course.database-strategy.inline.sharding-column=user_id spring.shardingsphere.sharding.tables.course.database-strategy.inline.algorithm-expression=m$->{user_id % 2 + 1} # 指定分表策略 约定 cid 值偶数添加到 course_1 表,如果 cid 是奇数添加到 course_2 表 spring.shardingsphere.sharding.tables.course.table-strategy.inline.sharding-column=cid spring.shardingsphere.sharding.tables.course.table-strategy.inline.algorithm-expression=course_$->{cid % 2 + 1} # 打开 sql 输出日志 spring.shardingsphere.props.sql.show=true测试代码运行@Autowired private UserMapper userMapper; @Test public void addUser(){ User user = new User(); user.setUsername("Jack"); user.setStatus("Normal"); userMapper.insert(user); } @Test public void findUser() { QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq("user_id", 536472243283165185L); userMapper.selectOne(wrapper); }添加方法运行结果查询方法运行结果Sharding-JDBC 公共表概念存储固定数据的表,表数据很少发生变化,查询时经常要进行关联。在每个数据库中都创建出相同结构公共表。操作公共表时,同时操作添加了公共表的数据库中的公共表,添加记录时,同时添加,删除时,同时删除。在多个数据库中创建公共表# use user_db; # use edu_db_1; use edu_db_2; create table t_dict( `dict_id` bigint(20) primary key, `status` varchar(100) not null, `value` varchar(100) not null );配置公共表的实体类和 mapper实体类:/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2020/11/20 * @Description:Dict实体类 */ @Data @TableName("t_dict") public class Dict { private Long dictId; private String status; private String value; }mapper:/** * @author 又坏又迷人 * 公众号: Java菜鸟程序员 * @date 2020/11/20 * @Description: DictMapper */ @Repository public interface DictMapper extends BaseMapper<Dict> { }配置分片策略application.properties:# sharding-jdbc 水平分库分表策略 # 配置数据源,给数据源起别名 # 水平分库需要配置多个数据库 # m0为用户数据库 spring.shardingsphere.datasource.names=m1,m2,m0 # 一个实体类对应两张表,覆盖 spring.main.allow-bean-definition-overriding=true # 配置第一个数据源的具体内容,包含连接池,驱动,地址,用户名,密码 spring.shardingsphere.datasource.m1.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.m1.driver-class-name=com.mysql.cj.jdbc.Driver spring.shardingsphere.datasource.m1.url=jdbc:mysql://localhost:3306/edu_db_1?serverTimezone=GMT%2B8 spring.shardingsphere.datasource.m1.username=root spring.shardingsphere.datasource.m1.password=123456 # 配置第二个数据源的具体内容,包含连接池,驱动,地址,用户名,密码 spring.shardingsphere.datasource.m2.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.m2.driver-class-name=com.mysql.cj.jdbc.Driver spring.shardingsphere.datasource.m2.url=jdbc:mysql://localhost:3306/edu_db_2?serverTimezone=GMT%2B8 spring.shardingsphere.datasource.m2.username=root spring.shardingsphere.datasource.m2.password=123456 # 配置user数据源的具体内容,包含连接池,驱动,地址,用户名,密码 spring.shardingsphere.datasource.m0.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.m0.driver-class-name=com.mysql.cj.jdbc.Driver spring.shardingsphere.datasource.m0.url=jdbc:mysql://localhost:3306/user_db?serverTimezone=GMT%2B8 spring.shardingsphere.datasource.m0.username=root spring.shardingsphere.datasource.m0.password=123456 # 配置user_db数据库里面t_user 专库专表 spring.shardingsphere.sharding.tables.t_user.actual-data-nodes=m0.t_user # 配置主键的生成策略 spring.shardingsphere.sharding.tables.t_user.key-generator.column=user_id spring.shardingsphere.sharding.tables.t_user.key-generator.type=SNOWFLAKE # 指定分表策略 spring.shardingsphere.sharding.tables.t_user.table-strategy.inline.sharding-column=user_id spring.shardingsphere.sharding.tables.t_user.table-strategy.inline.algorithm-expression=t_user # 指定数据库分布的情况和数据表分布的情况 # m1 m2 course_1 course_2 spring.shardingsphere.sharding.tables.course.actual-data-nodes=m$->{1..2}.course_$->{1..2} # 指定 course 表里面主键 cid 的生成策略 SNOWFLAKE spring.shardingsphere.sharding.tables.course.key-generator.column=cid spring.shardingsphere.sharding.tables.course.key-generator.type=SNOWFLAKE # 指定分库策略 约定 user_id 值偶数添加到 m1 库,如果 user_id 是奇数添加到 m2 库 # 默认写法(所有的表的user_id) #spring.shardingsphere.sharding.default-database-strategy.inline.sharding-column=user_id #spring.shardingsphere.sharding.default-database-strategy.inline.algorithm-expression=m$->{user_id % 2 + 1} # 指定只有course表的user_id spring.shardingsphere.sharding.tables.course.database-strategy.inline.sharding-column=user_id spring.shardingsphere.sharding.tables.course.database-strategy.inline.algorithm-expression=m$->{user_id % 2 + 1} # 指定分表策略 约定 cid 值偶数添加到 course_1 表,如果 cid 是奇数添加到 course_2 表 spring.shardingsphere.sharding.tables.course.table-strategy.inline.sharding-column=cid spring.shardingsphere.sharding.tables.course.table-strategy.inline.algorithm-expression=course_$->{cid % 2 + 1} # 公共表配置 spring.shardingsphere.sharding.broadcast-tables=t_dict # 配置主键的生成策略 spring.shardingsphere.sharding.tables.t_dict.key-generator.column=dict_id spring.shardingsphere.sharding.tables.t_dict.key-generator.type=SNOWFLAKE # 打开 sql 输出日志 spring.shardingsphere.props.sql.show=true测试代码运行@Autowired private DictMapper dictMapper; @Test public void addDict() { Dict dict = new Dict(); dict.setStatus("Normal"); dict.setValue("启用"); dictMapper.insert(dict); } @Test public void deleteDict() { QueryWrapper<Dict> wrapper = new QueryWrapper<>(); wrapper.eq("dict_id", 536486065947541505L); dictMapper.delete(wrapper); }添加方法运行结果删除方法运行结果什么是读写分离了解读写分离前,我们先了解下什么是主从复制。主从复制,是用来建立一个和主数据库完全一样的数据库环境,称为从数据库,主数据库一般是准实时的业务数据库。一台服务器充当主服务器,而另外一台服务器充当从服务器。主从复制原理主库将变更写入 binlog 日志,然后从库连接到主库之后,从库有一个 IO 线程,将主库的 binlog 日志拷贝到自己本地,写入一个 relay 中继日志(relay log)中。接着从库中有一个 SQL 线程会从中继日志读取 binlog,然后执行 binlog 日志中的内容,也就是在自己本地再次执行一遍 SQL 语句,从而使从服务器和主服务器的数据保持一致。也就是说:从库会生成两个线程,一个 I/O 线程,一个 SQL 线程; I/O 线程会去请求主库的 binlog,并将得到的 binlog 写到本地的 relay-log(中继日志)文件中; 主库会生成一个 log dump 线程, 用来给从库 I/O 线程传 binlog; SQL 线程,会读取 relay log 文件中的日志,并解析成 sql 语句逐一执行。需要注意的是,就是从库同步主库数据的过程是串行化的,也就是说主库上并行的操作,在从库上会串行执行。由于从库从主库拷贝日志以及串行执行 SQL 的特点,在高并发场景下,从库的数据是有延时的。在实际运用中,时常会出现这样的情况,主库的数据已经有了,可从库还是读取不到,可能要过几十毫秒,甚至几百毫秒才能读取到。半同步复制:解决主库数据丢失问题。也叫 semi-sync 复制,指的就是主库写入 binlog 日志之后,就会强制将数据立即同步到从库,从库将日志写入自己本地的 relay log 之后,接着会返回一个 ack 给主库,主库接收到至少一个从库的 ack 之后才会认为写操作完成了。并行复制:解决从库复制延迟的问题。指的是从库开启多个线程,并行读取 relay log 中不同库的日志,然后并行存放不同库的日志,这是库级别的并行。主从同步延迟问题MySQL 可以通过 MySQL 命令 show slave status 获知当前是否主从同步正常工作。另外一个重要指标就是 Seconds_Behind_Master,根据输出的 Seconds_Behind_Master 参数的值来判断:NULL,表示 io_thread 或是 sql_thread 有任何一个发生故障。0,表示主从复制良好。正值,表示主从已经出现延时,数字越大表示从库延迟越严重。导致主从同步延迟情况主库的从库太多,导致复制延迟。从库硬件比主库差,导致复制延迟。慢 SQL 语句过多。主从复制的设计问题,例如主从复制单线程,如果主库写并发太大,来不及传送到从库,就会导致延迟。Mysql5.7 之后可以支持多线程复制。设置参数slave_parallel_workers>0 和slave_parallel_type='LOGICAL_CLOCK'。网络延迟。主从同步解决方案使用 PXC 架构(下篇文章介绍)避免一些无用的 IO 消耗,可以上 SSD。IO 调度要选择 deadline 模式。适当调整 buffer pool 的大小。避免让数据库进行各种大量运算,数据库只是用来存储数据的,让应用端多分担些压力,或者可以通过缓存来完成。说到底读写分离就是主库进行写操作,从库进行读操作。具体可以搭配一主一从、一主多从、多主多从。根据业务场景来进行选择。搭建一主一从 MySQL 环境我使用的是两台 Centos7 虚拟机,主服务器 IP 为:192.168.3.107,从服务器 IP:192.168.3.108。MySQL 环境为:8.0.15。这里不讲如何搭建 MySQL 环境了。首先我们进入主服务器输入以下命令:vim /etc/my.cnf在[mysqld]节点下加入:#设置主mysql的id server-id = 1 #启用二进制日志 log-bin=mysql-bin #设置logbin格式 binlog_format = STATEMENT也可以加入 binlog-do-db 来指定同步的数据库 ,或者使用 binlog-ignore-db 来忽略同步的数据库,如果不写则同步所有数据库!然后我们进入从服务器输入以下命令:vim /etc/my.cnf在[mysqld]节点下加入:#设置从mysql的id server-id = 2 #启用中继日志 relay-log = mysql-relay最后我们使用下面命令在主和从都执行,重启 MySQL 服务器。/etc/init.d/mysqld restart以上完毕之后我们登录主服务器的 MySQL。mysql -u root -p进入 MySQL 后执行以下命令:#创建用于主从复制的账号db_sync,密码db_sync create user 'db_sync'@'%' identified with mysql_native_password by 'db_sync'; #授权 grant replication slave on *.* to 'db_sync'@'%'; #刷新权限 FLUSH PRIVILEGES;然后我们执行以下命令,记得file和position的值!show master status;以上完毕之后我们登录从服务器的 MySQL。mysql -u root -p;进入 MySQL 后执行以下命令:STOP SLAVE;接着我们输入命令来连接主服务器:#修改从库指向到主库 # master_host 主ip地址 # master_port 主mysql暴露的端口 # master_user 主mysql的用户名 # master_password 主mysql的密码 # master_log_file 填写刚才查看到的file # master_log_pos 填写刚才查看到的position CHANGE MASTER TO master_host = '192.168.3.107', master_port = 3306, master_user = 'db_sync', master_password = 'db_sync', master_log_file = 'mysql-bin.000006', master_log_pos = 863;然后启动我们的 slave:START SLAVE;最后一定要查看一下是否成功!show slave status \G;Slave_IO_Runing和Slave_SQL_Runing字段值都为Yes,表示同步配置成功。Sharding-JDBC 实现读写分离Sharding-JDBC 实现读写分离则是根据sql 语句语义分析,当 sql 语句有 insert、update、delete 时,Sharding-JDBC 就把这次操作在主数据库上执行;当 sql 语句有 select 时,就会把这次操作在从数据库上执行,从而实现读写分离过程。但 Sharding-JDBC 并不会做数据同步,数据同步是配置 MySQL 后由 MySQL 自己完成的。搭建环境成功后我们在主库和从库上都建库建表:create database user_db; use user_db; create table t_user( `user_id` bigint(20) primary key, `username` varchar(100) not null, `status` varchar(50) not null );配置读写分离策略application.properties:# 配置数据源,给数据源起别名 # m0为用户数据库 spring.shardingsphere.datasource.names=m0,s0 # 一个实体类对应两张表,覆盖 spring.main.allow-bean-definition-overriding=true #user_db 主服务器 spring.shardingsphere.datasource.m0.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.m0.driver-class-name=com.mysql.cj.jdbc.Driver spring.shardingsphere.datasource.m0.url=jdbc:mysql://192.168.3.107:3306/user_db?serverTimezone=GMT%2B8 spring.shardingsphere.datasource.m0.username=root spring.shardingsphere.datasource.m0.password=123456 #user_db 从服务器 spring.shardingsphere.datasource.s0.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.s0.driver-class-name=com.mysql.cj.jdbc.Driver spring.shardingsphere.datasource.s0.url=jdbc:mysql://192.168.3.108:3306/user_db?serverTimezone=GMT%2B8 spring.shardingsphere.datasource.s0.username=root spring.shardingsphere.datasource.s0.password=123456 # 主库从库逻辑数据源定义 ds0 为 user_db spring.shardingsphere.sharding.master-slave-rules.ds0.master-data-source-name=m0 spring.shardingsphere.sharding.master-slave-rules.ds0.slave-data-source-names=s0 # 配置user_db数据库里面t_user 专库专表 #spring.shardingsphere.sharding.tables.t_user.actual-data-nodes=m0.t_user # t_user 分表策略,固定分配至 ds0 的 t_user 真实表 spring.shardingsphere.sharding.tables.t_user.actual-data-nodes=ds0.t_user # 配置主键的生成策略 spring.shardingsphere.sharding.tables.t_user.key-generator.column=user_id spring.shardingsphere.sharding.tables.t_user.key-generator.type=SNOWFLAKE # 指定分表策略 spring.shardingsphere.sharding.tables.t_user.table-strategy.inline.sharding-column=user_id spring.shardingsphere.sharding.tables.t_user.table-strategy.inline.algorithm-expression=t_user # 打开 sql 输出日志 spring.shardingsphere.props.sql.show=true测试代码运行@Autowired private UserMapper userMapper; @Test public void addUser(){ User user = new User(); user.setUsername("Jack"); user.setStatus("Normal"); userMapper.insert(user); } @Test public void findUser() { QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq("user_id", 536553906142969857L); userMapper.selectOne(wrapper); }添加方法运行结果m0 就是我们配置的主库。可以看到添加是没问题的。然后我们看一下从库里有没有数据。查询方法运行结果可以看到结果也是 OK 的!什么是 ShardingSphere-Proxy定位为透明化的数据库代理端,提供封装了数据库二进制协议的服务端版本,用于完成对异构语言的支持。 目前提供 MySQL 和 PostgreSQL 版本,它可以使用任何兼容 MySQL/PostgreSQL 协议的访问客户端(如:MySQL Command Client, MySQL Workbench, Navicat 等)操作数据,对 DBA 更加友好。向应用程序完全透明,可直接当做 MySQL/PostgreSQL 使用。适用于任何兼容 MySQL/PostgreSQL 协议的的客户端。简单理解为:之前我们要配置多个数据源,而现在我们使用 ShardingSphere-Proxy 之后,我们相当于只操作一个库一个表,而多库多表操作被封装在了 ShardingSphere-Proxy 里面。是一个透明化的代理端。下载 ShardingSphere-Proxy下载地址:https://archive.apache.org/di...下载完进行解压Sharding-Proxy 配置(分表)进入到 conf 中打开server.yaml。将此部分注释打开即可。然后我们打开config-sharding.yaml文件进行分库分表的配置根据提示,如果使用 mysql,需要把 mysql 的驱动 jar 包放到 lib 目录下。拷贝即可。然后我在主服务器创建了一个数据库create database test_db;打开如下注释填写对应参数:schemaName: sharding_db dataSources: ds_0: url: jdbc:mysql://192.168.3.107:3306/test_db?serverTimezone=UTC&useSSL=false username: root password: 123456 connectionTimeoutMilliseconds: 30000 idleTimeoutMilliseconds: 60000 maxLifetimeMilliseconds: 1800000 maxPoolSize: 50 shardingRule: tables: t_order: actualDataNodes: ds_${0}.t_order_${0..1} tableStrategy: inline: shardingColumn: order_id algorithmExpression: t_order_${order_id % 2} keyGenerator: type: SNOWFLAKE column: order_id bindingTables: - t_order defaultDatabaseStrategy: inline: shardingColumn: user_id algorithmExpression: ds_${0} defaultTableStrategy: none:然后我们保存进入 bin 目录启动./start.sh。启动成功后我们进入 logs 目录查看 stdout.log 日志文件。如下图即启动成功!然后我们进入端口为 3307 的 mysql,ShardingSphere-Proxy默认端口为:3307mysql -uroot -proot -h127.0.0.1 -P3307新建一张表插入条数据。use sharding_db; create table if not exists ds_0.t_order(`order_id` bigint primary key,`user_id` int not null,`status` varchar(50)); insert into t_order(`order_id`,`user_id`,`status`)values(11,1,'jack');按照 order_id 进行分配,因为是奇数所以被分到了 t_order_1 表里。Sharding-Proxy 配置(分库)我们在主库创建数据库:create database test_1;我们在从库创建数据库:create database test_2;我们还是打开config-sharding.yaml进行如下配置:schemaName: sharding_db dataSources: ds_0: url: jdbc:mysql://192.168.3.107:3306/test_1?serverTimezone=UTC&useSSL=false username: root password: 123456 connectionTimeoutMilliseconds: 30000 idleTimeoutMilliseconds: 60000 maxLifetimeMilliseconds: 1800000 maxPoolSize: 50 ds_1: url: jdbc:mysql://192.168.3.108:3306/test_2?serverTimezone=UTC&useSSL=false username: root password: 123456 connectionTimeoutMilliseconds: 30000 idleTimeoutMilliseconds: 60000 maxLifetimeMilliseconds: 1800000 maxPoolSize: 50 shardingRule: tables: t_order: actualDataNodes: ds_${0..1}.t_order_${1..2} tableStrategy: inline: shardingColumn: order_id algorithmExpression: t_order_${order_id % 2 + 1} keyGenerator: type: SNOWFLAKE column: order_id bindingTables: - t_order defaultDatabaseStrategy: inline: shardingColumn: user_id algorithmExpression: ds_${user_id % 2} defaultTableStrategy: none:之后进入 bin 目录下重启一下 Proxy。./stop.sh ./start.sh进入 mysqlmysql -uroot -proot -h127.0.0.1 -P3307创建表添加数据use sharding_db; create table if not exists ds_0.t_order(`order_id` bigint primary key,`user_id` int not null,`status` varchar(50)); insert into t_order(`order_id`,`user_id`,`status`)values(11,1,'jack');可以看到结果已经插入到了对应的库中表中。配置 Sharding-Proxy 读写分离我们还是使用之前的一主一从搭配主从复制,在主和从上创建数据库:create database master_slave_user;修改 config-master_slave.yaml 文件(此文件为读写分离的配置)schemaName: master_slave_db dataSources: master_ds: url: jdbc:mysql://192.168.3.107:3306/master_slave_user?serverTimezone=UTC&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 connectionTimeoutMilliseconds: 30000 idleTimeoutMilliseconds: 60000 maxLifetimeMilliseconds: 1800000 maxPoolSize: 50 slave_ds_0: url: jdbc:mysql://192.168.3.108:3306/master_slave_user?serverTimezone=UTC&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 connectionTimeoutMilliseconds: 30000 idleTimeoutMilliseconds: 60000 maxLifetimeMilliseconds: 1800000 maxPoolSize: 50 masterSlaveRule: name: ms_ds masterDataSourceName: master_ds slaveDataSourceNames: - slave_ds_0 # - slave_ds_1之后进入 bin 目录下重启一下 Proxy。./stop.sh ./start.sh进入 mysqlmysql -uroot -proot -h127.0.0.1 -P3307创建表添加数据use master_slave_db; create table if not exists master_slave_user.t_order(`order_id` bigint primary key,`user_id` int not null,`status` varchar(50)); insert into t_order(`order_id`,`user_id`,`status`)values(11,1,'Jack');可以看到下图:主库和从库都已经存在数据了。读取操作就不再演示了,读取的是从库数据。
Netty 概述原生 NIO 存在的问题NIO 的类库与 API 繁杂,需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、Bytebuffer 等。要求熟悉 Java 多线程编程和网络编程。开发工作量和难度大,例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常流的处理等。什么是 NettyNetty 是由 JBOSS 提供的一个 Java 开源框架。Netty 提供异步的、基于事件驱动的网络应用程序框架,用以快速开发高性能、高可靠的网络 I/O 程序。Netty 可以快速、简单的开发一个网络应用,相当于简化和流程化了 NIO 的开发过程。Netty 是目前最流行的 NIO 框架,在互联网领域、大数据分布式计算领域、游戏行业、通信行业等获得了广泛的应用,Elasticsearch、Dubbo 框架内部都采用了 Netty。Netty 作为业界最流行的 nio 框架之一,它的健壮性、功能、性能、可定制性、可扩展性都是首屈一指的。优点:API 使用简单,开发门槛低。功能强大,预置了多种编解码功能,支持多种主流协议。定制能力强,通过 channelHandler 对通信框架进行灵活扩展。高性能。成熟,稳定,修复了所有的 NIO BUG.社区活跃。经历了大规模的商业应用考验,质量得到验证。线程模型介绍目前存在的线程模型有:传统阻塞 I/O 服务模型。Reactor 模式。根据 Reactor 的数量和处理资源线程池的数量不同,有三种不同实现:单 Reactor 单线程。单 Reactor 多线程。主从 Reactor 多线程。Netty 线程模式主要基于主从 Reactor 多线程模型做了一定的改进,其中主从 Reactor 多线程模型有多个 Reactor。传统阻塞 I/O 服务模型模型特点采用阻塞 I/O 获取输入的数据。每个连接都需要独立的线程完成数据的输入、业务处理、数据返回。 问题分析当并发数很大时,会创建大量的线程,占用很大的系统资源。连接创建后,如果当前线程暂时没有数据可读,该线程会阻塞在 Read 操作上,造成线程资源浪费。 解决方案基于I/O复用模型:多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象等待,无需阻塞所有连接,当某个连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。基于线程池复用线程资源:不必为每一个连接创建线程,将连接完成后的业务处理任务分配给线程进行处理,一个线程可以处理多个连接的业务。Reactor 模式Reactor 模式,通过一个或多个输入同时传递给服务器处理的模式(基于事件驱动)。服务器端程序处理传入的多个请求,并将它们同步分派到相应的处理线程。Reactor 模式使用了 I/O 复用监听事件,受到事件后分发给某个线程(进程),网络服务高并发处理的关键。核心组成Reactor:在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序对 I/O 事件作出反应。Handlers:处理程序执行 I/O 事件要完成的实际事件。Reactor 通过调用适当的处理程序来响应 I/O 事件,处理程序非阻塞操作。单 Reactor 单线程select 是 I/O 复用模型介绍的标准网络编程 API,可以实现应用程序通过一个阻塞对象监听多路连接请求。Reactor 对象通过 Select 监控客户端请求事件,收到事件后通过 Dispatch 进行分发。如果建立连接请求事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后的后续业务处理。如果不是建立连接事件,则 Reactor 会分发给调用连接对应的 Handler 来响应。Handler 会完成 Read—>业务处理—>Send 的完整业务流程。优缺点:优点:模型简单,无多线程、进程通信、竞争的问题,全部由一个线程完成。缺点:性能问题,只有一个线程无法发挥出多核 CPU 的性能,Handler 在处理某连接业务时,整个进程无法处理其他连接事件,容易导致性能瓶颈。缺点:可靠性问题,线程意外中止,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部信息,节点故障。使用场景:客户端数量有限,业务处理快捷(例如 Redis 在业务处理的时间复杂度为 O(1)的情况)。单 Reactor 多线程Reactor 通过 select 监控客户端请求事件,收到事件后,通过 dispatch 进行分发。如果是建立连接的请求,则由 Acceptor 通过 accept 处理连接请求,同时创建一个 handler 处理完成连接后的后续请求。如果不是连接请求,则由 Reactor 分发调用连接对应的 handler 来处理。Handler 只负责响应事件,不做具体的业务处理,通过 read 读取数据后,会分发给后面的 worker 线程池中的某个线程处理业务。Worker 线程池会分配独立的线程处理真正的业务,并将结果返回给 Handler。Handler 收到响应后,通过 send 方法将结果反馈给 Client。优缺点:优点:可以充分的利用多核 CPU 的处理能力。缺点:多线程数据共享、访问操作比较复杂,Reactor 处理所有的事件的监听和响应,因为 Reactor 在单线程中运行,在高并发场景容易出现性能瓶颈。主从 Reactor 多线程Reactor 主线程 MainReactor 对象通过 select 监听连接事件,收到事件后,通过 Acceptor 处理连接事件。当 Acceptor 处理连接事件后,MainReactor 将创建好的连接分配给 SubReactor。SubReactor 将连接加入到连接队列进行监听,并创建 Handler 进行各种事件处理。当有新事件发生时,SubReactor 调用对应的 Handler 进行处理。Handler 通过 read 读取数据,分发给后面的 Worker 线程池处理。Worker 线程池会分配独立的 Worker 线程进行业务处理,并将结果返回。Handler 收到响应结果后,通过 send 方法将结果返回给 Client。优缺点:优点:父线程和子线程的职责明确,父线程只需要接收新连接,子线程完成后续业务处理。优点:父线程与子线程的数据交互简单,Reactor 主线程是需要把新连接传给子线程,子线程无需返回数据。缺点:编程复杂度较高。Reactor 模式小结单 Reactor 单线程:前台接待员和服务员是同一个人,全程为顾客服务。单 Reactor 多线程:一个前台接待员,多个服务员。主从 Reactor 多线程:多个前台接待员,多个服务员。响应快,虽然 Reactor 本身是同步的,但不必为单个同步事件所阻塞。最大程度的避免了复杂的多线程及同步问题,避免了多线程/进程的切换开销。扩展性好,可以方便的通过增加 Reactor 势力个数充分利用 CPU 资源。复用性好,Reactor 模型本身与具体事件处理逻辑无关,具有很高的复用性。Netty 模型服务端端包含 1 个 Boss NioEventLoopGroup 和 1 个 Worker NioEventLoopGroup。NioEventLoopGroup 相当于 1 个事件循环组,这个组里包含多个事件循环 NioEventLoop,每个 NioEventLoop 包含 1 个 Selector 和 1 个事件循环线程。每个 Boss NioEventLoop 循环执行的任务包含 3 步:轮训 Accept 事件。处理 Accept I/O 事件,与 Client 建立连接,生成 NioSocketChannel,并将 NioSocketChannel 注册到某个 Worker NioEventLoop 的 Selector 上。处理任务队列中的任务,runAllTasks。任务队列中的任务包括用户调用 eventloop.execute 或 schedule 执行的任务,或者其它线程提交到该 eventloop 的任务。每个 Worker NioEventLoop 循环执行的任务包含 3 步:轮询 read、write 事件。处理 I/O 事件,即 read、write 事件,在 NioSocketChannel 可读、可写事件发生时进行处理。处理任务队列中的任务,runAllTasks。每个 Worker NioEventLoop 处理业务时,会使用 PipeLine(管道),pipeline 中包含了 channel,即通过 pipeline 可以获取对应通道,通道中维护了很多处理器。Netty 简单通讯代码案例/** * @author jack */ public class SimpleServer { public static void main(String[] args) { //创建bossGroup , 只负责连接请求 NioEventLoopGroup bossGroup = new NioEventLoopGroup(); //创建workerGroup , 负责客户端业务处理 NioEventLoopGroup workerGroup = new NioEventLoopGroup(); //创建服务端启动对象,配置参数. ServerBootstrap serverBootstrap = new ServerBootstrap(); try { serverBootstrap.group(bossGroup, workerGroup)//设置线程组 .channel(NioServerSocketChannel.class)//使用NioSocketChannel作为服务端的通道实现 .option(ChannelOption.SO_BACKLOG, 128)//设置线程队列得到连接个数 .childOption(ChannelOption.SO_KEEPALIVE, true)//设置保持活动连接状态 .childHandler(new ChannelInitializer<SocketChannel>() {//创建一个通道测试对象 //给pipeline设置处理器 @Override protected void initChannel(SocketChannel socketChannel) throws Exception { socketChannel.pipeline().addLast(new NettyServerHandler()); //自定义handler } });//workerGroup的EventLoop对应的管道设置处理器 System.out.println("服务端准备就绪..."); //绑定一个端口并且同步,生成了一个channelFuture对象 ChannelFuture cf = serverBootstrap.bind(6667).sync(); //对关闭通道进行监听 cf.channel().closeFuture().sync(); } catch (InterruptedException e) { e.printStackTrace(); } finally { workerGroup.shutdownGracefully(); bossGroup.shutdownGracefully(); } } }/** * 服务端自定义handler */ public class NettyServerHandler extends ChannelInboundHandlerAdapter { /** * 读取实际数据(这里我们可以读取客户端发送的消息) * * @param ctx 上下文对象,含有管道pipeline,通道channel ,地址 * @param msg 客户端发送的内容 * @throws Exception */ @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ByteBuf buf = (ByteBuf) msg; System.out.println("客户端发送: " + buf.toString(CharsetUtil.UTF_8)); System.out.println("客户端地址为:" + ctx.channel().remoteAddress()); } /** * 读取完成后 * * @param ctx * @throws Exception */ @Override public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { ctx.writeAndFlush(Unpooled.copiedBuffer("你好,客户端", CharsetUtil.UTF_8)); } /** * 处理异常,一般是关闭通道 * * @param ctx * @param cause * @throws Exception */ @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { cause.printStackTrace(); ctx.close(); } }/** * @author jack */ public class SimpleClient { public static void main(String[] args) { //客户端需要一个事件循环组 NioEventLoopGroup clientLoopGroup = new NioEventLoopGroup(); //创建客户端启动对象 Bootstrap bootstrap = new Bootstrap(); try { bootstrap.group(clientLoopGroup)//设置线程组 .channel(NioSocketChannel.class)//设置客户端通道实现类 .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { socketChannel.pipeline().addLast(new NettyClientHandler());//加入自定义处理器 } }); System.out.println("客户端已准备就绪"); //连接服务器 ChannelFuture cf = bootstrap.connect("127.0.0.1", 6667).sync(); cf.channel().closeFuture().sync(); } catch (InterruptedException e) { e.printStackTrace(); } finally { clientLoopGroup.shutdownGracefully(); } } }/** * 客户端自定义handler */ public class NettyClientHandler extends ChannelInboundHandlerAdapter { /** * 通道准备就绪时调用 * * @param ctx * @throws Exception */ @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { ctx.writeAndFlush(Unpooled.copiedBuffer("你好,服务端!", CharsetUtil.UTF_8)); } /** * 获取客户端回复 * @param ctx * @param msg * @throws Exception */ @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ByteBuf buf = (ByteBuf) msg; System.out.println("服务端回复: " + buf.toString(CharsetUtil.UTF_8)); } /** * 处理异常,一般是关闭通道 * * @param ctx * @param cause * @throws Exception */ @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { cause.printStackTrace(); ctx.close(); } }运行结果任务队列中的 task 有 3 种使用场景用户自定义的普通任务ctx.channel().eventLoop().execute(() -> System.out.println("任务逻辑")); 2.用户自定义的定时任务ctx.channel().eventLoop().schedule(() -> System.out.println("任务逻辑..."), 60, TimeUnit.SECONDS); 3.非当前 reactor 线程调用 channel 的各种方法 例如在推送系统的业务线程里面,根据用户的标识,找到对应的 channel 引用,然后调用 write 类方法向该用户推送消 息,就会进入到这种场景。最终的 write 会提交到任务队列中后被异步消费。Netty 模型小结Netty 抽象出两组线程池:BossGroup 专门负责接收客户端的连接;WorkerGroup 专门负责网络的读写。NioEventLoop 表示一个不断循环的执行任务的线程,每个 NioEventLoop 都有一个 selector,用于监听绑定在其上的 socket 的网络通道。NioEventLoop 内部采用串行化设计,从消息读取->处理->编码->发送始终由 I/O 线程 NioEventLoop 负责。NioEventLoopGroup 下包含多个 NioEventLoop。每个 NioEventLoop 中包含一个 Selector,一个 taskQueue。每个 NioEventLoop 的 Selector 可以注册监听多个 NioChannel。每个 NioChannel 只会绑定唯一的 NioEventLoop。每个 NioChannel 都会绑定一个自己的 ChannelPipeLine。Netty 核心组件BootStrap、ServerBootStrap一个 Netty 应用通常由一个 BootStrap 开始,主要作用是配置整个 Netty 程序,串联各个组件,Netty 中的 BootStrap 类是客户端程序的启动引导类,ServerBootStrap 是服务端启动引导类。常用方法:方法含义public ServerBootstrap group(EventLoopGroup parentGroup , EventLoopGroup childGroup)作用于服务器端,用来设置两个 EventLooppublic B group(EventLoopGroup group)作用于客户端,用来设置一个 EventLoopGrouppublic B channel(Class<? extends C> channelClass)用来设置一个服务端的通道实现public <T> B option(ChannelOption<T> option, T value)用来给 ServerChannel 添加配置public <T> ServerBootStrap childOption (ChannelOption<T> childOption, T value)用来给接收到的通道添加配置public ServerBootstrap childHandler (ChannelHandler childHandler)用来设置业务处理类(自定义 handler)public B handler(ChannelHandler handler)Handler 则在服务器端本身 bossGroup 中使用public ChannelFuture bind(int inetPort)用于服务端,设置占用的端口号public ChannelFuture connect (String inetHost,int inetPort)该方法用于客户端,用来连接服务器Future、ChannelFutureNetty 中所有操作都是异步的,不能立即得知消息是否被正确处理,但可以过一会等它执行完成或直接注册一个监听器,具体实现通过 Future 和 ChannelFuture,它们可以注册一个监听,当操作执行成功或失败时,监听会自动触发注册的监听事件。常用方法:方法含义Channel channel()返回当前正在进行 I/O 操作的通道ChannelFuture sync()等待异步操作执行完毕ChannelChannel 是 Netty 网络通信组件,能够用于执行网络 I/O 操作。通过 Channel 可获得当前网络连接的通道状态、配置参数(比如缓冲区大小)。Channel 提供异步的网络 I/O 操作(建立连接,读写,绑定端口),异步调用意味着任何 I/O 调用都将立即返回,但不保证在调用结束时所请求的 I/O 操作已完成。调用立即返回一个 ChannelFuture 实例,通过注册监听器,可以在 I/O 操作成功、失败或取消时回调通知调用方。支持关联 I/O 操作与对应的处理程序。不同协议、不同的阻塞类型的连接是不同的,Channel 类型与之对应。常用的 Channel 类型有:方法含义NioSocketChannel异步的客户端 TCP Socket 连接NioServerSocketChannel异步的服务端 TCP Socket 连接NioDatagramChannel异步的 UDP 连接NioStcpChannel异步的客户端 Sctp 连接NioSctpServerChannel异步的服务端 Sctp 连接SelectorNetty 基于 Selector 对象实现 I/O 多路复用,通过 Selector 一个线程可以监听多个连接的 Channel 事件。当向一个 Selector 中注册 Channel 后,Selector 内部的机制就可以自动不断地查询(select)这些 Channel 中是否有就绪的 I/O 事件(可读、可写、完成网络连接等),这样程序就可以简单地使用一个线程高效地管理多个 Channel。ChannelHandlerChannelHandler 是一个接口,处理 I/O 事件或拦截 I/O 操作,并将其转发到其 ChannelPipeline(业务处理链)中的下一个处理程序。ChannelHandler 本身并没有提供很多方法,因为这个接口有许多的方法需要实现,方便使用期间,可以继承他的子类。ChannelInboundHandler : 用于处理 Channel 入站 I/O 事件。ChannelOutBoundHandler:用于处理 Channel 出站 I/O 操作。适配器:ChannelInboundHandlerAdapter:用于处理出站 I/O 操作。ChanneInboundHandlerAdapter:用于处理入站 I/O 操作。ChannelDuplexHandler:用于处理入站和出站事件。以客户端应用程序为例:如果事件运动方向是客户端服务器,我们称之为“出站”,即客户端发送的数据会通过 pipeline 中的一系列 ChannelOutboundHandler,并被这些 Handler 处理,反之称为“入站”。Pipeline、ChannelPipelineChannelPipeline 是一个重点:ChannelPipeline 是一个 Handler 的集合,它负责处理和拦截 Inbound 或者 outbound 的事件和操作。ChannelPipeline 实现了一种高级形式的拦截过滤器模式,使用户可以完全控制事件的处理方式,以及 Channel 中各个的 ChannelHandler 如何相互交互。在 Netty 中每个 Channel 都有且仅有一个 ChannelPipeline 与之对应,它们的组成关系如下:一个 Channel 包含了一个 ChannelPipeline,而 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表,并且每个 ChannelHandlerContext 中又关联了一个 ChannelHandler。入站事件和出站事件在一个双向链表中,入站事件会从链表 head 往后传递到最后一个入站的 Handeler,出站事件会从链表 tail 往前传递到最前一个出站的 Handler,两种类型的 Handler 互不干扰。常用方法:方法含义ChannelPipeline addFirst(ChannelHandler... handlers)把一个业务处理类,放到链表中头结点的位置ChannelPipeline addLast(ChannelHandler... handlers)把一个业务处理类,放到链表中尾结点的位置ChannelHandlerContext保存 Channel 相关的所有上下文信息,同时关联一个 ChannelHandler 对象。即 ChannelHandlerContext 中包含一个具体的事件处理器 ChannelHandler,同时 ChannelHandlerContext 中也绑定了对应的 pipeline 和 Channel 的信息,方便对 ChannelHandler 进行调用。常用方法:方法含义ChannelFuture close()关闭通道ChannelOutboundInvoker flush()刷新ChannelFuture writeAndFlush(Object msg)将数据写入到 ChannelPipeline 中当前 ChannelHandler 的下一个 ChannelHandler 开始处理(出站)ChannelOptionNetty 在创建 Channel 实例后,一般需要通过 ChannelOption 参数来配置 channel 的相关属性。ChannelOption 参数如下:ChannelOption.SO_BACKLOG:对应 TCP/IP 协议 listen 函数中的 backlog 参数,用来初始化服务器可连接队列大小。服务端处理客户端连接请求是顺序处理的,所以同一时间只能处理一个客户端连接,多个客户端来的时候,服务端将不能处理的客户端连接请求放在队列中等待处理,backlog 参数指定了队列的大小。ChannelOption.SO_KEEPALIVE:一直保持连接活动状态。EventLoopGroup、以及实现类 NioEventLoopGroupEventLoopGroup 本质上是一个接口(interface),继承了 EventExecutorGroup,通过继承关系分析,可以发现 EventLoopGroup 的实现子类是 MultithreadEventLoopGroup 下的 NioEventLoopGroup。EventLoopGroup 是一组 EventLoop 的抽象,Netty 为了更好的利用多核 CPU 资源,一般会有多个 EventLoop 同时工作,每个 EventLoop 维护了一个 selector 实例。EventLoopGroup 提供next接口,可以从组里按照一定规则获取其中一个 EventLoop 来处理任务。在 Netty 服务器端编程中,我们一般都需要提供两个 EventLoopGroup,例如 BossEventLoopGroup 和 WorkerEventLoopGroup。通常一个服务端口(ServerSocketChannel)对应一个 Selector 和一个 EventLoop 线程。BossEventLoopGroup 负责接收客户端连接并将 SocketChannel 交给 WorkerEventLoopGroup 进行 I/O 处理。BossEventLoopGroup 通常是一个单线程的 EventLoop,EventLoop 维护了一个注册了 ServerSocketChannel 的 Selector 实例。BossEventLoopGroup 不断轮询 Selector 将连接事件分离出来。通常是 OP_ACCEPT 事件,然后将接收的 SocketChannel 交给 WorkerEventLoopGroup。WorkerEventLoopGroup 会由 next 选择其中一个 EventLoop 将这个 SocketChannel 注册到其维护的 Selector 并对其后续的 I/O 事件进行处理。UnpooledUnpolled 类是 Netty 提供的专门用于操作缓冲区(即 Netty 的数据容器)的工具类。通过给定的数据和字符编码返回一个 ByteBuf 对象:常用方法:public static ByteBuf copierBuffer(CharSequence string, Charset charset)。ByteBuf buffer = Unpooled.buffer(10); ByteBuf buf =Unpooled.copiedBuffer("你好", CharsetUtil.UTF_8);在 Netty 的 buffer 中,读取 buffer 中的数据不需要通过 flip()方法进行状态切换,其底层维护了 readerIndex 和 writerIndex0 ——> readerIndex :已读区域。readerIndex ——> writerIndex:未读但可读区域。writerIndex ——> capacity:可写区域。每调用一次 byteBuf.readByte()读取数据,byteBuf 的 readerIndex 便减少 1;调用 byteBuf.getByte()则不会引起 readerIndex 的变化。public abstract CharSequence getCharSequence(int index, int length, Charset charset) :的作用是按照某一个范围进行数据的读取,index 表示起始位置,length 表示读取长度,charset 表示字符编码格式。Netty 实现群聊系统服务器端:检测用户上线、离线、转发客户端消息。客户端:通过 channel 可以无阻塞发送消息给其他客户,同时可以接收其他客户端发送的消息(服务器转发得到)。Server 端public class Server { private static final int port = 6667; public static void main(String[] args) { run(); } /** * 处理客户端请求 */ public static void run() { //创建两个线程组 NioEventLoopGroup bossLoopGroup = new NioEventLoopGroup(1); NioEventLoopGroup workerLoopGroup = new NioEventLoopGroup(); ServerBootstrap serverBootstrap = new ServerBootstrap(); try { serverBootstrap.group(bossLoopGroup, workerLoopGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 128) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { socketChannel.pipeline() //增加解码器 .addLast("decoder", new StringDecoder()) //增加编码器 .addLast("encoder", new StringEncoder()) //加入自定义业务处理器 .addLast(new ServerHandler()); } }); ChannelFuture future = serverBootstrap.bind(port).sync(); future.channel().closeFuture().sync(); } catch (InterruptedException e) { e.printStackTrace(); } finally { workerLoopGroup.shutdownGracefully(); bossLoopGroup.shutdownGracefully(); } } }ServerHandlerpublic class ServerHandler extends SimpleChannelInboundHandler<String> { /** * 定义一个channel 组,管理所有的channel , GlobalEventExecutor.INSTANCE是全局事件执行器,单例模式 */ private static ChannelGroup channels = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); /** * 连接建立调用,将当前channel加入channelGroup * * @param ctx * @throws Exception */ @Override public void handlerAdded(ChannelHandlerContext ctx) throws Exception { Channel channel = ctx.channel(); //提示其他客户端当前客户端已上线 channels.writeAndFlush("[客户端]" + channel.remoteAddress() + "加入聊天!\n"); channels.add(channel); } /** * 表示channel处于活动状态,提示上线 * * @param ctx * @throws Exception */ @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { System.out.println(ctx.channel().remoteAddress() + ":已上线!"); } /** * 非活动状态提示 离线 * * @param ctx * @throws Exception */ @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { System.out.println(ctx.channel().remoteAddress() + ":已离线!"); } /** * 断开连接 * * @param ctx * @throws Exception */ @Override public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { Channel channel = ctx.channel(); //提示其他客户端当前客户端已断开连接 channels.writeAndFlush("[客户端]" + channel.remoteAddress() + "断开连接!\n"); } /** * 读取客户端消息并转发 * @param channelHandlerContext * @param msg * @throws Exception */ @Override protected void channelRead0(ChannelHandlerContext channelHandlerContext, String msg) throws Exception { Channel channel = channelHandlerContext.channel(); channels.forEach(ch -> { if (channel != ch) { ch.writeAndFlush("[客户]: " + channel.remoteAddress() + sdf.format(new Date()) +" 说:" + msg + "\n"); } else { ch.writeAndFlush(sdf.format(new Date())+" 你说:" + msg + "\n"); } }); } /** * 异常关闭 * @param ctx * @param cause * @throws Exception */ @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { ctx.close(); } }Client 端public class Client { private static final String HOST = "127.0.0.1"; private static final int PORT = 6667; public static void main(String[] args) { run(); } public static void run() { NioEventLoopGroup clientLoopGroup = new NioEventLoopGroup(); Bootstrap bootstrap = new Bootstrap(); try { bootstrap.group(clientLoopGroup) .channel(NioSocketChannel.class) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { socketChannel.pipeline() //增加解码器 .addLast("decoder", new StringDecoder()) //增加编码器 .addLast("encoder", new StringEncoder()) .addLast(new ClientHandler()); } }); ChannelFuture future = bootstrap.connect(HOST, PORT).sync(); Channel channel = future.channel(); System.out.println("客户端:" + channel.localAddress() + " 准备就绪"); Scanner scanner = new Scanner(System.in); while (scanner.hasNext()) { String msg = scanner.nextLine(); //通过channel发送到服务器端 channel.writeAndFlush(msg + "\r\n"); } } catch (InterruptedException e) { e.printStackTrace(); } finally { clientLoopGroup.shutdownGracefully(); } } }ClientHandlerpublic class ClientHandler extends SimpleChannelInboundHandler<String> { @Override protected void channelRead0(ChannelHandlerContext channelHandlerContext, String msg) throws Exception { System.out.println(msg.trim()); } }运行结果Netty 心跳监测机制案例客户端同用上面的即可。记得端口对应Server 端public class Server { public static void main(String[] args) { //创建两个线程组 NioEventLoopGroup bossLoopGroup = new NioEventLoopGroup(1); NioEventLoopGroup workerLoopGroup = new NioEventLoopGroup(); try { ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.group(bossLoopGroup, workerLoopGroup) .channel(NioServerSocketChannel.class) .handler(new LoggingHandler(LogLevel.INFO))//在bossLoopGroup 增加日志处理器 .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { ChannelPipeline pipeline = socketChannel.pipeline(); // 加入 IdleStateHandler // 第一个参数 多长时间没读 就发送心跳监测包看是否连接 // 第二个参数 多长时间没写 就发送心跳监测包看是否连接 // 第三个参数 多长时间没有读写 就发送心跳监测包看是否连接 // 第四个参数 时间单位 //当 触发后 会传递给管道中的下一个handler来处理,调用下一个handler的userEventTriggered pipeline.addLast(new IdleStateHandler(3,5,7, TimeUnit.SECONDS)); //加入空闲检测处理的handler pipeline.addLast(new ServerHandler()); } }); ChannelFuture future = serverBootstrap.bind(7000).sync(); future.channel().closeFuture().sync(); } catch (Exception e) { e.printStackTrace(); } finally { workerLoopGroup.shutdownGracefully(); bossLoopGroup.shutdownGracefully(); } } }ServerHandlerpublic class ServerHandler extends ChannelInboundHandlerAdapter { /** * @param ctx 上下文 * @param evt 事件 * @throws Exception */ @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent){ //将evt转型 IdleStateEvent event = (IdleStateEvent) evt; SocketAddress socketAddress = ctx.channel().remoteAddress(); switch (event.state()){ case READER_IDLE: System.out.println(socketAddress + "发生读空闲"); break; case WRITER_IDLE: System.out.println(socketAddress + "发生写空闲"); break; case ALL_IDLE: System.out.println(socketAddress + "发生读写空闲"); break; } } } }运行结果WebSocketHttp 短连接和长连接Http 短连接即 TCP 短连接,即客户端和服务器通过“三次握手”建立连接后,进行一次 HTTP 操作以后,便断开连接。因此,浏览器每打开一个 web 资源,便创建了一个新的 http 会话。Http 长连接即 TCP 长连接,即客户端和服务器建立连接后保持一定的时间,即使用户在进行某次操作后将浏览器(或客户端)关闭,但只要在保持时间内又一次访问该服务器,则默认使用已经创建好的连接。Http1.0 默认支持短连接,Http1.1 默认支持长连接。Http 连接无状态Http 协议无状态是指协议对于事务处理没有记忆性,即某一次打开一个服务器的网页和上一次打开这个服务器的网页之间没有关系。WebSocket 简介WebSocket 是一种可以在单个 TCP 连接上实现全双工通信的通信协议,HTTP 协议只能实现客户端请求,服务端响应的单向通信,而 webSocket 则可以实现服务端主动向客户端推送消息。WebSocket 复用了 HTTP 的握手通道,客户端和服务器的数据交换则遵照升级后的协议进行:WebSocket 相关的业务处理器可以将 HTTP 协议升级为 ws 协议,其核心功能之一为保持稳定的长连接。代码案例实现基于 webSocket 的长连接全双工交互。改变 HTTP 协议多次请求的约束,实现长连接,服务器可以发送消息给浏览器。客户端和服务器会相互感知。若服务器关闭,客户端会感知;同样客户端关闭,服务器也会感知。Server 端public class Server { public static void main(String[] args) throws InterruptedException { EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup(); try{ ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.group(bossGroup,workerGroup) .channel(NioServerSocketChannel.class) .handler(new LoggingHandler(LogLevel.INFO)) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ChannelPipeline pipeline = ch.pipeline(); //因为基于http协议,故使用http的编解码器 pipeline.addLast(new HttpServerCodec()); //过程中以块的方式写,添加 ChunkedWriteHandler 处理器 pipeline.addLast(new ChunkedWriteHandler()); /** * 说明 * 1、http数据在传输过程中是分段的,HttpObjectAggregator 可以将多个数据段整合起来 * 2、因此,当浏览器发送大量数据时,就会发出多次http请求 * */ pipeline.addLast(new HttpObjectAggregator(8192)); /** * 说明 * 1、对于 WebSocket,它的数据以 帧(Frame)的形式传递 * 2、可以看到 WebSocketFrame 下面有6个子类 * 3、浏览器请求时 ws://localhost:7000/xxx 表示请求的uri * 4、WebSocketServerProtocolHandler 会把 http 协议升级为ws协议 * 即保持长连接----------核心功能 * 5、如何升级——通过状态玛切换101 */ pipeline.addLast(new WebSocketServerProtocolHandler("/hello")); //自定义的 handler 处理业务逻辑 pipeline.addLast(new TextWebSocketFrameHandler()); } }); ChannelFuture channelFuture = serverBootstrap.bind(7000).sync(); channelFuture.channel().closeFuture().sync(); }finally { workerGroup.shutdownGracefully(); bossGroup.shutdownGracefully(); } } }Handlerpublic class TextWebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> { @Override protected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame textWebSocketFrame) throws Exception { System.out.println("服务器收到消息:" + textWebSocketFrame.text()); //回复消息 channelHandlerContext.channel().writeAndFlush(new TextWebSocketFrame("服务器时间:" + LocalDateTime.now() + " " + textWebSocketFrame.text())); } @Override public void handlerAdded(ChannelHandlerContext ctx) throws Exception { System.out.println("handlerAdded 被调用:" + ctx.channel().id().asLongText()); } @Override public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { System.out.println("handlerRemoved 被调用:" + ctx.channel().id().asLongText()); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { System.out.println("异常发生"+cause.getMessage()); ctx.close(); } }HTML<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>Title</title> </head> <body> <script> var socket; //判断当前浏览器是否支持webSocket编程 if (window.WebSocket) { //go on socket = new WebSocket("ws://localhost:7000/hello"); //相当于channelRead0,收到服务器端回送的消息 socket.onmessage = function (ev) { var rt = document.getElementById("responseText"); rt.value = rt.value + "\n" + ev.data; }; //相当于连接开启 socket.onopen = function (ev) { var rt = document.getElementById("responseText"); rt.value = "连接开启"; }; socket.onclose = function (ev) { var rt = document.getElementById("responseText"); rt.value = rt.value + "\n" + "连接关闭"; }; } else { alert("当前浏览器不支持webSocket"); } //发送消息到服务器 function send(message) { if (!window.socket) { //先判断socket是否创建好了 return; } if (socket.readyState == WebSocket.OPEN) { //通过socket发送消息 socket.send(message); } else { alert("连接没有开启"); } } </script> <form onsubmit="return false"> <textarea name="message" style="height: 300px; width: 300px"></textarea> <input type="button" value="发送消息" onclick="send(this.form.message.value)" /> <textarea id="responseText" style="height: 300px; width: 300px" ></textarea> <input type="button" value="清空内容" onclick="document.getElementById('responseText').value=''" /> </form> </body> </html>运行结果编码和解码数据在网络中是以二进制字节码的形式流动,而我们在接收或发送的数据形式则各种各样(文本、图片、音视频等),因此需要在发送端对数据进行编码,在接收端对收到的数据解码;codec(编解码器)的组成部分——Encoder(编码器)负责将业务数据转换为二进制字节码;Decoder(解码器)负责将二进制字节码转换为业务数据。Netty 编码机制——StringEncoder / StringDecoder负责字符串数据对象的编解码;ObjectEncoder / ObjectDecoder负责 java 对象的编解码。Netty 自带的 ObjectEncoder 和 ObjectDecoder 可以用于实现 POJO 对象或其他业务对象的编解码,其底层使用的仍是 java 的序列化技术,存在以下问题:无法实现客户端与服务器端的跨语言。序列化体积过大,是二进制字节码的 5 倍多。序列化性能相对较低。ProtoBuf 概述ProtoBuf 是 Google 发布的开源项目,全称 Google Protocol Buffers,ProtoBuf 是一种平台无关、语言无关的、可扩展且轻便高效的序列化数据结构的协议,适合用于数据存储和 RPC(远程过程调用)数据交换格式。ProtoBuf 是以Message的方式来管理数据的。所谓“平台无关、语言无关”,即客户端和服务器可以使用不同的编程语言进行开发。ProtoBuf 具有更高的性能和可靠性。使用 ProtoBuf 编译器可以自动生成代码,ProtoBuf 是把类的定义使用.proto文件描述出来,在通过 proto.exe 将.proto 文件编译为.java 文件。ProtoBuf 使用第一步:idea 加入插件 protoc第二步:加入 maven 依赖<dependency> <groupId>com.google.protobuf</groupId> <artifactId>protobuf-java</artifactId> <version>3.6.1</version> </dependency>第三步:编写 proto 文件syntax = "proto2"; //版本 option java_outer_classname = "StudentPOJO"; //生成的外部类名称,同时文件名 //protobuf以message的形式管理数据 message Student{ //会在 studentPOJO 外部类生成一个内部类 Student,它是真正发送的POJO对象 required int32 id = 1; //表示 Student 类中有一个属性 名字为id,类型为 int32(protoType),1表示属性的序号 required string name = 2; }根据网上教程安装 protobuf。生成 StudnetPOJO 文件,这里就不展示代码了,比较长。Server 端public class Server { public static void main(String[] args) throws InterruptedException { EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 128) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ChannelPipeline pipeline = ch.pipeline(); //在pipeline中加入ProtoBufferDecoder //指定对哪一种对象进行解码 pipeline.addLast("decoder", new ProtobufDecoder(StudentPOJO.Student.getDefaultInstance())); pipeline.addLast(new ServerHandler()); } }); ChannelFuture cf = serverBootstrap.bind(6668).sync(); //给 cf 添加监听器,监听感兴趣的事件 cf.addListener((ChannelFutureListener) future -> { if (cf.isSuccess()) { System.out.println("绑定端口 6668 成功"); } else { System.out.println(cf.cause()); } }); cf.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } }ServerHandlerpublic class ServerHandler extends SimpleChannelInboundHandler<StudentPOJO.Student> { @Override public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { ctx.writeAndFlush(Unpooled.copiedBuffer("hello,客户端~", CharsetUtil.UTF_8)); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { ctx.close(); } @Override protected void channelRead0(ChannelHandlerContext ctx, StudentPOJO.Student msg) throws Exception { System.out.println("客户端发送: id = " + msg.getId() + " 名字 = " + msg.getName()); } }Client 端public class ClientHandler extends ChannelInboundHandlerAdapter { @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { //发送一个 student 对象到服务器 StudentPOJO.Student student = StudentPOJO.Student.newBuilder().setId(1000).setName("Jack").build(); ctx.writeAndFlush(student); } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ByteBuf buf = (ByteBuf) msg; System.out.println("服务器回送消息:" + buf.toString(CharsetUtil.UTF_8)); System.out.println("服务器端地址:" + ctx.channel().remoteAddress()); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { cause.printStackTrace(); ctx.close(); } }ClientHandlerpublic class Client { public static void main(String[] args) throws InterruptedException { NioEventLoopGroup eventExecutors = new NioEventLoopGroup(); try { Bootstrap bootstrap = new Bootstrap(); bootstrap.group(eventExecutors) .channel(NioSocketChannel.class) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { //在pipeline中加入ProtoBufferEncoder ChannelPipeline pipeline = ch.pipeline(); //编码 pipeline.addLast("encoder", new ProtobufEncoder()); pipeline.addLast(new ClientHandler()); } }); System.out.println("客户端已准备就绪"); ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 6668).sync(); channelFuture.channel().closeFuture().sync(); } finally { eventExecutors.shutdownGracefully(); } } }运行结果handler 调用机制ChannelHandler 充当了处理入站和出站数据的应用程序逻辑的容器。例如:实现 ChannelInboundHandler 接口(或 ChannelInboundHandlerAdapter),可以接收入站事件和数据,这些数据将被业务逻辑处理;当给客户端回送响应时,也可以通过 ChannelInboundHandler 冲刷数据。业务逻辑通常写在一个或多个 ChannelInboundHandler 中。ChannelOutboundHandler 与之类似,只不过是用来处理出站数据的。ChannelPipeline 提供了 ChannelHandler 链的容器(pipeline.addLast()可以将一系列的 handler 以链表的形式添加),以客户端应用程序为例,如果事件运动方向为客户端->服务器,称之为“出站”,即客户端发送给服务器的数据通过 pipeline 中的一系列 ChannelOutboundHandler,并被这些 handler 处理。反之则称为“入站”。编码解码器当 Netty 发送或者接受一个消息的时候,就将会发生一次数据转换。入站消息会被解码:从字节码转换到另一种格式(比如 Java)。如果是出站消息,它会被编码成字节。Netty 提供一系列使用的编解码器,它们都实现了 CHannelInboundHandler 或者 ChannelOutboundHandler 接口。在这些类中,channelRead 方法已经被重写。以入站为例,对于每个从入站 Channel 读取的消息,这个方法会被调用。随后,他将调用由解码器所提供的 decode()方法进行解码,并将已经解码的字节转发给 ChannelPipeline 中的下一个 ChannelInboundHandler。消息入站后,会经过 ChannelPipeline 中的一系列 ChannelHandler 处理,这些 handler 中有 Netty 已经实现的,也有我们重新实现的自定义 handler,但它们都需要实现 ChannelInboundHandler 接口;即消息入站后所经过的 handler 链是由一系列 ChannelInboundHandler 组成的,其中第一个经过的 handler 就是解码器 Decoder;消息出站与入站类似,但消息出站需要经过一系列 ChannelOutboundHandler 的实现类,最后一个经过的 handler 是编码器 Encoder。解码器 — ByteToMessageDecoder由于不知道远程节点是否会发送一个完整的信息,TCP 可能出现粘包和拆包的问题。ByteToMessageDecoder 的作用就是对入站的数据进行缓冲,直至数据准备好被处理。ByteToMessageDecoder 示例分析:public class ToIntgerDecoder extends ByteToMessageDecoder{ @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception{ if (in.readableBytes() >= 4) { out.add(in.readint()); } } }在此实例中,假设通过 Socket 发送了 8 字节数据,每次入站从 ByteBuf 中读取个 4 字节,将其解码为一个 int,并加入一个 List 中。当没有更多的元素可以被添加到该 List 中时,代表此次发送的数据已发送完成,List 中的所有内容会被发送给下一个 ChannelInboundHandler。Int 在被添加到 List 中时,会被自动装箱为 Intger,调用 readInt()方法前必须验证所输入的 ByteBuf 是否有足够的数据。代码示例:使用自定义的编码解码器客户端可以发送一个 Long 类型的数据给服务器。Server 端public class Server { public static void main(String[] args) { NioEventLoopGroup bossGroup = new NioEventLoopGroup(1); NioEventLoopGroup workGroup = new NioEventLoopGroup(); ServerBootstrap serverBootstrap = new ServerBootstrap(); try { serverBootstrap.group(bossGroup, workGroup) .channel(NioServerSocketChannel.class) .childHandler(new ServerInitializer()); //自定义初始化类 ChannelFuture future = serverBootstrap.bind(7000).sync(); future.channel().closeFuture().sync(); } catch (Exception e) { e.printStackTrace(); } finally { workGroup.shutdownGracefully(); bossGroup.shutdownGracefully(); } } }ServerInitializer 自定义初始化类public class ServerInitializer extends ChannelInitializer<SocketChannel> { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { ChannelPipeline pipeline = socketChannel.pipeline(); //入站的handler解码 pipeline.addLast(new ByteToLongDecoder()).addLast(new ServerInboundHandler()); } }ByteToLongDecoder 自定义解码器public class ByteToLongDecoder extends ByteToMessageDecoder { /** * @param channelHandlerContext 上下文对象 * @param byteBuf 入站的ByteBuf * @param list List集合,将解码后的数据传给下一个Handler * @throws Exception */ @Override protected void decode(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf, List<Object> list) throws Exception { // Long 大于 8个字节 if (byteBuf.readableBytes() >= 8) { list.add(byteBuf.readLong()); } } }ServerInboundHandler 自定义 handler,处理业务public class ServerInboundHandler extends SimpleChannelInboundHandler<Long> { @Override protected void channelRead0(ChannelHandlerContext channelHandlerContext, Long aLong) throws Exception { System.out.println("从客户端读取:" + aLong); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { cause.printStackTrace(); ctx.close(); } }Client 端public class Client { public static void main(String[] args) { NioEventLoopGroup clientLoopGroup = new NioEventLoopGroup(); try { Bootstrap bootstrap = new Bootstrap(); bootstrap.group(clientLoopGroup) .channel(NioSocketChannel.class) .handler(new ClientInitializer());//自定义初始化类 ChannelFuture future = bootstrap.connect("127.0.0.1", 7000).sync(); future.channel().closeFuture().sync(); } catch (Exception e) { e.printStackTrace(); } finally { clientLoopGroup.shutdownGracefully(); } } }ClientInitializer 客户端自定义初始化类public class ClientInitializer extends ChannelInitializer<SocketChannel> { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { ChannelPipeline pipeline = socketChannel.pipeline(); //出站,数据进行编码 pipeline.addLast(new LongToByteEncoder()).addLast(new ClientHandler()); } }LongToByteEncoder 编码器public class LongToByteEncoder extends MessageToByteEncoder<Long> { @Override protected void encode(ChannelHandlerContext channelHandlerContext, Long aLong, ByteBuf byteBuf) throws Exception { System.out.println("开始编码,msg = " + aLong); byteBuf.writeLong(aLong); } }ClientHandler 自定义 handler,处理逻辑public class ClientHandler extends SimpleChannelInboundHandler<Long> { @Override protected void channelRead0(ChannelHandlerContext ctx, Long msg) throws Exception { System.out.println("服务器的ip : " + ctx.channel().remoteAddress()); System.out.println("收到服务器数据 : " + msg); } /** * 发送数据 * * @param ctx * @throws Exception */ @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { System.out.println("client发送数据"); ctx.writeAndFlush(12345678L); } }运行结果其他解码器LineBasedFrameDecoder:它使用行尾控制字符(\n 或\r\n)作为分割符来解析数据;DelimiterBasedFrameDecoder:使用自定义的特殊字符作为分隔符;HttpObjectDecoder:一个 HTTP 数据的解码器;LengthFieldBasedFrameDecoder:通过指定长度来标识整包信息,这样就可以自动的处理粘包和半包信息TCP 粘包和拆包基本介绍TCP 是面向连接,面向流,提供高可靠性服务。在消息收发过程中,需要在发送端和接收端建立对应的 Socket,发送端不会一有数据就进行发送,而是将多次间隔较小的,数据量较小的数据合并成一定长度的数据包整体发送。这样可以提高效率,但会给接收方分辨单个数据消息增加难度,因为面向流的通信是没有消息保护边界的。TCP 粘包与拆包,是指发送端在发送多个数据消息时出现的不同情形。由于数据在发送前需要先转换为二进制字节码,当多个数据消息的字节码被合并成一个数据包发送时,称为粘包;当某个数据消息的字节码被划分到几个数据包内发送时,称为拆包。粘包和拆包可能使接收端解码数据包时出现错误。TCP 粘包和拆包的解决方案:使用自定义协议+编解码器解决,只要接收端能够知道每次读取数据的长度,就可以按位读取,避免出现读取错误。我们需要做的就是使接收端知道每次读取数据的长度。TCP 粘包拆包代码演示Server 端public class Server { public static void main(String[] args) { NioEventLoopGroup bossGroup = new NioEventLoopGroup(1); NioEventLoopGroup workGroup = new NioEventLoopGroup(); ServerBootstrap serverBootstrap = new ServerBootstrap(); try { serverBootstrap.group(bossGroup, workGroup) .channel(NioServerSocketChannel.class) .childHandler(new ServerInitializer()); //自定义初始化类 ChannelFuture future = serverBootstrap.bind(7000).sync(); future.channel().closeFuture().sync(); } catch (Exception e) { e.printStackTrace(); } finally { workGroup.shutdownGracefully(); bossGroup.shutdownGracefully(); } } }ServerInitializerpublic class ServerInitializer extends ChannelInitializer<SocketChannel> { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { ChannelPipeline pipeline = socketChannel.pipeline(); pipeline.addLast(new ServerHandler()); } }ServerHandlerpublic class ServerHandler extends SimpleChannelInboundHandler<ByteBuf> { private int count; @Override protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) throws Exception { byte[] buffer = new byte[buf.readableBytes()]; buf.readBytes(buffer); //将buffer转换成字符串 String str = new String(buffer, CharsetUtil.UTF_8); System.out.println("服务端接收到数据:" + str); System.out.println("服务端接收次数:" + ++count); ByteBuf byteBuf = Unpooled.copiedBuffer(UUID.randomUUID().toString(), CharsetUtil.UTF_8); ctx.writeAndFlush(byteBuf); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { cause.printStackTrace(); ctx.close(); } }Client 端public class Client { public static void main(String[] args) { NioEventLoopGroup clientLoopGroup = new NioEventLoopGroup(); try { Bootstrap bootstrap = new Bootstrap(); bootstrap.group(clientLoopGroup) .channel(NioSocketChannel.class) .handler(new ClientInitializer());//自定义初始化类 ChannelFuture future = bootstrap.connect("127.0.0.1", 7000).sync(); future.channel().closeFuture().sync(); } catch (Exception e) { e.printStackTrace(); } finally { clientLoopGroup.shutdownGracefully(); } } }ClientInitializerpublic class ClientInitializer extends ChannelInitializer<SocketChannel> { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { ChannelPipeline pipeline = socketChannel.pipeline(); pipeline.addLast(new ClientHandler()); } }ClientHandlerpublic class ClientHandler extends SimpleChannelInboundHandler<ByteBuf> { private int count; @Override protected void channelRead0(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf) throws Exception { byte[] bytes = new byte[byteBuf.readableBytes()]; byteBuf.readBytes(bytes); String str = new String(bytes, CharsetUtil.UTF_8); System.out.println("客户端接收到数据: " + str); System.out.println("客户端接收次数:" + ++count); } @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { //发送十条数据 for (int i = 0; i < 10; i++) { ByteBuf byteBuf = Unpooled.copiedBuffer("hello,server" + i, CharsetUtil.UTF_8); ctx.writeAndFlush(byteBuf); } } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { cause.printStackTrace(); ctx.close(); } }运行结果可以看到在第一个客户端没有发生问题,启动第二个客户端后就发生了拆包问题。自定义协议解决粘包拆包要求客户端发送 5 个 message 对象,客户端每次发送一个 message 对象。服务器端每次接收一个 message,分 5 次进行解码,每读取一个 message,会回送一个 message 对象给客户端。使用自定义协议+编解码器实现具体功能:具体代码客户端与服务器主程序与之前相同MessageProtocol 自定义协议public class MessageProtocol { private int length; //关键 private byte[] context; public int getLength() { return length; } public byte[] getContext() { return context; } public void setLength(int length) { this.length = length; } public void setContext(byte[] context) { this.context = context; }MessageEncoder 自定义编码器public class MessageEncoder extends MessageToByteEncoder<MessageProtocol> { @Override protected void encode(ChannelHandlerContext ctx, MessageProtocol msg, ByteBuf out) throws Exception { System.out.println("MessageEncoder encode方法被调用"); out.writeInt(msg.getLength()); out.writeBytes(msg.getContext()); } }MessageDecoder.自定义解码器public class MessageDecoder extends ReplayingDecoder<Void> { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { System.out.println("MessageDecoder decode方法被调用"); //将得到的二进制字节码转换为 MessageProtocol 数据包 int length = in.readInt(); byte[] content = new byte[length]; in.readBytes(content); //封装成MessageProtocol对象,放入out中交给下一个handler处理 MessageProtocol messageProtocol = new MessageProtocol(); messageProtocol.setLength(length); messageProtocol.setContext(content); out.add(messageProtocol); } }在 ServerInitializer 和 ClientInitializer 中增加 addList()编解码器ServerHandlerpublic class ServerHandler extends SimpleChannelInboundHandler<MessageProtocol> { private int count; @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { cause.printStackTrace(); ctx.close(); } @Override protected void channelRead0(ChannelHandlerContext ctx, MessageProtocol msg) throws Exception { //接收数据并处理 int len = msg.getLength(); byte[] context = msg.getContext(); System.out.println("服务端接收到信息如下"); System.out.println("数据长度:"+len); System.out.println("内容:"+new String(context, CharsetUtil.UTF_8)); System.out.println("服务器接收到协议包数量 = "+(++this.count)); //回复消息 String response = UUID.randomUUID().toString(); int responseLen = response.getBytes("utf-8").length; byte[] responseBytes = response.getBytes("utf-8"); //构建一个协议包 MessageProtocol messageProtocol = new MessageProtocol(); messageProtocol.setLength(responseLen); messageProtocol.setContext(responseBytes); ctx.writeAndFlush(messageProtocol); } }ClientHandlerpublic class ClientHandler extends SimpleChannelInboundHandler<MessageProtocol> { private int count; @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { //使用客户端循环发送10条数据 for (int i=0;i<5;i++){ String mes = "今天下雨,出门带伞"; byte[] content = mes.getBytes(Charset.forName("utf-8")); int length = mes.getBytes(Charset.forName("utf-8")).length; //创建协议包 MessageProtocol messageProtocol = new MessageProtocol(); messageProtocol.setLength(length); messageProtocol.setContext(content); ctx.writeAndFlush(messageProtocol); } } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { System.out.println("异常消息 = "+cause.getMessage()); ctx.close(); } @Override protected void channelRead0(ChannelHandlerContext ctx, MessageProtocol msg) throws Exception { int len = msg.getLength(); byte[] msgContext = msg.getContext(); System.out.println("客户端接收的消息如下:"); System.out.println("消息长度 = "+len); System.out.println("消息内容 = "+new String(msgContext, CharsetUtil.UTF_8)); System.out.println("客户端接收消息的数量 = "+(++this.count)); } }
I/O 模型I/O 模型基本说明I/O 模型简单理解为:就是使用什么样的通道进行数据的发送和接收,很大程度上决定了程序通信的性能。Java 支持 3 种网络编程模型:BIO、NIO、AIO。Java BIO:同步并阻塞(传统阻塞型),服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不作任何事情会造成不必要的线程开销。Java NIO:同步非阻塞,服务器实现模式为一个线程处理多个请求(连接),即客户端发送的连接请求会被注册到多路复用器上,多路复用器轮询到有 I/O 请求就会进行处理。Java AIO:异步非阻塞,AIO 引入了异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用。同步阻塞:你到饭馆点餐,然后在那等着,还要一边喊:好了没啊!同步非阻塞:在饭馆点完餐,就去遛狗了。不过溜一会儿,就回饭馆喊一声:好了没啊!异步阻塞:遛狗的时候,接到饭馆电话,说饭做好了,让您亲自去拿。异步非阻塞:饭馆打电话说,我们知道您的位置,一会给你送过来,安心遛狗就可以了。BIO、NIO、AIO 使用场景分析BIO 方式适用于连接数比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4 之前唯一的选择,程序较为简单容易理解。NIO 方式适用于连接数目多且连接比较短的架构,比如聊天服务器,弹幕系统,服务器间通讯等,编程比较复杂,JDK1.4 开始支持。AIO 方式适用于连接数目多且连接比较长的架构,比如相册服务器,充分调用 OS 参与并发操作,变成比较复杂,JDK7 开始支持。BIO 基本介绍Java BIO 就是传统的 Java IO 编程,其相关的类和接口在 java.io 包下。BIO(Blocking I/O):同步阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时,服务器就会需要启动一个线程来进行处理。如果这个连接不作任何事情就会造成不必要的开销,可以通过线程池机制改善。BIO 编程简要流程服务器驱动一个 ServerSocket。客户端启动 Socket 对服务器进行通信,默认情况下服务器端需要对每一个客户端建立一个线程进行通信。客户端发出请求后,先咨询服务器时候否线程响应,如果没有则会等待,或者被拒绝。如果有响应,客户端线程会等待请求结束后,再继续执行。BIO 服务端代码案例public class Server { public static void main(String[] args) throws IOException { //创建线程池 ExecutorService executorService = Executors.newCachedThreadPool(); //创建serverSocket ServerSocket serverSocket = new ServerSocket(6666); for (; ; ) { System.out.println("等待连接中..."); //监听,等待客户端连接 Socket socket = serverSocket.accept(); System.out.println("连接到一个客户端"); executorService.execute(() -> handler(socket)); } } //编写一个handler方法,和客户端通讯 public static void handler(Socket socket) { byte[] bytes = new byte[1024]; System.out.println("当前线程信息: " + Thread.currentThread().getName()); try { //通过socket获取输入流 InputStream inputStream = socket.getInputStream(); //循环读取客户端发送的数据 while (inputStream.read(bytes) != -1) { System.out.println(Thread.currentThread().getName()+ " : 发送信息为 :"+ new String(bytes, 0, bytes.length)); } } catch (IOException e) { e.printStackTrace(); } finally { System.out.println("关闭连接"); try { socket.close(); } catch (IOException e) { e.printStackTrace(); } } } }运行结果使用终端命令telnet 127.0.0.1 6666BIO 问题分析每个请求都需要创建独立的线程,与对应的客户端进行数据处理。当并发数大时,需要创建大量线程来处理连接,系统资源占用较大。连接建立后,如果当前线程暂时没有数据可读,则当前线程会一直阻塞在 Read 操作上,造成线程资源浪费。NIO 基本介绍Java NIO 全称 Java non-blocking IO,指的是 JDK 提供的新 API。从 JDK 1.4 开始,Java 提供了一系列改进的输入/输出的新特性,被统称为 NIO,即 New IO,是同步非阻塞的。NIO 相关类都放在 java.nio 包下,并对原 java.io 包中很多类进行了改写。NIO 有三大核心部分:Channel(管道)、Buffer(缓冲区)、Selector(选择器)。NIO 是面向缓冲区编程的。数据读取到了一个它稍微处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞的高伸缩性网络。Java NIO 的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用数据,如果目前没有可用数据时,则说明都不会获取,而不是保持线程阻塞,所以直到数据变为可以读取之前,该线程可以做其他事情。非阻塞写入同理。NIO Buffer 的基本使用public class BufferTest { public static void main(String[] args) { //同理对应的还有:ByteBuffer,IntBuffer,FloatBuffer,CharBuffer,ShortBuffer,DoubleBuffer,LongBuffer //创建一个Buffer,大小为5 IntBuffer buffer = IntBuffer.allocate(5); //存放数据 for (int i = 0; i < buffer.capacity(); i++) { buffer.put(i); } //切换成读模式. 读写切换 buffer.flip(); while (buffer.hasRemaining()) { System.out.println(buffer.get()); // 0 1 2 3 4 } } }NIO 和 BIO 对比BIO 以流的方式处理数据,而 NIO 以块的方式处理数据,块 I/O 的效率比流 I/O 高很多。BIO 是阻塞的,而 NIO 是非阻塞的。BIO 基于字节流和字符流进行操作,而 NIO 基于 Channel(通道)和 Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于监听多个通道事件(比如连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道。NIO 三大核心组件关系说明:每个 Channel 对应一个 Buffer。Selector 对应一个线程,一个线程对应多个 Channel。该图反应了有三个 Channel 注册到该 Selector。程序切换到那个 Channel 是由事件决定的(Event)。Selector 会根据不同的事件,在各个通道上切换。Buffer 就是一个内存块,底层是有一个数组。数据的读取和写入是通过 Buffer,但是需要flip()切换读写模式。而 BIO 是单向的,要么输入流要么输出流。NIO 三大核心理解Buffer 的机制及子类Buffer(缓冲区)基本介绍缓冲区本质上是一个可以读写数据的内存块,可以理解为是一个容器对象(含数组),该对象提供了一组方法,可以更轻松地使用内存块,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况。Channel 提供从文件、网络读取数据的渠道,但是读取或者都必须经过 Buffer。在 Buffer 子类中维护着一个对应类型的数组,用来存放数据:public abstract class IntBuffer extends Buffer implements Comparable<IntBuffer> { // These fields are declared here rather than in Heap-X-Buffer in order to // reduce the number of virtual method invocations needed to access these // values, which is especially costly when coding small buffers. // final int[] hb; // Non-null only for heap buffers final int offset; boolean isReadOnly; // Valid only for heap buffers // Creates a new buffer with the given mark, position, limit, capacity, // backing array, and array offset // IntBuffer(int mark, int pos, int lim, int cap, // package-private int[] hb, int offset) { super(mark, pos, lim, cap); this.hb = hb; this.offset = offset; } // Creates a new buffer with the given mark, position, limit, and capacity // IntBuffer(int mark, int pos, int lim, int cap) { // package-private this(mark, pos, lim, cap, null, 0); }Buffer 常用子类描述ByteBuffer存储字节数据到缓冲区ShortBuffer存储字符串数据到缓冲区CharBuffer存储字符数据到缓冲区IntBuffer存储整数数据据到缓冲区LongBuffer存储长整型数据到缓冲区DoubleBuffer存储浮点型数据到缓冲区FloatBuffer存储浮点型数据到缓冲区Buffer 中定义了四个属性来提供所其包含的数据元素。// Invariants: mark <= position <= limit <= capacity private int mark = -1; private int position = 0; private int limit; private int capacity;属性描述capacity容量,即可以容纳的最大数据量;在缓冲区被创建时候就被指定,无法修改limit表示缓冲区的当前终点,不能对缓冲区超过极限的位置进行读写操作,但极限是可以修改的position当前位置,下一个要被读或者写的索引,每次读写缓冲区数据都会改变该值,为下次读写做准备Mark标记当前 position 位置,当 reset 后回到标记位置。Channel 的基本介绍NIO 的通道类似于流,但有如下区别:通道是双向的可以进行读写,而流是单向的只能读,或者写。通道可以实现异步读写数据。通道可以从缓冲区读取数据,也可以写入数据到缓冲区。常用的 Channel 有:FileChannel、DatagramChannel、SocketChannel、SocketServerChannel。FileChannel 类FileChannel 主要用来对本地文件进行 IO 操作,常见的方法有:public int read(ByteBuffer dst) :从通道中读取数据到缓冲区中。public int write(ByteBuffer src):把缓冲区中的数据写入到通道中。public long transferFrom(ReadableByteChannel src,long position,long count):从目标通道中复制数据到当前通道。public long transferTo(long position,long count,WriteableByteChannel target):把数据从当前通道复制给目标通道。使用 FileChannel 写入文本文件public class NIOFileChannel { public static void main(String[] args) throws IOException { String str = "Hello,Java菜鸟程序员"; //创建一个输出流 FileOutputStream fileOutputStream = new FileOutputStream("hello.txt"); //获取通道 FileChannel channel = fileOutputStream.getChannel(); //创建缓冲区 ByteBuffer byteBuffer = ByteBuffer.allocate(100); //写入byteBuffer byteBuffer.put(str.getBytes()); //切换模式 byteBuffer.flip(); //写入通道 channel.write(byteBuffer); //关闭 channel.close(); fileOutputStream.close(); } }使用 FileChannel 读取文本文件public class NIOFileChannel { public static void main(String[] args) throws IOException { FileInputStream fileInputStream = new FileInputStream("hello.txt"); FileChannel channel = fileInputStream.getChannel(); ByteBuffer byteBuffer = ByteBuffer.allocate(100); channel.read(byteBuffer); System.out.println(new String(byteBuffer.array(), 0, byteBuffer.limit())); //Hello,Java菜鸟程序员 channel.close(); fileInputStream.close(); } }使用 FileChannel 复制文件public class NIOFileChannel03 { public static void main(String[] args) throws IOException { FileInputStream fileInputStream = new FileInputStream("hello.txt"); FileOutputStream fileOutputStream = new FileOutputStream("world.txt"); FileChannel inChannel = fileInputStream.getChannel(); FileChannel outChannel = fileOutputStream.getChannel(); ByteBuffer byteBuffer = ByteBuffer.allocate(1); while (inChannel.read(byteBuffer) != -1) { byteBuffer.flip(); outChannel.write(byteBuffer); //清空重置 byteBuffer.clear(); } fileOutputStream.close(); fileInputStream.close(); } }使用 transferFrom 复制文件public class NIOFileChannel04 { public static void main(String[] args) throws IOException { FileInputStream fileInputStream = new FileInputStream("hello.txt"); FileOutputStream fileOutputStream = new FileOutputStream("world.txt"); FileChannel inChannel = fileInputStream.getChannel(); FileChannel outChannel = fileOutputStream.getChannel(); //从哪拷贝,从几开始到几结束 对应的还有transferTo()方法. outChannel.transferFrom(inChannel, 0, inChannel.size()); outChannel.close(); inChannel.close(); fileOutputStream.close(); fileInputStream.close(); } }Channel 和 Buffer 的注意事项ByteBuffer 支持类型化的 put 和 get,put 放入什么数据类型,get 就应该使用相应的数据类型来取出,否则可能会产生 ByteUnderflowException 异常。可以将一个普通的 Buffer 转换为只读的 Buffer:asReadOnlyBuffer()方法。NIO 提供了 MapperByteBuffer,可以让文件直接在内存(堆外内存)中进行修改,而如何同步到文件由 NIO 来完成。NIO 还支持通过多个 Buffer(即 Buffer 数组)完成读写操作,即Scattering(分散)和 Gathering(聚集)。Scattering(分散):在向缓冲区写入数据时,可以使用 Buffer 数组依次写入,一个 Buffer 数组写满后,继续写入下一个 Buffer 数组。Gathering(聚集):从缓冲区读取数据时,可以依次读取,读完一个 Buffer 再按顺序读取下一个。Selector 的基本介绍Java 的 NIO 使用了非阻塞的 I/O 方式。可以用一个线程处理若干个客户端连接,就会使用到 Selector(选择器)。Selector 能够检测到多个注册通道上是否有事件发生(多个 Channel 以事件的形式注册到同一个 selector),如果有事件发生,便获取事件然后针对每个事件进行相应的处理。只有在连接真正有读写事件发生时,才会进行读写,减少了系统开销,并且不必为每个连接都创建一个线程,不用维护多个线程。避免了多线程之间上下文切换导致的开销。Selector 特点Netty 的 I/O 线程 NioEventLoop 聚合了 Selector(选择器 / 多路复用器),可以并发处理成百上千个客户端连接。当线程从某客户端 Socket 通道进行读写时,若没有数据可用,该线程可以进行其他任务。线程通常将非阻塞 I/O 的空闲时间用于其他通道上执行 I/O 操作,所以单独的线程可以管理多个输入输出通道。由于读写操作都是非阻塞的,就可以充分提高 I/O 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程挂起。一个 I/O 线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 I/O 一连接一线程模型,架构性能、弹性伸缩能力和可靠性都得到极大地提升。Selector 常用方法public abstract class Selector implement Closeable{ public static Selector open(); //得到一个选择器对象 public int select(long timeout); //监控所有注册的通道,当其中的IO操作可以进行时,将对应的selectionkey加入内部集合并返回,参数设置超时时间 public Set<SelectionKey> selectionKeys(); //从内部集合中得到所有的SelectionKey }Selector 相关方法说明selector.select()://若未监听到注册管道中有事件,则持续阻塞selector.select(1000)://阻塞 1000 毫秒,1000 毫秒后返回selector.wakeup()://唤醒 selectorselector.selectNow(): //不阻塞,立即返回NIO 非阻塞网络编程过程分析当客户端连接时,会通过 SeverSocketChannel 得到对应的 SocketChannel。Selector 进行监听,调用 select()方法,返回注册该 Selector 的所有通道中有事件发生的通道个数。将 socketChannel 注册到 Selector 上,public final SelectionKey register(Selector sel, int ops),一个 selector 上可以注册多个 SocketChannel。注册后返回一个 SelectionKey,会和该 Selector 关联(以集合的形式)。进一步得到各个 SelectionKey,有事件发生。再通过 SelectionKey 反向获取 SocketChannel,使用 channnel()方法。可以通过得到的 channel,完成业务处理。SelectionKey 中定义了四个操作标志位:OP_READ表示通道中发生读事件;OP_WRITE—表示通道中发生写事件;OP_CONNECT—表示建立连接;OP_ACCEPT—请求新连接。NIO 非阻塞网络编程代码示例public class Server { public static void main(String[] args) throws IOException { //创建serverSocketChannel ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); //绑定端口 serverSocketChannel.socket().bind(new InetSocketAddress(6666)); //设置为非阻塞 serverSocketChannel.configureBlocking(false); //得到Selector对象 try (Selector selector = Selector.open()) { //把ServerSocketChannel注册到selector,事件为OP_ACCEPT serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); //如果返回的>0,表示已经获取到关注的事件 while (selector.select() > 0) { Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> iterator = selectionKeys.iterator(); while (iterator.hasNext()) { //获得到一个事件 SelectionKey next = iterator.next(); //如果是OP_ACCEPT,表示有新的客户端连接 if (next.isAcceptable()) { //给该客户端生成一个SocketChannel SocketChannel accept = serverSocketChannel.accept(); accept.configureBlocking(false); //将当前的socketChannel注册到selector,关注事件为读事件,同时给socket Channel关联一个buffer accept.register(selector, SelectionKey.OP_READ,ByteBuffer.allocate(1024)); System.out.println("获取到一个客户端连接"); //如果是读事件 } else if (next.isReadable()) { //通过key 反向获取到对应的channel SocketChannel channel = (SocketChannel) next.channel(); //获取到该channel关联的buffer ByteBuffer buffer = (ByteBuffer) next.attachment(); while (channel.read(buffer) != -1) { buffer.flip(); System.out.println(new String(buffer.array(), 0, buffer.limit())); buffer.clear(); } } iterator.remove(); } } } } }public class Client { public static void main(String[] args) throws IOException { //得到一个网络通道 SocketChannel socketChannel = SocketChannel.open(); //设置为非阻塞 socketChannel.configureBlocking(false); //提供服务器端的IP和端口 InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 6666); //连接服务器 if (!socketChannel.connect(inetSocketAddress)) { while (!socketChannel.finishConnect()) { System.out.println("连接需要时间,客户端不会阻塞...先去吃个宵夜"); } } //连接成功,发送数据 String str = "hello,Java菜鸟程序员"; ByteBuffer byteBuffer = ByteBuffer.wrap(str.getBytes()); socketChannel.write(byteBuffer); socketChannel.close(); System.out.println("客户端退出"); } }运行结果SelectionKey 的相关方法方法描述public abstract Selector selector();得到与之关联的 Selector 对象public abstract SelectableChannel channel();得到与之关联的通道public final Object attachment()得到与之关联的共享数据public abstract SelectionKey interestOps(int ops);设置或改变监听的事件类型public final boolean isReadable();通道是否可读public final boolean isWritable();通道是否可写public final boolean isAcceptable();是否可以建立连接 ACCEPTNIO 实现群聊系统实现服务器端与客户端的数据简单通讯(非阻塞)实现多人群聊。服务器端:可以检测用户上线,离线,并实现消息转发功能。客户端:通过 Channel 可以无阻塞发送数据给其他所有用户,同时可以接收其他用户发送的消息(由服务器转发得到)。public class GroupChatClient { private static final String HOST = "127.0.0.1"; private static final int PORT = 6667; private Selector selector; private SocketChannel socketChannel; private String username; public GroupChatClient() { try { selector = Selector.open(); //连接服务器 socketChannel = SocketChannel.open(new InetSocketAddress(HOST, PORT)); //设置非阻塞 socketChannel.configureBlocking(false); //注册 socketChannel.register(selector, SelectionKey.OP_READ); username = socketChannel.getLocalAddress().toString().substring(1); System.out.println("客户端: " + username + ",准备就绪..."); } catch (IOException e) { e.printStackTrace(); } } /** * 向服务器发送数据 * * @param info */ public void sendInfo(String info) { info = username + "说: " + info; try { socketChannel.write(ByteBuffer.wrap(info.getBytes())); } catch (IOException e) { e.printStackTrace(); } } /** * 读取服务端回复的消息 */ public void readInfo() { try { //有可用通道 if (selector.select() > 0) { Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey key = iterator.next(); if (key.isReadable()) { //得到相关的通道 SocketChannel sc = (SocketChannel) key.channel(); //得到一个buffer ByteBuffer buffer = ByteBuffer.allocate(1024); //读取 sc.read(buffer); //把读取到的缓冲区数据转成字符串 String msg = new String(buffer.array()); System.out.println(msg.trim()); } iterator.remove(); //删除当前的selectionKey,防止重复操作 } } } catch (IOException e) { e.printStackTrace(); } } public static void main(String[] args) { //启动客户端 GroupChatClient chatClient = new GroupChatClient(); //启动一个线程,每隔3秒,读取从服务器端发送的数据 new Thread(() -> { while (true) { chatClient.readInfo(); try { TimeUnit.MILLISECONDS.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); //发送数据给服务器 Scanner scanner = new Scanner(System.in); while (scanner.hasNextLine()) { chatClient.sendInfo(scanner.nextLine()); } } }public class GroupChatServer { //定义属性 private Selector selector; private ServerSocketChannel listenChannel; private static final int PORT = 6667; public GroupChatServer() { try { //获得选择器 selector = Selector.open(); //listenChannel listenChannel = ServerSocketChannel.open(); //绑定端口 listenChannel.socket().bind(new InetSocketAddress(PORT)); //设置非阻塞模式 listenChannel.configureBlocking(false); //将该listenChannel注册到Selector listenChannel.register(selector, SelectionKey.OP_ACCEPT); } catch (IOException e) { e.printStackTrace(); } } public static void main(String[] args) { //创建一个服务器对象 GroupChatServer groupChatServer = new GroupChatServer(); //监听 groupChatServer.listen(); } /** * 监听 */ public void listen() { try { //如果返回的>0,表示已经获取到关注的事件 while (selector.select() > 0) { Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); //判断是否有事件 while (iterator.hasNext()) { //获得事件 SelectionKey key = iterator.next(); //如果是OP_ACCEPT,表示有新的客户端连接 if (key.isAcceptable()) { SocketChannel socketChannel = listenChannel.accept(); //设置为非阻塞 socketChannel.configureBlocking(false); //注册到Selector socketChannel.register(selector, SelectionKey.OP_READ); System.out.println("获取到一个客户端连接 : " + socketChannel.getRemoteAddress() + " 上线!"); } else if (key.isReadable()) { //如果是读事件,就读取数据 readData(key); } iterator.remove(); } } } catch (IOException e) { e.printStackTrace(); } finally { } } /** * 读取客户端消息 */ private void readData(SelectionKey key) { SocketChannel channel = null; try { //得到channel channel = (SocketChannel) key.channel(); //创建buffer ByteBuffer buffer = ByteBuffer.allocate(1024); if (channel.read(buffer) != -1) { String msg = new String(buffer.array()); System.out.println(msg); // 转发消息给其它客户端(排除自己) sendInfoOtherClients(msg, channel); } } catch (Exception e) { try { System.out.println(channel.getRemoteAddress() + " 下线了!"); // 关闭通道 key.cancel(); channel.close(); } catch (IOException ioException) { ioException.printStackTrace(); } } finally { } } /** * 转发消息给其它客户端(排除自己) */ private void sendInfoOtherClients(String msg, SocketChannel self) throws IOException { //服务器转发消息 System.out.println("服务器转发消息中..."); //遍历所有注册到selector的socketChannel并排除自身 for (SelectionKey key : selector.keys()) { //反向获取通道 Channel targetChannel = key.channel(); //排除自身 if (targetChannel instanceof SocketChannel && targetChannel != self) { //转型 SocketChannel dest = (SocketChannel) targetChannel; //将msg存储到buffer中 ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes()); //将buffer中的数据写入通道 dest.write(buffer); } } } }AIO 基本介绍JDK 7 引入了 Asynchronous I/O,即 AIO。在进行 I/O 编程中,通常用到两种模式:Reactor 和 Proactor 。Java 的 NIO 就是 Reactor,当有事件触发时,服务器端得到通知,进行相应的处理。AIO 叫做异步非阻塞的 I/O,引入了异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才会启动线程,特点就是先由操作系统完成后才通知服务端程序启动线程去处理,一般用于连接数较多且连接时长较长的应用。Reactor 与 Proactor两种 IO 多路复用方案:Reactor and Proactor。Reactor 模式是基于同步 I/O 的,而 Proactor 模式是和异步 I/O 相关的。由于 AIO 目前应用并不广泛,所以本文只是讲述 AIO 基本介绍。
什么是网络编程什么是网络?在计算机领域中,网络是信息传输、接受、共享的虚拟平台。通过它把各个点、面、体的信息联系到一起,从而实现这些资源的共享。什么是网络编程?网络编程从大的方面就是说对信息的发送接收。通过操作相应API调度计算机资源硬件,并且利用管道(网线)进行数据交互的过程。更为具体的涉及:网络模型、套接字、数据包。7层网络模型-OSI互联网的本质就是一系列的网络协议,这个协议就叫OSI协议(一系列协议),按照功能不同,分工不同,人为的分层七层。实际上这个七层是不存在的。没有这七层的概念,只是人为的划分而已。区分出来的目的只是让我们明白哪一层是干什么用的。每一层都运行不同的协议。实际上也可以把它划成五层、四层。七层划分为:应用层、表示层、会话层、传输层、网络层、数据链路层、物理层。五层划分为:应用层、传输层、网络层、数据链路层、物理层。四层划分为:应用层、传输层、网络层、网络接口层。应用层用户使用的都是应用程序,均工作于应用层,大家都可以开发自己的应用程序,数据多种多样,必须规定好数据的组织形式 。应用层功能:规定应用程序的数据格式。比如TCP协议可以为各种各样的程序传递数据,比如Email、WWW、FTP等。那么,必须有不同协议规定电子邮件、网页、FTP数据的格式,这些应用程序协议就构成了”应用层”。表示层表示层的用途是提供一个可供应用层选择的服务的集合,使得应用层可以根据这些服务功能解释数据的含义。表示层以下各层只关心如何可靠地传输数据,而表示层关心的是所传输数据的表现方式、它的语法和语义。表示服务的例子有统一的数据编码、数据压缩格式和加密技术等。会话层会话层任务是:向两个实体的表示层提供建立和使用连接的方法。将不同实体之间的表示层的连接称为会话。因此会话层的任务就是组织和协调两个会话进程之间的通信,并对数据交换进行管理。传输层传输层是通信子网和资源子网的接口和桥梁,起到承上启下的作用。该层的主要任务是:向用户提供可靠的端到端的差错和流量控制,保证报文的正确传输。传输层的作用是向高层屏蔽下层数据通信的细节,即向用户透明地传送报文。该层常见的协议:TCP/IP中的TCP协议、Novell网络中的SPX协议和微软的NetBIOS/NetBEUI协议。传输层提供会话层和网络层之间的传输服务,这种服务从会话层获得数据,并在必要时,对数据进行分割。然后,传输层将数据传递到网络层,并确保数据能正确无误地传送到网络层。因此,传输层负责提供两节点之间数据的可靠传送,当两节点的联系确定之后,传输层则负责监督工作。综上,传输层的主要功能如下:传输连接管理:提供建立、维护和拆除传输连接的功能。传输层在网络层的基础上为高层提供面向连接和面向无连接的两种服务。网络层网络层的作用是在网络与网络相互连接的环境中,将数据从发送端主机发送到接受端主机。举例:我在学校教室中,我想找隔壁班的妹子,我通知小弟去告诉她,说有个帅哥找你。而小弟就是网关,IP地址就是我所处的教室,MAC地址就是我在教室的某个位置。数据链路层、物理层早期的时候,数据链路层就是来对电信号来做分组的。以前每个公司都有自己的分组方式,非常的乱,后来形成了统一的标准,即以太网协议Ethernet。通信传输实际上是通过物理的传输介质实现的。数据链路层的作用就是在这些同构传输介质互连的设备之间进行数据处理。物理层中,将数据的 0 、1转换为电压和脉冲光传输给物理的传输介质,而相互直连的设备之间使用地址实现传输。这种地址被称为 MAC 地址,也可称为物理地址或硬件地址。采用 MAC 地址信息的首部附加到从网络层转发过来的数据上,将其发送到网络。网络层与数据链路层都是基于目标地址将数据发送给接收端的,但是网络层负责将整个数据发送给最终目标地址。而数据链路层则只负责发送一个分段内的数据。OSI七层网络模型TCP/IP四层概念模型对应网络协议应用层(Application)应用层HTTP、TFTP, FTP, NFS, WAIS、SMTP表示层(Presentation)应用层Telnet, Rlogin, SNMP, Gopher会话层(Session)应用层SMTP, DNS传输层(Transport)传输层TCP, UDP网络层(Network)网络层IP, ICMP, ARP, RARP, AKP, UUCP数据链路层(Data Link)数据链路层FDDI, Ethernet, Arpanet, PDN, SLIP, PPP物理层(Physical)数据链路层IEEE 802.1A, IEEE 802.2到IEEE 802.11OSI的封包与解包过程Socket与TCP、UDP什么是Socket简单来说是IP地址与端口的结合协议(RFC 793)一种地址与端口的结合描述协议。TCP/IP 协议的相关API的总称;是网络API的集合实现。在计算机通信领域,Socket 被翻译为“套接字”,它是计算机之间进行通信的一种约定或一种方式。通过 Socket 这种约定,一台计算机可以接收其他计算机的数据,也可以向其他计算机发送数据。Socket的作用与组成在网络传输中用于唯一标识两个端点之间的连接。端点:包括(IP + Port)四个要素:客户端地址、客户端端口、服务器地址、服务器端口。Socket流程服务器端先初始化Socket,然后与端口绑定(bind),对端口进行监听(listen),调用accept开始阻塞,等待客户端连接。在这时如果有个客户端初始化一个Socket,然后连接服务器(connect),如果连接成功,这时客户端与服务器端的连接就建立了。客户端发送数据请求,服务器端接收请求并处理请求,然后把回应数据发送给客户端,客户端读取数据,最后关闭连接,一次交互结束。Socket 之 TCPTCP是面向连接的通信协议通过三次握手建立连接,通讯完成时要拆除连接第一次握手:客户端发送带有 SYN 标志的连接请求报文段,然后进入 SYN_SEND 状态,等待服务端确认。第二次握手:服务端接受到客户端的 SYN 报文段后,需要发送 ACK 信息对这个 SYN 报文段进行确认。同时,还要发送自己的 SYN 请求信息。服务端会将上述信息放到一个报文段(SYN+ACK 报文段)中,一并发送给客户端,此时服务端进入 SYN_RECV 状态。第三次握手:客户端接收到服务端的 SYN+ACK 报文段后,会向服务端发送 ACK 确认报文段,这个报文段发送完毕后,客户端和服务端都进入 ESTABLEISHED 状态,完成 TCP 三次握手。4由于TCP是面向连接的所以只能用于端到端的通讯。Socket 之 UDPUDP是面向无连接的通讯协议。UDP数据包括目标端口号和源端口号信息。由于通讯不需要连接,所以可以实现广播发送,并不局限于端到端。Client-Server ApplicationTCP/IP 协议中,两个进程间通信的主要模式为:CS模型。主要目的:协同网络中的计算机资源、服务模式、进程间数据共享。常见的:HTTP、FTP、SMTPSocket TCP 基础代码实战客户端代码:/** * @author Jack */ public class Client { public static void main(String[] args) throws IOException { Socket socket = new Socket(); // 超时时间 socket.setSoTimeout(3000); // 连接本地,端口2000;超时时间3000ms socket.connect(new InetSocketAddress(Inet4Address.getLocalHost(), 2000), 3000); System.out.println("已发起服务器连接..."); System.out.println("客户端信息:" + socket.getLocalAddress() + " P:" + socket.getLocalPort()); System.out.println("服务器信息:" + socket.getInetAddress() + " P:" + socket.getPort()); try { // 发送接收数据 todo(socket); } catch (Exception e) { System.out.println("异常关闭"); } // 释放资源 socket.close(); System.out.println("客户端已退出~"); } private static void todo(Socket client) throws IOException { // 构建键盘输入流 InputStream in = System.in; BufferedReader input = new BufferedReader(new InputStreamReader(in)); // 得到Socket输出流,并转换为打印流 OutputStream outputStream = client.getOutputStream(); PrintStream socketPrintStream = new PrintStream(outputStream); // 得到Socket输入流,并转换为BufferedReader InputStream inputStream = client.getInputStream(); BufferedReader socketBufferedReader = new BufferedReader(new InputStreamReader(inputStream)); boolean flag = true; do { // 键盘读取一行 String str = input.readLine(); // 发送到服务器 socketPrintStream.println(str); // 从服务器读取一行 String echo = socketBufferedReader.readLine(); if ("bye".equalsIgnoreCase(echo)) { flag = false; } else { System.out.println(echo); } } while (flag); // 资源释放 socketPrintStream.close(); socketBufferedReader.close(); } }服务端代码:/** * @author jack */ public class Server { public static void main(String[] args) throws IOException { ServerSocket server = new ServerSocket(2000); System.out.println("服务器准备就绪..."); System.out.println("服务器信息:" + server.getInetAddress() + " P:" + server.getLocalPort()); // 等待客户端连接 for (; ; ) { // 得到客户端 Socket client = server.accept(); // 客户端构建异步线程 ClientHandler clientHandler = new ClientHandler(client); // 启动线程 clientHandler.start(); } } /** * 客户端消息处理 */ private static class ClientHandler extends Thread { private Socket socket; private boolean flag = true; ClientHandler(Socket socket) { this.socket = socket; } @Override public void run() { super.run(); System.out.println("新客户端连接:" + socket.getInetAddress() + " P:" + socket.getPort()); try (// 得到打印流,用于数据输出;服务器回送数据使用 PrintStream socketOutput = new PrintStream(socket.getOutputStream()); // 得到输入流,用于接收数据 BufferedReader socketInput = new BufferedReader(new InputStreamReader( socket.getInputStream()))) { do { // 服务器拿到拿到一条数据 String str = socketInput.readLine(); if ("bye".equalsIgnoreCase(str)) { flag = false; // 回送 socketOutput.println("bye"); } else { // 打印到屏幕。并回送数据长度 System.out.println(str); socketOutput.println("服务器收到了,你发送的长度为 :" + str.length()); } } while (flag); } catch (Exception e) { System.out.println("连接异常断开"); } finally { // 连接关闭 if (socket != null) { try { socket.close(); } catch (IOException e) { e.printStackTrace(); } } } System.out.println("客户端已退出:" + socket.getInetAddress() + " P:" + socket.getPort()); } } }运行结果:报文、协议、Mac地址、IP、端口报文段报文段是指TCP/IP协议网络传输过程中,起着路由导航作用。用于查询各个网络路由网段、IP地址、交换协议等IP数据包。报文在传输过程中会不断地封装成分组、包、帧来传输。传输协议协议顾名思义,一种规定,约束。约定大于配置,在网络传输中依然适用;网络的传输流程是很健壮的稳定的,得益于基础的协议构成。简单来说: A -> B 的传输数据,B能识别,反之 B -> A 的传输数据 A也能识别,这就是协议。Mac地址Media Access Control 或者 Medium Access Control。意译为媒体访问控制,或称为物理地址、硬件地址。用来定义网络设备的位置。通常被固化在每个以太网网卡(NIC,Network Interface Card)。MAC(硬件)地址长48位(6字节),采用十六进制格式。比如:44-45-53-54-00-00 。IP地址互联网协议地址(Internet Protocol Address)。是分配给网络上使用网际协议的设备的数字标签。常见的IP地址分为IPv4,IPv6。IP地址由32位二进制数组成,常以XXX.XXX.XXX.XXX形式出现,每组XXX代表小于或等于255的10进制数。IP地址分为A、B、C、D、E五大类,其中E类属于特殊保留地址。IP地址 - IPv4总数量:4,294,967,296个(即232):42亿个;最终于2011年2月3日用尽。如果主机号都是1,那么这个地址为直接广播地址。IP地址 "255.255.255.255" 为受限广播地址。IP地址 - IPv6总共有128位长,IPv6地址的表达形式,一般采用32个十六进制。由两个逻辑部分组成:一个64位的网络前缀和一个64位的主机地址,主机地址通常根据物理地址自动生成,叫做EUI-64。2001:0db8:85a3:0000:1319:8a2e:0370:7344IPv4可以转换为IPv6,但IPv6不一定能转换为IPv4。端口如果把IP地址比做一间房子,端口就是出入这间房子的门或者窗户。外界的信息飞到不同窗户也就是给不同的人传递信息。0-49151号端口都是特殊端口。计算机之间依照互联网传输层 TCP/IP 协议的协议通信,不同协议都对应不同的端口。49152到65535号端口属于“动态端口”范围,没有端口可以被正式占用。
了解 HTTP 协议浏览器背后的故事当我们在浏览器输入一个域名后,背后究竟发生了什么?第一步:当我们输入域名后,在 DNS 服务器进行域名查询。第二步:得到对应的 ip 地址。第三步:浏览器根据 ip 向 web 服务器进行通信发送请求,而通信的协议就是 HTTP。第四步:web 服务器回传页面内容。第五步:浏览器收到回传信息的报文数据,进行渲染出我们看得懂的页面。举个例子:如果我们想给张三打电话,我们需要在通讯录中先找到名字为张三的人,而张三这个名字就是域名,对应的手机号就是 ip。在通话过程中我讲普通话,而张三讲英语,这样肯定是没有办法沟通的,而共同语言就是 HTTP 协议。那什么是 HTTP?超文本传输协议(Hyper Text Transfer Protocol)是一种通信协议,它允许将超文本标记语言(HTML)文档从 Web 服务器传送到客户端的浏览器。HTTP 是一个属于应用层的面向对象的协议,由于其便捷、快速的方式,适用于分布式超媒体信息系统。它于 1990 年提出,经过几年的使用与发展,得到不断的完善和扩展。WEB 和 HTTPWEB 是一种基于超文本和 HTTP 的、全球性的、动态交互的、跨平台的分布式图形信息系统。建立在 Internet 的一种网络服务,为浏览者在 Internet 上查找和浏览信息提供了图形化的、易于访问的直观界面,其中的文档及超级链接将 Internet 上的信息节点组织成一个互为关联的网状结构。HTTP 协议的前世今生万维网的创始人叫蒂姆·伯纳斯·李(Tim Berners-Lee)简单点说,是当代互联网的创始人。在 1990 年,他发表了一篇论文,提出了在互联网上构建超链接文档系统的构想,在这篇论文里他确立了三项关键技术:URI:统一资源标识符,作为互联网上资源的唯一标识HTML:超文本标记语言,描述超文本文档HTTP:超文本传输协议,用来传输超文本这三项技术直接奠定了我们当今 Web 世界的技术,蒂姆把它称为万维网(World Wide Web)。所以,1991 年,HTTP 0.9 诞生了。HTTP 0.9该版本极其简单,只有一个命令 GET。协议规定,服务器只能回应 HTML 格式的字符串,不能回应别的格式。服务器发送完毕,就关闭 TCP 连接。虽然这一版 HTTP 协议虽然很简单,但是作为一个原型,充分验证了 Web 服务的可行性。HTTP 1.0主要增加了以下几部分内容:增加了 HEAD/POST 等新方法增加了响应状态码增加了版本号增加了 Header 头部的概念增加了 Content-Type,传输数据不再仅限于文本但是 HTTP/1.0 并不是一个标准,只是记录已有实践和模式的一份参考文档,不具有实际的约束力,相当于一个备忘录。HTTP 1.1主要增加了以下几部分内容:增加了 PUT/DELETE/OPITIONS 等新方法增加了缓存控制和管理 Cache Control明确了连接管理,允许持久连接 Keepalive允许响应数据分块,利于传输大文件(Chunked)强制要求 Host 头由于 HTTP/1.1 太过庞大和复杂,因此在 2014 年又进行了一次修订,拆分为六份较小的文档这六份文档增加了两个大的需求:加大了 HTTP 的安全性,比如使用 TLS 协议让 HTTP 可以支持更多的应用,目前已经支持四种网络协议:传统的短连接可重用 TCP 的长连接模型服务端 PUSH 模型WebSocket 模型HTTP 2.0HTTP/1.1 存在两个问题:连接慢,请求是串行的,需要保证顺序,例如一个网页中可能会有多个资源性能差,HTTP/1.1 是以文本的方式,借助 CPU 的 zip 压缩方式减少网络带宽,但是耗费了 前端和后端的 CPU2010 年,Google 推出了新的 SPDY 协议,并应用于自家的服务器,HTTP/2 就是以 SPDY 为基础的,它的特点主要是:使用二进制传输,不再是纯文本可以在一个 TCP 连接中并发多个 HTTP 请求,移除了 HTTP/1.1 中的串行请求使用 HPACK 算法来压缩头部允许服务器主动向客户端推送数据增强了安全性,基于 TLS 协议HTTP 3.0HTTP 2.0 的主要问题有队头阻塞问题,也就是说,若干个 HTTP 请求在复用一个 TCP 的连接,那么一旦发生丢包,造成的问题就是所有的请求都必须等待这个丢了的包重传回来,哪怕这个包不是我的 HTTP 请求的。基于此,Google 发明了 QUIC(Quick UDP Internet Connections)协议,它是基于 UDP 的。因此,它就解决了以下几个问题:UDP 是无序的,因此不存在队头阻塞问题QUIC 有一套自己的丢包重传和拥塞控制的协议HTTPS 握手通常需要六次网络交互,QUIC 直接将 TLS 和 TCP 合并成了三次握手透过 TCP/IP 看 HTTPHTTP 协议是构建在TCP/IP协议之上的,是 TCP/IP 协议的一个子集。TCP/IP 协议族TCP/IP 协议其实是一系列与互联网相关联的协议集合起来的总称。TCP/IP 协议族是由一个四层协议组成的系统,这四层分别为:应用层、传输层、网络层、数据链路层。应用层应用层一般是我们编写的应用程序,决定了向用户提供的应用服务。应用层可以通过系统调用与传输层进行通信。如:FTP、DNS、HTTP等。传输层传输层通过系统调用向应用层提供处于网络连接中的两台计算机之间的数据传输功能。在传输层有两个性质不同的协议:TCP、UDP。网络层网络层用来处理在网络上流动的数据包,数据包是网络传输的最小数据单位。该层规定了通过怎样的路径(传输路线)到达对方的计算机,并把数据包传输给对方。链路层链路层用来处理连接网络的硬件部分,包括控制操作系统、硬件设备驱动、NIC(Network Interface Card,网络适配器)以及光纤等物理可见部分。硬件上的范畴均在链路层的作用范围之内。HTTP 数据传输过程发送端发送数据时,数据会从上层传输到下层,且每经过一层都会被加上该层的头部信息。而接收端接受数据时候,数据会从下层传输到上层,传输前会把下层的头部信息删除。传输层 —— TCP 三次握手第一次握手:客户端发送带有 SYN 标志的连接请求报文段,然后进入 SYN_SEND 状态,等待服务端确认。第二次握手:服务端接受到客户端的 SYN 报文段后,需要发送 ACK 信息对这个 SYN 报文段进行确认。同时,还要发送自己的 SYN 请求信息。服务端会将上述信息放到一个报文段(SYN+ACK 报文段)中,一并发送给客户端,此时服务端进入 SYN_RECV 状态。第三次握手:客户端接收到服务端的 SYN+ACK 报文段后,会向服务端发送 ACK 确认报文段,这个报文段发送完毕后,客户端和服务端都进入 ESTABLEISHED 状态,完成 TCP 三次握手。顺便说一下四次挥手:当被动方收到主动方的FIN报文通知时,它仅仅表示主动方没有数据再发送给被动方了。但未必被动方所有的数据都完整的发送给了主动方,所以被动方不会马上关闭SOCKET,它可能还需要发送一些数据给主动方后,再发送FIN报文给主动方,告诉主动方同意关闭连接,所以这里的ACK报文和FIN报文多数情况下都是分开发送的。原理:第一次挥手:Client发送一个FIN,用来关闭Client到Server的数据传送,Client进入FIN_WAIT_1状态。第二次挥手:Server收到FIN后,发送一个ACK给Client,确认序号为收到序号+1(与SYN相同,一个FIN占用一个序号),Server进入CLOSE_WAIT状态。第三次挥手:Server发送一个FIN,用来关闭Server到Client的数据传送,Server进入LAST_ACK状态。第四次挥手:Client收到FIN后,Client进入TIME_WAIT状态,接着发送一个ACK给Server,确认序号为收到序号+1,Server进入CLOSED状态,完成四次挥手。DNS 域名解析已经介绍了与 HTTP 协议有着密切关系的 TCP/IP 协议,接下来介绍的 DNS 服务也是与 HTTP 协议有着密不可分的关系。通常我们访问一个网站,使用的是主机名或者域名来进行访问的。因为相对于 IP 地址(一组纯数字),域名更容易让人记住。但 TCP/IP 协议使用的是 IP 地址进行访问的,所以必须有个机制或服务把域名转换为 IP 地址,DNS 服务就是用来解决这个问题的,DNS提供了域名到IP地址之间的解析服务。DNS 域名解析流程浏览器缓存 :当用户通过浏览器访问某域名时,浏览器首先会在自己的缓存中查找是否有该域名对应的 IP 地址(若曾经访问过该域名且没有清空缓存)。系统缓存 :当浏览器缓存中无域名对应 IP 则会自动检查用户计算机系统 Hosts 文件 DNS 缓存是否有该域名对应 IP。路由器缓存 :当浏览器及系统缓存中均无域名对应 IP 则进入路由器缓存中检查,以上三步均为客户端的 DNS 缓存。ISP(互联网服务提供商)DNS缓存 : 当在用户客服端查找不到域名对应 IP 地址,则将进入 ISP DNS 缓存中进行查询。比如用的是电信的网络,则会进入电信的 DNS 缓存服务器中进行查找。根域名服务器:当以上均未完成,则进入根服务器进行查询。全球仅有 13 台根域名服务器,1 个主根域名服务器,其余 12 为辅根域名服务器。根域名收到请求后会查看区域文件记录,若无则将其管辖范围内顶级域名(如.com)服务器 IP 告诉本地 DNS 服务器。顶级域名服务器:顶级域名服务器收到请求后查看区域文件记录,若无则将其管辖范围内主域名服务器的 IP 地址告诉本地 DNS 服务器。主域名服务器:主域名服务器接受到请求后查询自己的缓存,如果没有则进入下一级域名服务器进行查找,并重复该步骤直至找到正确记录。保存结果至缓存:本地域名服务器把返回的结果保存到缓存,以备下一次使用,同时将该结果反馈给客户端,客户端通过这个 IP 地址与 web 服务器建立链接。回顾 HTTP 事务处理流程当客户端访问 Web 站点时,首先会通过 DNS 服务查询到域名的 IP 地址。然后浏览器生成 HTTP 请求并通过 TCP/IP 协议发送给 Web 服务器。Web 服务器接收到请求后会根据请求生成响应内容,并通过 TCP/IP 协议返回给客户端。熟悉 HTTP 协议结构和通讯原理HTTP 协议特点支持客户/服务器模式客户/服务器模式工作的方式是由客户端向服务器发送请求,服务器端响应请求,并进行相应服务。简单快速客户向服务器请求服务时,只需传送请求方法和路径。请求方法常用的有GET、HEAD、POST。每种方法规定了客户与服务器联系的类型不同。由于 HTTP 协议简单,使得 HTTP 服务器的程序规模小,因而通信速度快。灵活HTTP 允许传输任意类型的数据对象。正在传输的类型由 Content-Type 加以标记。无连接无连接的含义是限制每次连接只处理一次请求。服务器处理完客户请求,并收到客户的应答后,就断开连接。采用这种方式可以节省传输时间。无状态HTTP 协议是无状态协议无状态是指协议对于事务处理没有记忆能力。缺少状态意味着如果后续处理需要前面的信息,则它必须重传,这样可能导致每次连接传送的数据量增大。详解 URL 与 URI 的区别与联系问题:我们输入在浏览器里的 Web 地址应该叫 URL 还是 URI?URI:一个紧凑的字符串用来表示抽象或物理资源。URL:是 URI 的子集,除了确定一个资源,还提供了一种定位该资源的主要访问机制。维基百科:URI 可以分为 URL,URN 或同时具备 localtors 和 names 特性的一个东西。URN 作用就好像一个人的名字,URL 就像一个人的地址。话句话说:URN 确定了东西的身份,URL 提供了找到它的方式。URL 是 URI 的一种,但不是所有的 URI 都是 URL。URI 和 URL 最大的区别是”访问机制“。URN 是唯一标识的一部分,是身份信息。举例:http://www.ietf.org/rfc/rfc/2396.txt : 是 URLtelnet://192.0.2.16:80 : 是 URIHTTP 报文结构分析HTTP 的报文头大体可以分为四类,分别是:通用报文头请求报文头响应报文头实体报文头在 HTTP/1.1 中规范了 47 种报文头字段。通用报文头请求报文头响应报文头实体报文头ACCEPT作用: 浏览器端可以接受的媒体类型。Accept:text/html 代表浏览器可以接受服务器回发的类型为 text/html,也就是我们所说的 html 文档,如果服务器无法返回 text/html 类型的数据,服务器应该返回一个 406 错误(Non Acceptable)。ACCEPT-Encoding作用:浏览器声明自己接受的编码方法,通常是指定压缩方法,是否支持压缩,只是什么压缩方法(gzip,deflate)。ACCEPT-Language作用:浏览器声明自己接受的语言Accept-Language:zh-cn,zh;q=0.7.en-us,en;q=0.3客户端在服务器有中文版资源的情况下,会请求其返回中文版的响应,没有中文版时,则请求返回英文版响应。ConnectionConnection:keep-alive 当一个网页打开完成后,客户端和服务器之间用于传输 HTTP 数据的 TCP 连接不会关闭,如果客户端再次访问这个服务器上的网页,会继续使用这一条已经建立的连接。Connection:close 代表一个 Request 完成后,客户端和服务器之间用于传输 HTTP 数据的 TCP 连接会关闭,当客户端再次发送 Request,需要重新建立 TCP 连接。Host作用:请求报文头域主要用于指定被请求资源的 Internet 主机和端口号,它通常从 HTTP URL 中提取出来的。比如:https://www.baidu.com:8080Referer当浏览器向 Web 服务器发送请求时候,一般会带上 Referer,告诉服务器我是从那个页面链接过来的,服务器借此可以获得一些信息用于处理。User-Agent作用:告诉 HTTP 服务器,客户端使用的操作系统和浏览器的名称和版本。Content-Type作用:说明了报文体内对象的媒体类型。application/xhtml+xml :XHTML 格式application/xml :XML 数据格式application/atom+xml : Atom XML 局和各市application/json : json 数据格式application/pdf :pdf 格式application/msword : Word 文档格式application/octet-stream :二进制流数据application/x-www-form-urlencoded:表单提交HTTP 请求方法剖析HTTP/1.1 常用方法:GETPOSTPOSTPUTHEADDELETEOPTIONSCONNECTGETGET 方法用来请求访问已经被 URI 识别的资源。指定的资源经服务器端解析后返回响应内容。GET 方法也可以用来提交表单和其他数据。比如:http://localhost/login?name=admin&password=123456,从这段 URL 中,很容易就可以辨认出表单提交的内容。POSTPOST 方法与 GET 功能类似,一般用来传输实体的主体。POST 方法的主要目的不是获取响应主体内容。POST 数据不会在 URL 中显示,也就克服了长度问题。PUT从客户端向服务器传送的数据取代指定的文档内容。PUT 方法与 POST 方法最大的不同是:PUT 是幂等的,而 POST 不是幂等的。因此,我们更多时候将 PUT 方法作为传送资源。HEAD类似于 GET 请求,只不过返回的响应中没有具体的内容,用于获取报头。DELETE请求服务器删除指定的资源。OPTIONS用来查询针对请求 URI 指定的资源支持的方法。TRACE回显服务器收到的请求,主要用于测试或诊断。CONNECT开启一个客户端与所请求资源之间的双向沟通的通道,它可以用来创建隧道。HTTP 响应状态码拆解状态码是用以表示网页服务器超文本传输协议响应状态的 3 位数字代码。状态码分类常用的 HTTP 状态码状态码状态码英文名称描述200OK请求已成功,请求所希望的响应头或数据体将随此响应返回202Accepted已接收,已经接受请求,但未处理完成206Partial Content部分内容,服务器成功处理了部分 GET 请求301Moved Parmanently永久移动,请求的资源已被永久移动到新的 URI,返回信息会包括新的 URI302Found临时移动,与 301 相似。但资源只是临时被移动。客户端应继续使用原有 URI400Bad Request客户端请求的语法错误,服务器无法理解401Unauthorized请求要求用户的身份认证403Forbidden服务器理解请求客户端的请求,但拒绝执行此请求404Not Found服务器无法根据客户端的请求找到对应资源(网页)500Internal Server Error服务器内部错误,无法完成请求502Bad Gateway充当网关或代理的服务器,从远端服务器接受到了一个无效的请求HTTP 状态管理:Cookie 与 SessionCookieCookie 实际上是一小段的文本信息。客户端请求服务器,如果服务器需要记录该用户状态,就向客户端浏览器发送一个 Cookie。客户端浏览器会把 Cookie 保存起来。当浏览器再请求该网站时,浏览器把请求的网址连同该 Cookie 一同提交给服务器。服务器检查该 Cookie,以此来辨认用户状态。Cookie 工作原理发起请求服务端 set-cookie返回服务器响应结果客户端读取 set-cookie客户端再次发起请求服务端检查 cookie,返回响应结果SessionSession 是另一种记录客户状态的机制,保存在服务器上。客户端浏览器访问服务器的时候,服务器把客户端信息以某种形式记录在服务器上。客户端浏览器再次访问只需要从该 Session 中查找该客户的状态就可以了。Session 的工作原理保存 Session ID 的方式CookieURL 重写隐藏表单Session 的有效期Session 超时时间程序调用 HttpSession.invalidate()服务器进程被停止Cookie 与 Session存放位置不同安全性(隐私策略不同)有效期上的不同对服务器压力的不同HTTP 协议的特性和使用方法编码和解码每套编码规范都有自己使用的场景,字库表存储了编码规范中能够所有能够表示的字(比如:所有的汉字都在 gbk 编码规范的字库表里),在一个组库表,每一个字都有对应的二进制数,这些二进制数存储在字符集中。字库表和字符集一一对应,相互转换。不同编码规范节省的空间也不一样,一个较短的二进制数通过一种编码方式转化成字符集中对应的地址,然后在字库表中找到一个字符,显示给用户。常见的编码规范有:ASCII 码:作用: 英语及西欧语言。位数: ASCII 是用 7 位表示的,能表示 128 个 字符;其扩展使用 8 位表示,表示 256 个字符。范围: ASCII 从 00 到 7F,扩展从 00 到 FF。一个英文字母(不分大小写)占一个字节的空间,一个中文汉字占两个字节的空间。GBK 编码标准:兼容 GB2312、GB13000-1、BIG5 编码中的所有汉字,使用双字节编码。编码空间为 0x8140 ~ 0xFEFE,共有 23940 个码位。其中 GBK1 区和 GBK2 区也是 GB2312 的编码范围。收录了 21003 个汉字。iso8859-1属于单字节编码,最多能表示的字符范围是 0-255,应用于英文系列。比如,字母’a’的编码为 0x61=97。 iso8859-1 编码表示的字符范围很窄,无法表示中文字符。由于是单字节编码,和计算机最基础的表示单位一致,所以很多时候,仍旧使用 iso8859-1 编码来表示。把其他任何编码当做 iso8859-1 来解码的时候,都能解开,也是 MYSQL 的默认编码。位数:8 位。范围:从 00 到 FF,兼容 ASCII 字符集。 英文 一个字节,不支持中文Unicode 编码:作用:亚 美 采用同一编码字集。位数:16 位, 范围:符号 6811 个,汉字 20902 个,韩文拼音 11172 个,造字区 6400 个,保留 20249 个,共计 65534 个。英文 中文都占用两个字节,中英各自标点符号也是如此。URL 的编码与解码URL 是采用 ASCII 字符集进行编码的,所以如果 URL 中含有非 ASCII 字符集中的字符,要对其进行编码。URL 中一些保留字符,如"&"表示参数分隔符,如果想要在 URL 中使用这些保留字,那就需要编码。"%编码"规范对 URL 中属于 ASCII 字符集的非保留字不做编码;对 URL 中的保留字需要取其 ASCII 内码,然后加上 "%" 前缀将该字符进行编码;对于 URL 中的非 ASCII 字符需要取其 Unicode 内码,然后加上 "%" 前缀将该字符进行编码。HTTP 协议之身份认证常见认证方式BASIC 认证(基本认证)DIGEST 认证(摘要认证)SSL 客户端认证FormBase 认证(基于表单认证)BASIC 认证什么是 BASIC 认证?Basic 认证是一种较为简单的 HTTP 认证方式,客户端通过明文(Base64 编码格式)传输用户名和密码到服务端进行认证,通常需要配合 HTTPS 来保证信息传输的安全。BASIC 认证流程:缺点:用户名和密码明文(Base64)传输,需要配合 HTTPS 来保证信息传输的安全。即使密码被强加密,第三方仍可通过加密后的用户名和密码进行重放攻击。没有提供任何针对代理和中间节点的防护措施。假冒服务器很容易骗过认证,诱导用户输入用户名和密码。DIGEST 认证什么是 DIGEST 认证?为弥补 BASIC 认证存在的缺点,从 HTTP /1.1 就有了 DIGEST 认证。DIGEST 认证同样适用质询/响应的方式,但不会像 BASIC 认证那样直接放松明文密码。DIGEST 认证流程:SSL 客户端认证什么是 SSL 客户端认证?SSL 客户端认证是借由 HTTPS 的客户端证书完成认证的方式。凭借客户端证书认证,服务器可确认访问是否来自己登录的客户端。基于表单的认证基于表单的认证方法并不是在 HTTP 协议中定义的。使用由 Web 应用程序各自实现基于表单的认证方式。通过 Cookie 和 Session 的方式来保持用户的状态。HTTP 的长连接和短连接HTTP 协议是基于请求/响应模式的,因此只要服务端给了响应,本次 HTTP 请求就结束了。HTTP 的长连接和短连接本质上是 TCP 长连接和短连接。HTTP/1.0 中,默认使用的是短连接,也就是说,浏览器和服务器每进行一次 HTTP 操作,就建立一次连接,结束就中断。HTTP/1.1 起,默认使用长连接,用以保持连接性。什么是长连接?长连接意味着进行一次数据传输后,不关闭连接,长期保持连通状态。如果两个应用程序之间有新的数据需要传输,则直接复用这个连接,无需再建立一个新的连接。就像下图这样。它的优势是在多次通信中可以省去连接建立和关闭连接的开销,并且从总体上来看,进行多次数据传输的总耗时更少。缺点是需要花费额外的精力来保持这个连接一直是可用的,因为网络抖动、服务器故障等都会导致这个连接不可用,甚至是由于防火墙的原因。所以,一般我们会通过下面这几种方式来做“保活”工作,确保连接在被使用的时候是可用状态:利用 TCP 自身的保活(Keepalive)机制来实现,保活机制会定时发送探测报文来识别对方是否可达。一般的默认定时间隔是 2 小时,你可以根据自己的需要在操作系统层面去调整这个间隔,不管是 linux 还是 windows 系统。上层应用主动的定时发送一个小数据包作为“心跳”,探测是否能成功送达到另外一端。 保活功能大多数情况下用于服务端探测客户端的场景,一旦识别客户端不可达,则断开连接,缓解服务端压力。提前多说一句,如果在做了高可用的分布式系统场景中运用长连接会更麻烦一些。因为高可用必然包含自动故障转移、故障隔离等机制。这恰恰导致了一旦发生故障,客户端需要及时发现哪些连接已处于不可用状态,并进行相应的重连,包括重新做负载均衡等工作。什么是短连接?它的优势是由于每次使用的连接都是新建的,所以基本上只要能够建立连接,数据就大概率能送达到对方。并且哪怕这次传输出现异常也不用担心影响后续新的数据传输,因为届时又是一个新的连接。缺点是每个连接都需要经过三次握手和四次握手的过程,耗时大大增加。另外,短连接还有一个致命的缺点。当你在基于 socket 进行开发的时候,这些包含的具体资源主要就是这 5 个:源 IP、源端口、目的 IP、目的端口、协议,有个专业的叫法称之为“五元组”。在一台计算机上只要这五元组的值不重复,那么连接就可以被建立。然而一台计算机最多只能开启 65535 个端口,如果现在两个进程之间需要通信,作为服务端的 IP 和端口必然是固定的,因此单个客户端理论上最多只能与服务端同时建立 65535 个 socket 连接。如果除去操作系统和其它进程所占用的端口,实际还会更少。所以,一旦使用不当,在很短的时间内建立了大量连接,端口很容易被占用完。这不但会导致自身无法正常工作,还会影响到同一台计算机上的其它进程。HTTP 中介之代理代理又当服务器又当客户端代理的作用抓包匿名访问过滤器HTTP 中介之网关网关可以作为某种翻译器使用,它抽象出了一种能够到达资源的方法。网关是资源和应用程序之间的粘合剂。网关扮演的是“协议转换器”的角色。可以看到,代理是相同协议的端点,而网关是使用不同协议的端点。WEB 网关Web 网关在一侧使用 HTTP 协议,在另一侧使用另外一种协议。<客户端协议>/<服务器端协议>(HTTP/)服务器端网关:通过 HTTP 协议与客户端对话,通过其他协议与服务器通信。(/HTTP)客户端网关:通过其他协议与客户端对话,通过 HTTP 协议与服务器通信。常见的网关类型(HTTP/\*)服务器端 Web 网关。请求流入原始服务器时,服务器端 Web 网关会将客户端 HTTP 请求转换为其他协议与服务器进行连接,完成获取资源以后,会将对象放在一条 http 响应中发送给客户端(HTTP/HTTPS)服务器端安全网关。一个组织可以通过网关对所有的输入 Web 请求加密,以提供额外的隐私和安全性保护。客户端可以用普通的 HTTP 浏览 Web 内容,但网关会自动加密。(HTTPS/HTTP)客户端安全加速器网关。将 HTTPS/HTTP 网关作为安全加速器使用的情况越来越多了,这些 HTTPS/HTTP 网关位于 Web 服务器之前,通常作为不可见的拦截网关或反向代理使用。它们接收安全的 HTTPS 流量,对安全流量进行解密,并向 Web 服务器发送普通的 HTTP 请求。这些网关中通常都包含专用的解密硬件,以比原始服务器有效的多的方式来解密安全流量,以减轻原始服务器的负荷。这些网关在网关和原始服务器之间发送的是未加密的流量。所以,要谨慎使用,确保网关和原始服务器之间的网络是安全的。资源网关。最常见的网关,应用程序服务器,会将目标服务器与网关结合在一个服务器中实现。应用程序服务器是服务器端网关,与客户端通过 HTTP 进行通信,并与服务器端的应用程序相连。客户端是通过 HTTP 连接到应用程序服务器的。但应用程序服务器并没有回送文件,而是将请求通过一个网关应用编程接口(Application Programming Interface,API)发送给运行在服务器上的应用程序。HTTP 缓存什么是 HTTP 缓存?http 缓存指的是: 当客户端向服务器请求资源时,会先抵达浏览器缓存,如果浏览器有“要请求资源”的副本,就可以直接从浏览器缓存中提取而不是从原始服务器中提取这个资源。常见的 http 缓存只能缓存 get 请求响应的资源,对于其他类型的响应则无能为力,所以后续说的请求缓存都是指 GET 请求。为什么要使用 HTTP 缓存?1.客户端每次都要请求服务器,浪费流量。 2.服务器每次都得提供查找,下载,请求用户基础如果较大,服务器存在较大压力。 3.客户端每次请求完都要进行页面渲染,用户体验较差。所以我们可以将请求的文件存放起来使用,比如使用 HTTP 缓存。HTTP 缓存头部字段Cache-Control:请求/响应头,缓存控制字段。no-store:所有内容都不缓存。no-cache: 缓存,但是浏览器使用缓存前,都会请求服务器判断缓存资源是否是最新。max-age=x(单位秒):请求缓存后的 X 秒内不再发起请求。s-maxage=x(单位秒)代理服务器请求源站缓存后的 X 秒内不再发起请求,只对 CDN 缓存有效。public :客户端和代理服务器(CDN)都可以缓存。private:只有客户端可以缓存。Expires :响应头,代表资源过期时间,由服务器返回提供,是 HTTP1.0 的属性,在与 max-age 共存的情况下,优先级要低。Last-Modified:响应头,资源最新修改时间,由服务器告诉浏览器。if-Modified-Since:请求头,资源最新修改时间,由浏览器告诉服务器,和 Last-Modified 是一对,他俩会进行对比。Etag:响应头,资源标识,由服务器告诉浏览器。if-None-Match:请求头,缓存资源标识,由浏览器告诉服务器(其实就是上次服务器的 Etag),和 Etag 是一对,它两个会进行对比。HTTP 缓存工作方式让服务器与浏览器约定一个文件过期时间——Expires(GMT 时间格式)。第一次请求的还是:假设我们返回一个 js 文件,然后再返回个Expires 时间。后续请求的时候:浏览器会先对比当前时间是否已经大于 Expires,也就是判断文件是否超过了约定的过期时间。时间没过,不发起请求,直接使用本地缓存。时间过期,发起请求,继续第一次请求的逻辑。问题:假设 Expires 已过期,浏览器再次请求服务器,但 js 文件相比上次并未做任何改变,那这次请求我们是否通过某种方式加以避免?比如约定时间是一个星期,约定时间到了我还没改。解决:让服务器与浏览器在约定文件过期时间的基础上,再加一个文件最新修改时间的对比——Last-Modified 与 if-Modified-Since第一次请求:假设我们返回一个 js 文件,然后再返回个Expires 时间,再返回一个Last-Modified。后续请求:Expires 过期,服务器带上了文件最新修改时间 if-Modified-Since(也就是上次请求服务器返回的 Last-Modified),服务器将 if-Modified-Since 与 Last-Modified 做了个对比。if-Modified-Since 与 Last-Modified 不相等,服务器查找了最新的 js,同时再次返回 Expires 与全新的 Last-Modified。if-Modified-Since 与 Last-Modified 相等,服务器返回了状态码 304,文件没修改过,还是使用本地缓存。问题:浏览器端可以随意修改 Expires,Expires 不稳定,Last-Modified 只能精确到秒,假设文件是在 1s 内发生变动,Last-Modified 无法感知到变化,这种情况下浏览器永远拿不到最新的文件。解决:让服务器与浏览器在过期时间 Expires+Last-Modified 的基础上,再增加一个文件内容唯一对比标记——Etag 与 If-None-Match。我们说 Expires 有可能被篡改,这里我们再加入一个 max-age 来加以代替(cache-control 其中一个值)。第一次请求:假设我们返回一个 js 文件,然后再返回个max-age=60,再返回一个Last-Modified,还有一个文件内容唯一标识 Etag。后续请求:60S 内,不发起请求,直接使用本地缓存。(max-age=60 代表请求成功缓存后的 60S 内不再发起请求,与 Expires 相似,同时存在 max-age 优先级要比 Expires 高)60S 后,浏览器带上了if-Modified-Since 与 If-None-Match(上次服务器返回来的 Etag)发起请求,服务器对比 If-None-Match 与 Etag(不对比 if-Modified-Since 与 Last-Modified 了,Etag 优先级比 Last-Modified 高。)If-None-Match 与 Etag 不相等,说明 js 内容被修改过,服务器返回最新 js 与全新的 Etag 与 max-age=60 与 Last-Modified 与 ExpiresIf-None-Match 与 Etag 相等,说明 js 文件内容无任何改变,返回 304,告诉浏览器继续使用之前的本地缓存。问题:我们已经可以精确的对比服务器文件与本地缓存文件差异,但其实上面方案的演变都存在一个较大缺陷, max-age 或 Expires 不过期,浏览器无法主动感知服务器文件变化。缓存改进方案md5/hash 缓存通过不缓存 html,为静态文件添加 MD5 或者 hash 标识,解决浏览器无法跳过缓存过期时间主动感知文件变化的问题。之前的浏览器与服务器之间的缓存都是建立在每次请求的文件都是在相同的目录以及相同的文件名,如果目录或者是文件名发生改变的时候就会重新请求,管那些什么失效时间乱七八糟的花里胡哨的东西,所以这个时候就出现了新的解决办法。 就是通过 webpack 来解决,每次打包的时候生成新的文件。CDN 缓存CDN 是构建在网络之上的内容分发网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使得用户就近获取所需内容,减低网络拥堵,提高用户访问速度和命中率。假设多年前我们所在的城市只有一个火车站,每次春运,整个城市的人都得去这个火车站买票,人流量以及购票的需求可想而知有多大,为了缓解这个问题,城市的不同区,都出现了火车票代售点,这样每个区的人都可以就近买票了,火车站总站的压力就这样大大减轻了。我们可以把每个区的售票点称之为 CDN 节点,也就是前面所说的代理服务器。简而言之,我们可以把CDN 理解成浏览器与服务器之间的临时站点,它会替服务器处理一部分的浏览器请求,从而整理减轻总服务器的压力。我们可以把 CDN 的价值归纳为:CDN 通过分流的形式,大大减轻源站的访问压力。就像住的区比较偏远,每次买票要去城市中心,而这个区后来有了分站,火车票就可以就近购买一样。CDN 也解决了跨地区访问问题,根本上为访问提供了加速。举例:CDN 边缘节点缓存数据,当浏览器请求,CDN 将代替源站判断并处理此处请求。第一次请求:服务器将文件交给 CDN,CDN 来进行缓存,同时 CDN 将文件返回给浏览器,浏览器本身也进行缓存。后续请求:情况 1:CDN 节点自己缓存的文件未过期,于是返回了 304 给浏览器,打回了这次请求。情况 2:CDN 节点发现自己缓存的文件过期了,为了保险起见,自己发起请求给了服务器(源站),成功拿回了最新数据,然后再交给与了浏览器。其实说到这,CDN 缓存的问题也跟前面的 http 缓存一样,CDN 缓存时间不过期,浏览器始终被拦截,无法拿到最新的文件。但是我们回归 http 缓存问题本质,缓存本身针对于更新频率不高的静态文件,其次,CDN 缓存提供了分流以及访问加速其它优势条件。CDN 类似一个平台,是可以通过登录,手动更新 CDN 缓存的,变相解决了浏览器缓存无法手动控制的问题。浏览器操作对 HTTP 缓存的影响浏览器地址栏回车,或者点击跳转按钮,前进,后退,新开窗口,这些行为,会让 Expires,max-age 生效,也就是说,这几种操作下,浏览器会判断过期时间,再考虑要不要发起请求,当然 Last-Modified 和 Etag 也有效。F5 刷新浏览器,或者使用浏览器导航栏的刷新按钮,这几种,会忽略掉 Expires,max-age 的限制,强行发起请求,Last-Modified 和 Etag 在这种情况下也有效。CTRL+F5是强制请求,所有缓存文件都不使用,全部重新请求下载,因此 Expires,max-age,Last-Modified 和 Etag 全部失效。HTTP 内容协商机制指客户端和服务器端就响应的资源内容进行交涉,然后提供给客户端最为适合的资源。内容协商会以响应资源的语言,字符集,编码方式等作为判断的基准。当浏览器的默认语言为英文或者中文,访问相同 URI 的 Web 页面时候,就返回对应的英文或中文的 Web 页面,这种机制称为内容协商(Content Negotiation)。内容协商方式客户端驱动客户端发起请求,服务器发送可选项列表,客户端作出选择后在发送第二次请求。优点:比较容易实现。缺点:增加了时延,至少要发送两次请求,第一次请求获取资源列表,第二次获取选择的副本。服务器启动(使用广泛)服务器检查客户端的请求头部集并决定提供哪个版本的页面。优点:比客户端驱动的协商要快。HTTP 提供了 Q 机制(理解为权重),允许服务器近似匹配,还提供了 vary 首部供服务器告知下游的设备(如代理服务器)如何对请求估值。缺点:首部集不匹配,服务器要做猜测。透明协商某个中间设备(通常是缓存代理)代表客户端进行协商。优点:免除了 web 服务器的协商开销,比客户端驱动的协商要快。缺点:HTTP 并没有提供相应的规范。服务器驱动内容协商-请求首部集Accept:告知服务器发送何种媒体类型Accept-Language:告知服务器发送何种语言Accept-Charset:告知服务器发送何种字符集Accept-Encoding:告知服务器采用何种编码内容协商首部集是由客户端发送给服务器用于交换偏好信息的,以便服务器可以从文档的不同版本中选择出最符合客户端偏好的那个来提供服务服务器用下面列出的实体首部集来匹配客户端的 Accept 首部集:Accept 首部实体首部AcceptContent-TypeAccept-LanguageContent-LanguageAccept-CharsetContent-TypeAccept-EncodingContent-Encoding服务器驱动内容协商–近似匹配假设客户端的 Accept-Language 指定的是西班牙语,但是服务端只有英语与法语版本,这个客户端希望在没有西班牙语的时候优先返回英语。这就意味着,我们需要一种 HTTP 机制更详细的描述偏好。这种机制就是质量值(q 值)。示例如下:Accept-Language: en;q=0.5, fr;q=0.0, nl;q=1.0, tr;q=0.0这个首部表示:用户最愿意接受荷兰语(nl),英文也行(en),就是不愿意接受法语(fr)或者土耳其语(tr)。q 值的范围从 0.0~1.0(1.0 优先级最高)HTTPS理解 HTTP 和 HTTPSHTTP 与 HTTPS 的概念HTTP 是客户端浏览器或其他程序与 Web 服务器之间的应用层通信协议。在 Internet 上的 Web 服务器上存放的都是超文本信息,客户机需要通过 HTTP 协议传输所要访问的超文本信息。HTTPS(全称:Hyper Text Transfer Protocol over Secure Socket Layer),是以安全为目标的 HTTP 通道,简单讲是 HTTP 的安全版。那为什么需要使用 HTTPS 来进行通信?我们先看一下 HTTP 的缺点:通信内容为明文,即未加密,内容可能会被窃听。通信双方的身份没有进行验证,可能出现伪装身份的情况。接受的报文完整性无法确定,可能中途被改动。HTTPS 协议概述HTTPS 可以认为是HTTP + TLS(安全传输层协议,前身是 SSL 协议)。鉴于 HTTP 的缺点,HTTPS 在 HTTP 的基础上增加了:内容加密身份认证数据完整性保护在访问使用 HTTPS 通信的 Web 网站时候,我们可以看到:首先,需要理清的是 HTTPS 并非是一个新的协议。HTTP 的通信接口部分采用了 SSL 协议来实现。可以看出,SSL 是独立于 HTTP 的协议,它同样也可用于其他协议的加密,如 SMTP 等。加密方式对称加密对称密钥加密是指加密和解密使用同一个密钥的方式,这种方式存在的最大问题就是密钥发送问题,即如何安全地将密钥发给对方;为什么叫对称加密?一方通过密钥将信息加密后,把密文传给另一方,另一方通过这个相同的密钥将密文解密,转换成可以理解的明文。非对称加密共享密钥带来了一个问题就是,如何能够安全地把密钥发送给对方。而公开密钥则较好地解决了这个问题。非对称的密钥。一把是公有密钥,一把是私有密钥。公有密钥是对通信双方公开的,任何人都可以获取,而私有的则不公开。发送方使用这把公有密钥对报文进行加密,接收方则使用私有的密钥进行解密。仅仅通过密文和公有密钥是很难破解到密文。使用公开密钥带来安全的同时,也隐藏着一些问题,就是如何保证公开的这把公有密钥的真实性?这个问题伴随也是证书机构。通过证书来保证公有密钥的真实性。HTTPS 采用混合加密机制由于公有密钥的机制相对复杂,导致其处理速度相对较慢。于是 HTTPS 利用了两者的优势,采用了混合加密的机制。我们知道,共享(对称)密钥未能解决的问题是如何能够安全地把密钥发送给对方。只要解决了这个问题就可以进行安全地通信。于是,HTTPS 首先是通过公有密钥来对共享密钥进行加密传输。当共享密钥安全地传输给对方后,双方则使用共享密钥的方式来加密报文,以此来提高传输的效率。HTTP 和 HTTPS 的工作过程HTTP 的工作过程HTTP 由请求和响应构成,是一个标准的客户端服务器模型(C/S)。HTTP 协议永远都是客户端发起请求,服务器回送响应。地址解析。域名系统 DNS 解析域名得到主机的 IP 地址封装 HTTP 请求数据包。封装的内容有以上部分结合本机自己的信息。封装成 TCP 包,建立 TCP 连接(TCP 的三次握手)客户机发送请求命令。 建立连接后,客户机向服务器发送一个请求服务器响应。服务器接到请求后,给予相应的响应信息服务器关闭 TCP 连接。一般 Web 服务器向浏览器发送了请求数据,它要关闭 TCP 连接客户端解析报文,解析 HTML 代码,并渲染HTTPS 的工作过程https 通信时,首先建立 ssl 层的连接,客户端将 ssl 版本号和加密组件发到服务器端,服务器端收到后对 ssl 版本号和加密组件进行匹配,同时将 CA 证书及密钥发送到客户端。客户端对证书进行验证,验证通过后使用非对称加密对数据通信时的密钥进行协商。协商后得到一致的获得一致的对称加密密钥。然后使用对称加密算法进行 TCP 连接,后续的过程跟 http 的过程一致。三次握手,数据交换,四次挥手,通信结束。过程如下 :客户端和服务器端通过 TCP 建立连接。客户端向服务器发送 HTTPS 请求。服务器响应请求,并将数字证书发送给客户端,数字证书包括公共秘钥、域名、申请证书的公司。客户端收到服务器端的数字证书之后,会验证数字证书的合法性。如果公钥合格,那么客户端会生成一个用于进行对称加密的密钥 client key,并用服务器的公钥对客户端密钥进行非对称加密。客户端会发起 HTTPS 中的第二个 HTTP 请求,将加密之后的客户端密钥发送给服务器。服务器接收到客户端发来的密文之后,会用私钥对其进行非对称解密,得到客户端秘钥。并使用客户端秘钥进行对称加密,生成密文并发送。客户端收到密文,并使用客户端秘钥进行解密,渲染网页。SSL 会使通信的效率降低通信速率降低HTTPS 除了 TCP 连接,发送请求,响应之外,还需要进行 SSL 通信。整体通信信息量增加。加密过程消耗资源每个报文都需要进行加密和解密的运算处理。比起 HTTP 会消耗更多的服务器资源。证书开销如果想要通过 HTTPS 进行通信,就必须向认证机构购买证书。HTTP 与 HTTPS 的区别安全性上,HTTPS 是安全超文本协议,在 HTTP 基础上有更强的安全性。简单来说,HTTPS 是使用 TLS/SSL 加密的 HTTP 协议。申请证书上,HTTPS 需要使用申请证书。传输协议上, HTTP 是超文本传输协议,明文传输;HTTPS 是具有安全性的 TLS/SSL 加密传输协议。连接方式与端口上,http 的连接简单,是无状态的,端口是 80; https 在 http 的基础上使用了 ssl 协议进行加密传输,端口是 443。基于 HTTP 的功能追加协议HTTP 协议的瓶颈HTTP 的一些标准会成为 HTTP 性能上的瓶颈:一条连接上只可发送一个请求。请求只能从客户端开始,客户端不可以接收除响应以外的指令。请求/响应首部未经压缩就发送,首部信息越多延迟越大。每次互相发送相同的首部造成的浪费较多。可任意选择数据压缩格式,非强制压缩发送。解决办法1. Ajax和以前的同步通信相比,由于它只更新一部分页面,响应中传输的数据量会因此而减少。但仍未解决 HTTP 协议本身存在的问题。2. CometComet 会先将响应置于挂起状态,当服务器端有内容更新时,再返回该响应。因此服务器端一旦有更新,就可以立即反馈给客户端。3. SPDYGoogle 在 2010 年发布,其开发目标旨在解决 HTTP 的性能瓶颈,缩短 Web 页面的加载时间。SPDY 没有完全改写 HTTP 协议,而是在 TCP/IP 的应用层与运输层之间通过新加会话层的形式运作。同时考虑到安全性问题,SPDY 规定通信中使用 SSL。使用 SPDY 后,HTTP 协议额外获得的功能:多路复用流:单一的 TCP 连接,可以无限制处理多个 HTTP 请求。赋予请求优先级:给请求逐个分配优先级顺序。压缩 HTTP 首部:减少通信产生的数据包的数量和发送的字节数。推送功能:支持服务器主动向客户端主动推送数据的功能。服务器提示功能:服务器可以主动提示客户端请求所需的资源。用 SPDY 时,Web 服务器要对应作出相应的改动;SPDY 的确是一种可有效消除 HTTP 瓶颈的技术,但很多 Web 网站存在的问题并非仅仅是由 HTTP 瓶颈所导致。4. WebSocketWebSocket 是建立在 HTTP 基础上的协议,因此连接的发起方仍是客户端,而一旦确立 WebSocket 通信连接,不论服务器还是客户端,任意一方都可直接向对方发送报文。WebScoket 协议的主要特点:推送功能:支持服务器向客户端推送数据的推送功能。减少通信量:只要建立起 WebSocket 连接,就希望一直保持连接状态,和 HTTP 相比,不但每次连接时的总开销减少,而且由于 WebSocket 的首部信息很小,通信量也相应减少了。为了实现 WebSocket 通信,在 HTTP 连接建立之后,需要完成一次“握手”(Handshaking)的步骤。5.WebDAVWebDAV(Web-based Distributed Authoring and Versioning,基于万维网的分布式创作和版本控制)是一个可对 Web 服务器上的内容直接进行文件复制、编辑等操作的分布式文件系统,它还具备文件创建者管理、文件编辑过程中禁止其他用户内容覆盖的加锁功能,以及对文件内容修改的版本控制
类的加载器概述类加载器是JVM执行类加载机制的前提。ClassLoader的作用:ClassLoader是Java的核心组件,所有的Class都是由ClassLoader进行加载的,ClassLoader负责通过各种方式将Class信息的二进制数据流读入JVM内部,转换为一个与目标类对应的java.lang.Class对象实例。然后交给Java虚拟机进行链接、初始化等操作。因此,ClassLoader在整个装载阶段,只能影响到类的加载,而无法通过ClassLoader去改变类的链接和初始化行为。至于它是否可以运行,则由Execution Engine决定。类加载分类类的加载分类:显式加载 vs 隐式加载class文件的显式加载与隐式加载的方式是指JVM加载class文件到内存的方式。显式加载指的是在代码中通过调用ClassLoader加载class对象,如直接使用Class.forName(name)或this.getClass().getClassLoader().loadClass()加载class对象。隐式加载则是不直接在代码中调用ClassLoader的方法加载class对象,而是通过虚拟机自动加载到内存中,如在加载某个类的class文件时,该类的class文件中引用了另外一个类的对象,此时额外引用的类将通过JVM自动加载到内存中。代码示例:User user=new User();//隐式加载 Class clazz=Class.forName("com.atguigu.java.User");//显式加载并初始化 ClassLoader.getSystemClassLoader().loadClass("T1.Parent"); //显式加载,但不初始化类加载器的必要性一般情况下,Java开发人员并不需要在程序中显式地使用类加载器,但是了解类加载器的加载机制却显得至关重要。从以下几个方面说:避免在开发中遇到java.lang.ClassNotFoundException异常或java.lang.NoClassDefFoundError异常时,手足无措。只有了解类加载器的 加载机制才能够在出现异常的时候快速地根据错误异常日志定位问题和解决问题需要支持类的动态加载或需要对编译后的字节码文件进行加解密操作时,就需要与类加载器打交道了。开发人员可以在程序中编写自定义类加载器来重新定义类的加载规则,以便实现一些自定义的处理逻辑。命名空间什么是类的唯一性?对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在Java虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义。否则,即使这两个类源自同一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。命名空间每个类加载器都有自己的命名空间,命名空间由该加载器及所有的父加载器所加载的类组成在同一命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类类加载机制的基本特征双亲委派模型。但不是所有类加载都遵守这个模型,有的时候,启动类加载器所加载的类型,是可能要加载用户代码的,比如JDK内部的ServiceProvider/ServiceLoader机制,用户可以在标准API框架上,提供自己的实现,JDK也需要提供些默认的参考实现。例如,Java中JNDI、JDBC、文件系统、Cipher等很多方面,都是利用的这种机制,这种情况就不会用双亲委派模型去加载,而是利用所谓的上下文加载器。可见性,子类加载器可以访问父加载器加载的类型,但是反过来是不允许的。不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑。单一性,由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器“邻居”间,同一类型仍然可以被加载多次,因为互相并不可见。类的加载器分类JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)。从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。无论类加载器的类型如何划分,在程序中我们最常见的类加载器结构主要是如下情况:除了顶层的启动类加载器外,其余的类加载器都应当有自己的“父类”加戟器。不同类加载器看似是继承(Inheritance)关系,实际上是包含关系。在下层加载器中,包含着上层加载器的引用。引导类加载器(Bootstrap ClassLoader)这个类加载使用C/C++语言实现的,嵌套在JVM内部。它用来加载Java的核心库(JAVAHOME/jre/lib/rt.jar或sun.boot.class.path路径下的内容)。用于提供JVM自身需要的类。并不继承自java.lang.ClassLoader,没有父加载器。出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类加载扩展类和应用程序类加载器,并指定为他们的父类加载器。扩展类加载器(Extension ClassLoader)Java语言编写,由sun.misc.Launcher$ExtClassLoader实现。继承于ClassLoader类父类加载器为启动类加载器从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。代码示例:public static void main(String[] args) { //获取BootstrapcLassLoader能够加载的api的路径 URL[] urLs = sun.misc.Launcher.getBootstrapClassPath().getURLs(); for (URL element : urLs) { System.out.println(element.toExternalForm()); } // file:/D:/java/jre/lib/resources.jar // file:/D:/java/jre/lib/rt.jar // file:/D:/java/jre/lib/sunrsasign.jar // file:/D:/java/jre/lib/jsse.jar // file:/D:/java/jre/lib/jce.jar // file:/D:/java/jre/lib/charsets.jar // file:/D:/java/jre/lib/jfr.jar // file:/D:/java/jre/classes //引导类加载 ClassLoader classloader = java.security.Provider.class.getClassLoader(); System.out.println(classloader); // null System.out.println("***********扩展类加载器*************"); String extDirs = System.getProperty("java.ext.dirs"); for (String path : extDirs.split(";")) { System.out.println(path); } // D:\Java\jre\lib\ext // C:\Windows\Sun\Java\lib\ext //扩展类加载器 ClassLoader classLoader1 = sun.security.ec.CurveDB.class.getClassLoader(); System.out.println(classLoader1);// sun.misc.Launcher$ExtClassLoader@6e0be858 }系统类加载器(AppClassLoader)java语言编写,由sun.misc.Launcher$AppClassLoader实现继承于ClassLoader类父类加载器为扩展类加载器它负责加载环境变量classpath或系统属性java.class.path 指定路径下的类库应用程序中的类加载器默认是系统类加载器。它是用户自定义类加载器的默认父加载器通过ClassLoader的getSystemClassLoader()方法可以获取到该类加载器用户自定义类加载器在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的。在必要时,我们还可以自定义类加载器,来定制类的加载方式。体现Java语言强大生命力和巨大魅力的关键因素之一便是,Java开发者可以自定义类加载器来实现类库的动态加载,加载源可以是本地的JAR包,也可以是网络上的远程资源。通过类加载器可以实现非常绝妙的插件机制,这方面的实际应用案例举不胜举。例如,著名的OSGI组件框架,再如Eclipse的插件机制。类加载器为应用程序提供了一种动态增加新功能的机制,这种机制无须重新打包发布应用程序就能实现。同时,自定义加载器能够实现应用隔离,例如Tomcat,Spring等中间件和组件框架都在内部实现了自定义的加载器,并通过自定义加载器隔离不同的组件模块。这种机制比C/C++程序要好太多,想不修改C/C++程序就能为其新增功能,几乎是不可能的,仅仅一个兼容性便能阻挡住所有美好的设想。自定义类加载器通常需要继承ClassLoader。ClassLoader源码分析ClassLoader与现有类加载器的关系:ClassLoader的主要方法public final ClassLoader getParent() :返回该类加载器的超类加载器public Class<?> loadClass(String name) :加载名称为name的类,返回结果为java.lang.Class类的实例。如果找不到类,则返回ClassNotFoundException异常。该方法中的逻辑就是使用双亲委派机制实现的。protected Class<?> loadClass(String name, boolean resolve) //resolve = false 不进行解析 throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded // 查看该类是否被加载过 Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { //获取当前类父类的加载器 if (parent != null) { //使用父类的加载器加载 c = parent.loadClass(name, false); } else { //说明父类加载器为引导类加载器 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) {//当前类的加载器父类加载器未加载此类 or 当前类的加载器未加载此类 // If still not found, then invoke findClass in order // to find the class. long t1 = System.nanoTime(); //调用当前Classloader的findClass c = findClass(name); // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { //是否解析 resolveClass(c); } return c; } }protected Class <?> findClass(String name) :查找二进制名称为name的类,返回结果为java.lang.Class类的实例。这是一个受保护的方法,JVM鼓励我们重写此方法,需要自定义加载器遵循双亲委托机制,该方法会在检查完父类加载器之后被loadClass()方法调用。protected Class<?> findClass(String name) throws ClassNotFoundException { throw new ClassNotFoundException(name); }我们找到对应重写该方法的地方。URLClassLoader类。protected Class<?> findClass(final String name) throws ClassNotFoundException { final Class<?> result; try { result = AccessController.doPrivileged( new PrivilegedExceptionAction<Class<?>>() { public Class<?> run() throws ClassNotFoundException { //全路径替换 String path = name.replace('.', '/').concat(".class"); Resource res = ucp.getResource(path, false); if (res != null) { try { //调用defineClass生成Class对象 return defineClass(name, res); } catch (IOException e) { throw new ClassNotFoundException(name, e); } } else { return null; } } }, acc); } catch (java.security.PrivilegedActionException pae) { throw (ClassNotFoundException) pae.getException(); } if (result == null) { throw new ClassNotFoundException(name); } return result; }protected final Class<?>defineclass(String name,byte[]b,int off,int len):根据给定的字节数组b转换为Class的实例,off和len参数表示实际Class信息在byte数组中的位置和长度,其中byte数组b是ClassLoader从外部获取的。这是受保护的方法,只有在自定义ClassLoader子类中可以使用。 defineClass()方法是用来将byte字节流解析成JVM能够识别的Class对象(ClassLoader 中已实现该方法逻辑),通过这个方法不仅能够通过class文件实例化class对象,也可 以通过其他方式实例化Class对象,如通过网络接收一个类的字节码,然后转换为byte 字节流创建对应的Class对象。 defineClass()方法通常与findClass()方法一起使用,一般情况下,在自定义类加载器 时,会直接覆盖ClassLoader的findClass()方法并编写加载规则,取得要加载类的字节 码后转换成流,然后调用defineClass()方法生成类的Class对象代码示例:@Override protected Class<?>findClass(String name)throws ClassNotFoundException{ //获取类的字节数组 byte[]classData=getClassData(name); if(classData==null){ throw new ClassNotFoundException(); }else{ //使用defineclass生成class对象 return defineclass(name,classData,0,classData.length); } }protected final void resolveClass(Class<?>c):链接指定的一个Java类。使用该方法可以使用类的Class对象创建完成的同时也被解析。前面我们说链接阶段主要是对字节码进行验证,为类变量分配内存并设置初始值同时将字节码文件中的符号引用转换为直接引用。protected final Class<?>findLoadedClass(String name):查找名称为name的已经被加载过的类,返回结果为java.lang.Class类的实例。这个方法是final方法,无法被修改。private final ClassLoader parent:它也是一个ClassLoader的实例,这个字段所表示的ClassLoader也称为这个ClassLoader的双亲。在类加载的过程中,ClassLoader可能会将某些请求交予自己的双亲处理。SecureClassLoader 与 URLClassLoaderSecureClassLoader扩展了ClassLoader,新增了几个与使用相关的代码源(对代码源的位置及其证书的验证)和权限定义类验证(主要指对class源码的访问权限)的方法,一般我们不会直接跟这个类打交道,更多是与它的子类URLClassLoader有所关联。前面说过,ClassLoader是一个抽象类,很多方法是空的没有实现,比如findClass()、findResource()等。而URLClassLoader这个实现类为这些方法提供了具体的实现。并新增了URLClassPath类协助取得Class字节码流等功能。在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。双亲委派模型如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就成功返回。只有父类加载器无法完成此加载任务时,才自己去加载。本质规定了类加载的顺序是:引导类加载器先加载,若加载不到,由扩展类加载器加载,若还加载不到,才会由系统类加载器或自定义的类加载器进行加载。优势与劣势优势避免类的重复加载,确保一个类的全局唯一性Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。保护程序安全,防止核心API被随意篡改问题如果在自定义的类加载器中重写java.lang.ClassLoader.loadClass(String)或java.lang.ClassLoader.loadclass(String,boolean)方法,抹去其中的双亲委派机制,那么是不是就能够加载核心类库了呢?这也不行!因为JDK还为核心类库提供了一层保护机制。不管是自定义的类加载器,还是系统类加载器抑或扩展类加载器,最终都必须调用 java.lang.ClassLoader.defineclass(String,byte[],int,int,ProtectionDomain)方法,而该方法会执行preDefineClass()接口,该接口中提供了对JDK核心类库的保护。劣势检查类是否加载的委托过程是单向的,这个方式虽然从结构上说比较清晰,使各个ClassLoader的职责非常明确,但是同时会带来一个问题,即顶层的ClassLoader无法访问底层的ClassLoader所加载的类。通常情况下,启动类加载器中的类为系统核心类,包括一些重要的系统接口,而在应用类加载器中,为应用类。按照这种模式,应用类访问系统类自然是没有问题,但是系统类访问应用类就会出现问题。比如在系统类中提供了一个接口,该接口需要在应用类中得以实现,该接口还绑定一个工厂方法,用于创建该接口的实例,而接口和工厂方法都在启动类加载器中。这时,就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题。自定义类加载器为什么要自定义类加载器?隔离加载类在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境。比如:阿里内某容器框架通过自定义类加载器确保应用中依赖的jar包不会影响到中间件运行时使用的jar包。再比如:Tomcat这类Web应用服务器,内部自定义了好几种类加载器,用于隔离同一个Web应用服务器上的不同应用程序。修改类加载的方式类的加载模型并非强制,除Bootstrap外,其他的加载并非一定要引入,或者根据实际情况在某个时间点进行按需进行动态加载扩展加载源比如从数据库、网络、甚至是电视机机顶盒进行加载防止源码泄漏Java代码容易被编译和篡改,可以进行编译加密。那么类加载也需要自定义,还原加密的字节码。常见的场景实现类似进程内隔离,类加载器实际上用作不同的命名空间,以提供类似容器、模块化的效果。例如,两个模块依赖于某个类库的不同版本,如果分别被不同的容器加载,就可以互不干扰。这个方面的集大成者是JavaEE和OSGI、JPMS等框架。应用需要从不同的数据源获取类定义信息,例如网络数据源,而不是本地文件系统。或者是需要自己操纵字节码,动态修改或者生成类型。注意在一般情况下,使用不同的类加载器去加载不同的功能模块,会提高应用程序的安全性。但是,如果涉及Java类型转换,则加载器反而容易产生不美好的事情。在做Java类型转换时,只有两个类型都是由同一个加载器所加载,才能进行类型转换,否则转换时会发生异常。实现方式Java提供了抽象类java.lang.ClassLoader,所有用户自定义的类加载器都应该继承ClassLoader类。在自定义ClassLoader的子类时候,我们常见的会有两种做法:方式一:重写loadClass()方法方式二:重写findclass()方法(推荐)这两种方法本质上差不多,毕竟loadClass()也会调用findClass(),但是从逻辑上讲我们最好不要直接修改loadClass()的内部逻辑。建议的做法是只在findClass()里重写自定义类的加载方法,根据参数指定类的名字,返回对应的Class对象的引用。loadclass()这个方法是实现双亲委派模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。因此我们最好是在双亲委派模型框架内进行小范围的改动,不破坏原有的稳定结构。同时,也避免了自己重写loadClass()方法的过程中必须写双亲委托的重复代码,从代码的复用性来看,不直接修改这个方法始终是比较好的选择。当编写好自定义类加载器后,便可以在程序中调用loadClass()方法来实现类加载操作。代码示例:public class MyClassLoad extends ClassLoader { private String byteCodePath; public MyClassLoad(String byteCodePath) { this.byteCodePath = byteCodePath; } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { BufferedInputStream bufferedInputStream = null; ByteArrayOutputStream byteOutputStream = null; try { // 获取完整的字节码文件路径+class文件名 String fileName = byteCodePath + name + ".class"; // 获取一个输入流 bufferedInputStream = new BufferedInputStream(new FileInputStream(fileName)); // 获取一个输出流 byteOutputStream = new ByteArrayOutputStream(); // 具体读取数据并写出的过程 int len; byte[] data = new byte[1024]; while ((len = bufferedInputStream.read(data)) != -1) { byteOutputStream.write(data, 0, len); } // 将输出流转成数组 byte[] byteCode = byteOutputStream.toByteArray(); // 调用defineClass(),将字节数组转成Class实列 Class<?> aClass = defineClass(null, byteCode, 0, byteCode.length); return aClass; } catch (IOException e) { e.printStackTrace(); } finally { try { if (bufferedInputStream != null) { bufferedInputStream.close(); } } catch (IOException e) { e.printStackTrace(); } try { if (byteOutputStream != null) { byteOutputStream.close(); } } catch (IOException e) { e.printStackTrace(); } } return null; } }测试类:public class MyClassLoadTest { public static void main(String[] args) throws ClassNotFoundException { MyClassLoad myClassLoad = new MyClassLoad("D:/"); Class<?> myClassLoadClass = myClassLoad.findClass("Demo1"); System.out.println(myClassLoadClass.getClassLoader().getClass().getName()); System.out.println(myClassLoadClass.getClassLoader().getParent().getClass().getName()); } }Java9新特性为了保证兼容性,JDK9没有从根本上改变三层类加载器架构和双亲委派模型,但为了模块化系统的顺利运行,仍然发生了一些值得被注意的变动。变化扩展机制被移除,扩展类加载器由于向后兼容性的原因被保留,不过被重命名为平台类加载器(platform class loader)。可以通过classLoader的新方法getPlatformClassLoader()来获取。JDK9时基于模块化进行构建(原来的rt.jar和tools.jar被拆分成数十个JMOD文件),其中的Java类库就已天然地满足了可扩展的需求,那自然无须再保留<JAVA_HOME>libext目录,此前使用这个目录或者java.ext.dirs系统变量来扩展JDK功能的机制已经没有继续存在的价值了。平台类加载器和应用程序类加载器都不再继承自java.net.URLClassLoader。现在启动类加载器、平台类加载器、应用程序类加载器全都继承于jdk.internal.loader.BuiltinClassLoader。在Java9中,类加载器有了名称。该名称在构造方法中指定,可以通过getName()方法来获取。平台类加载器的名称是platform,应用类加载器的名称是app。类加载器的名称在调试与类加载器相关的问题时会非常有用。启动类加载器现在是在jvm内部和java类库共同协作实现的类加载器(以前是C++实现),但为了与之前代码兼容,在获取启动类加载器的场景中仍然会返回null,而不会得到BootClassLoader实例。类加载的委派关系也发生了变动。当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。
字节码指令集概述Java字节码对于虚拟机,就好比汇编语言对于计算机,属于基本执行指令。Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。由于Java虚拟机采用面向操作数栈而不是寄存器的结构,所以大多数的指令都不包含操作数,只有一个操作码。比如 aload_0 就是只有操作码没有操作数,而invokespecial #1 就是由操作数和操作码构成。由于限制了Java虚拟机操作码的长度为一个字节(0 ~ 255),这意味着指令集的操作码总数不可能超过256条。字节码与数据类型对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务i代表int类型l代表longs代表shortb代表bytec代表charf代表floatd代表double也有一些指令的助记符中没有明确地指明操作类型的字母,如arraylength指令,他没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。还有另外一些指令,如无条件跳转指令goto则是与数据类型无关的。大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。编译器会在编译器或运行期将byte和short类型的数据带符号扩展(Sign-Extend)为相应的int类型数据,将boolean和char类型数据零位扩展(Zero-Extend)为相应的int类型数据。与之类似,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。因此,大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的int类型作为运算类型(Computational Type)。加载与存储指令作用加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传递。常用指令【局部变量压栈指令】将一个局部变量加载到操作数栈:xload、xload_(其中x为i、l、f、d、a,n为0到3)【常量入栈指令】将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_ml、iconst、lconst、fconst、dconst【出栈装入局部变量表指令】将一个数值从操作数栈存储到局部变量表:xstore、xstore_(其中x为i、l、f、d、a,n为0到3);xastore(其中x为i、1、f、d、a、b、c、s)上面所列举的指令助记符中,有一部分是以尖括号结尾的(例如iload)。这些指令助记符实际上代表了一组指令(例如iload代表了iload0、iload1、iload2和iload3这几个指令)。这几组指令都是某个带有一个操作数的通用指令(例如iload)的特殊形式,对于这若干组特殊指令来说,它们表面上没有操作数,不需要进行取操作数的动作,但操作数都隐含在指令中。例如:iload_0:将局部变量表中索引为0位置上的数据压入操作数栈中。 iload 0:将局部变量表中索引为0位置上的数据压入操作数栈中。 iload 4:将局部变量表中索引为4位置上的数据压入操作数栈中。前两种所表达的意思是相同的,不过iload_0相当于是只有操作码所以只占用1个字节,而iload 0 是操作码和操作数所组成的,而操作数占 2 个字节,所以占用3个字节。默认最多只有0-3。局部变量压栈指令局部变量压栈指令将给定的局部变量表中的数据压入操作数栈。这类指令大体可以分为:xload_<n>(x为i、1、f、d、a,n为0到3)xload(x为i、1、f、d、a)说明:在这里,x的取值表示数据类型。指令xload_n表示将第n个局部变量压入操作数栈,比如iload_1、fload_0、aload_0等指令。其中aload_n表示将一个对象引用压栈。指令xload通过指定参数的形式,把局部变量压入操作数栈,当使用这个命令时,表示局部变量的数量可能超过了4个,比如指令iload、fload等。代码示例:// 1 局部变量入栈命令 public void load(int num,Object obj,long count,boolean flag,short[] arr){ System.out.println(num); System.out.println(obj); System.out.println(count); System.out.println(flag); System.out.println(arr); }所对应的局部变量表常量入栈指令常量入栈指令的功能是将常数压入操作数栈,根据数据类型和入栈内容的不同,又可以分为const系列、push系列和ldc指令。指令const系列:用于对特定的常量入栈,入栈的常量隐含在指令本身里。指令有:iconst_<i>(i从-1到5)、lconst_<1>(1从0到1)、fconst_<f>(f从0到2)、dconst_<d>(d从0到1)、aconst_null。比如:iconst_m1将-1压入操作数栈;iconst_x(x为0到5)将x压入栈;lconst_0、lconst_1分别将长整数0和1压入栈;fconst_0、fconst_1、fconst_2分别将浮点数0、1、2压入栈;dconst_0和dconst_1分别将double型0和1压入栈;aconst_null将null压入操作数栈;从指令的命名上不难找出规律,指令助记符的第一个字符总是喜欢表示数据类型,i表示整数,l表示长整数,f表示浮点数,d表示双精度浮点,习惯上用a表示对象引用。如果指令隐含操作的参数,会以下划线形式给出。const_x,是变量值,并且是有范围,比如int大于5,就要使用push系列指令push系列:主要包括bipush和sipush。它们的区别在于接收数据类型的不同,bipush接收8位整数作为参数,sipush接收16位整数,它们都将参数压入栈。指令ldc系列:如果以上指令都不能满足需求,那么可以使用万能的ldc指令,它可以接收一个8位的参数,该参数指向常量池中的int、float或者String的索引,将指定的内容压入堆栈。类似的还有ldc_w,它接收两个8位数,能支持的索引范围大于ldc。如果要压入的元素是long或者double类型的,则使用ldc2_w指令,使用方式都是类似的。代码示例:public void pushConstLdc() { int i = -1; int a = 5; int b = 6; int c = 127; int d = 128; int e = 32767; int f = 32768; }所对应的字节码指令如下:指令总结如下:类型指令范围int(boolean,byte,char,short)iconst[-1,5]bipush[-128,127]sipush[-32768,32767]ldcany int valuelonglconst0,1ldcany int valuefloatfconst0,1,2ldcany int valuedoubledconst0,1ldcany int valuereferenceaconstnullldcString literal,Class literal出栈装入局部变量表出栈装入局部变量表指令用于将操作数栈中栈顶元素弹出后,装入局部变量表的指定位置,用于给局部变量赋值。这类指令主要以store的形式存在,比如xstore(x为i、l、f、d、a)、xstore_n(x为i、l、f、d、a,n为0至3)。其中,指令istore_n将从操作数栈中弹出一个整数,并把它赋值给局部变量索引n位置。指令xstore由于没有隐含参数信息,故需要提供一个byte类型的参数类指定目标局部变量表的位置。说明:一般说来,类似像store这样的命令需要带一个参数,用来指明将弹出的元素放在局部变量表的第几个位置。但是,为了尽可能压缩指令大小,使用专门的istore_1指令表示将弹出的元素放置在局部变量表第1个位置。类似的还有istore_0、istore_2、istore_3,它们分别表示从操作数栈顶弹出一个元素,存放在局部变量表第0、2、3个位置。由于局部变量表前几个位置总是非常常用,因此这种做法虽然增加了指令数量,但是可以大大压缩生成的字节码的体积。如果局部变量表很大,需要存储的槽位大于3,那么可以使用istore指令,外加一个参数,用来表示需要存放的槽位位置。代码示例:public void store(int k, double d) { int m = k + 2; long l = 2; String str = "jack"; float f = 10.0F; d = 10; }对应的字节码指令和局部变量表:算术指令作用算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新压入操作数栈。分类大体上算术指令可以分为两种:对整型数据进行运算的指令和对浮点类型数据进行运算的指令。byte、short、char和boolean类型说明在每一大类中,都有针对Java虚拟机具体数据类型的专用算术指令。但没有直接支持byte、short、char和boolean类型的算术指令,对于这些数据的运算,都使用int类型的指令来处理。此外,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。运算时的溢出数据运算可能会导致溢出,例如两个很大的正整数相加,结果可能是一个负数。其实Java虚拟机规范并无明确规定过整型数据溢出的具体结果,仅规定了在处理整型数据时,只有除法指令以及求余指令中当出现除数为0时会导致虚拟机抛出异常ArithmeticException。运算模式向最接近数舍入模式:JVM要求在进行浮点数计算时,所有的运算结果都必须舍入到适当的精度,非精确结果必须舍入为可被表示的最接近的精确值,如果有两种可表示的形式与该值一样接近,将优先选择最低有效位为零的;(类似四舍五入)向零舍入模式:将浮点数转换为整数时,采用该模式,该模式将在目标数值类型中选择一个最接近但是不大于原值的数字作为最精确的舍入结果;(类似取整)NaN值使用当一个操作产生溢出时,将会使用有符号的无穷大表示,如果某个操作结果没有明确的数学定义的话,将会使用NaN值来表示。而且所有使用NaN值作为操作数的算术操作,结果都会返回NaN;(Infinity无穷大)public void test() { int i = 10; double j = i / 0.0; System.out.println(j); // Infinity double d1 = 0.0; double d2 = d1 / 0.0; System.out.println(d2); // NaN }所有算术指令加法指令:iadd、ladd、fadd、dadd减法指令:isub、lsub、fsub、dsub乘法指令:imul、lmul、fmul、dmul除法指令:idiv、ldiv、fdiv、ddiv求余指令:irem、lrem、frem、drem取反指令:ineg、lneg、fneg、dneg自增指令:iinc比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp位运算指令:位移指令:ishl、ishr、iushr、lshl、lshr、lushr按位或指令:ior、lor按位与指令:iand、land按位异或指令:ixor、lxor代码示例:public void method2() { float i = 10; float j = -i; i = -j; }对应字节码如下:比较指令的说明比较指令的作用是比较栈顶两个元素的大,并将比较结果入栈。比较指令有:dcmpg,dcmpl、fcmpg、fcmpl、lcmp。与前面讲解的指令类似,首字符d表示double类型,f表示float,l表示long。对于double和float类型的数字,由于NaN的存在,各有两个版本的比较指令。以float为例,有fcmpg和fcmpl两个指令,它们的区别在于在数字比较时,若遇到NaN值,处理结果不同。指令dcmpl和dcmpg也是类似的,根据其命名可以推测其含义,在此不再赘述。指令lcmp针对long型整数,由于long型整数没有NaN值,故无需准备两套指令。类型转换指令宽化类型转换转换规则Java虚拟机直支持以下数值的宽化类型转换(widening numeric conversion,小范围类型向大范围类型的安全转换)。也就是说,并不需要指令执行,包括:从int类型到long、float或者double类型。对应的指令为:i2l、i2f、i2d从long类型到float、double类型。对应的指令为:l2f、l2d从float类型到double类型。对应的指令为:f2d简化为:int–>long–>float–>double// 宽化类型转换 public void test() { int i = 10; long l = i; // i2l float f = i; // i2f double d = i; // i2d float f1 = l; // l2f double d1 = l; // l2d double d2 = f1; // f2d }精度损失问题宽化类型转换是不会因为超过目标类型最大值而丢失信息的,例如,从int转换到long,或者从int转换到double,都不会丢失任何信息,转换前后的值是精确相等的。从int、long类型数值转换到float,或者long类型数值转换到double时,将可能发生精度丢失——可能丢失掉几个最低有效位上的值,转换后的浮点数值是根据IEEE754最接近舍入模式所得到的正确整数值。尽管宽化类型转换实际上是可能发生精度丢失的,但是这种转换永远不会导致Java虚拟机抛出运行时异常。代码示例:public void upCast2() { int i = 123123123; float f = i; System.out.println(f); // 1.2312312E8 = 123123120 精度丢失 long l = 123123123123123123L; //1.2312312312312312E17 double d = l; //123123123123123120 精度丢失 System.out.println(d); }补充说明从byte、char和short类型到int类型的宽化类型转换实际上是不存在的。对于byte类型转为int,虚拟机并没有做实质性的转化处理,只是简单地通过操作数栈交换了两个数据。而将byte转为long时,使用的是i2l,可以看到在内部byte在这里已经等同于int类型处理,类似的还有short类型,这种处理方式有两个特点:一方面可以减少实际的数据类型,如果为short和byte都准备一套指令,那么指令的数量就会大增,而虚拟机目前的设计上,只愿意使用一个字节表示指令,因此指令总数不能超过256个,为了节省指令资源,将short和byte当做int处理也在情理之中。另一方面,由于局部变量表中的槽位固定为32位,无论是byte或者short存入局部变量表,都会占用32位空间。从这个角度说,也没有必要特意区分这几种数据类型。窄化类型转换(强制转换)规则Java虚拟机也直接支持以下窄化类型转换:从int类型至byte、short或者char类型。对应的指令有:i2b、i2s、i2c从long类型到int类型。对应的指令有:l2i从float类型到int或者long类型。对应的指令有:f2i、f2l从double类型到int、long或者float类型。对应的指令有:d2i、d2l、d2f代码示例:public void downCastl() { int i = 10; byte b = (byte) i; // i2b short s = (short) i; // i2s char c = (char) i; // i2c long l = 10L; int il = (int) l; // l2i byte b1 = (byte) l; // l2i i2b } public void downCast2() { float f = 10; long l = (long) f; // f2l int i = (int) f; // f2i byte b = (byte) f; // f2i i2b double d = 10; byte b1 = (byte) d; // d2i i2b }精度损失问题窄化类型转换可能会导致转换结果具备不同的正负号、不同的数量级,因此,转换过程很可能会导致数值丢失精度。尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但是Java虚拟机规范中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常。补充说明当将一个浮点值窄化转换为整数类型T(T限于int或long类型之一)的时候,将遵循以下转换规则:如果浮点值是NaN,那转换结果就是int或long类型的0。如果浮点值不是无穷大的语浮点值使用IEEE754的向零舍入模式取整,获得整数值v,如果v在目标类型T(int或long)的表示范围之内,那转换结果就是v。否则,将根据v的符号,转换为T所能表示的最大或者最小正数当将一个double类型窄化转换为float类型时,将遵循以下转换规则:通过向最接近数舍入模式舍入一个可以使用float类型表示的数字。最后结果根据下面这3条规则判断:如果转换结果的绝对值太小而无法使用float来表示,将返回float类型的正负零。如果转换结果的绝对值太大而无法使用float来表示,将返回float类型的正负无穷大。对于double类型的NaN值将按规定转换为float类型的NaN值。对象的创建与访问指令Java是面向对象的程序设计语言,虚拟机平台从字节码层面就对面向对象做了深层次的支。有一系列指令专门用于对象操作,可进一步细分为创建指令、字段访问指令、数组操作指令、类型检查指令。创建指令虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令:创建类实例的指令:创建类实例的指令:new它接收一个操作数,为指向常量池的索引,表示要创建的类型,执行完成后,将对象的引用压入栈。创建数组的指令:创建数组的指令:newarray、anewarray、multianewarray。newarray:创建基本类型数组anewarray:创建引用类型数组multianewarray:创建多维数组上述创建指令可以用于创建对象或者数组,由于对象和数组在Java中的广泛使用,这些指令的使用频率也非常高。代码示例:// 创建对象 public void newInstance() { Object obj = new Object(); File file = new File("Hello.txt"); }对应的字节码如下:dup是将栈顶数值复制一份并送入至栈顶。因为invokespecial会消耗掉一个当前类的引用,因而需要复制一份。// 创建数组 public void newArray() { int[] intArray = new int[10]; // newarray Object[] objArray = new Object[10]; // anewarray int[][] mintArray = new int[10][10]; // multianewarray String[][] strArray = new String[10][]; // newarray String[][] strArray2 = new String[10][5]; // multianewarray }字段访问指令对象创建后,就可以通过对象访问指令获取对象实例或数组实例中的字段或者数组元素。访问类字段(static字段,或者称为类变量)的指令:getstatic、putstatic访问类实例字段(非static字段,或者称为实例变量)的指令:getfield、putfield数组操作指令数组操作指令主要有:xastore和xaload指令。具体为:把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload将一个操作数栈的值存储到数组元素中的指令:rbastore、castore、sastore、iastore、lastore、fastore、dastore、aastore取数组长度的指令:arraylength该指令弹出栈顶的数组元素,获取数组的长度,将长度压入栈。数组类型加载指令存储指令byte(boolean)baloadbastorecharcaloadcastoreshortsaloadsastoreintialoadiastorelonglaloadlastorefloatfaloadfastoredoubledaloaddastorereferenceaaloadaastore虚拟机栈中并不存储数组元素信息,所以astore改变的是堆中的实例数组说明指令xaload表示将数组的元素压栈,比如saload、caload分别表示压入short数组和char数组。指令xaload在执行时,要求操作数中栈顶元素为数组索引i,栈顶顺位第2个元素为数组引用a,该指令会弹出栈顶这两个元素,并将a[i]重新压入堆栈。xastore则专门针对数组操作,以iastore为例,它用于给一个int数组的给定索引赋值。在iastore执行前,操作数栈顶需要以此准备3个元素:值、索引、数组引用,iastore会弹出这3个值,并将值赋给数组中指定索引的位置。代码示例:public void text() { int[] intArray = new int[10]; intArray[3] = 20; System.out.println(intArray[1]); }对应的字节码如下:类型检查指令检查类实例或数组类型的指令:instanceof、checkcast。指令cheqkcast用于检查类型强制转换是否可以进行。如果可以进行,那么checkcast指令不会改变操作数栈,否则它会抛出ClassCastException异常。|指令instanceof用来判断给定对象是否是某一个类的实例,它会将判断结果压入操作数栈。//类型检查指令 public String checkcast(Object obj) { if (obj instanceof String) { return (String) obj; } else { return null; } }对应的字节码如下:方法的调用和返回指令方法调用指令方法调用指令:invokevirtual、invokeinterface、invokespecial、invokestatic、invokedynamic以下5条指令用于方法调用:invokevirtual:指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),支持多态。这也是Java语言中最常见的方法分派方式。invokeinterface:指令用于调用接口方法,它会在运行时搜索由特定对象所实现的这个接口方法,并找出适合的方法进行调用。invokespecial:指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法(构造器)、私有方法和父类方法。这些方法都是静态类型绑定的,不会在调用时进行动态派发。invokestatic:指令用于调用命名类中的类方法(static方法)。这是静态绑定的。invokedynamic::调用动态绑定的方法,这个是JDK1.7后新加入的指令。用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。前面4条调用指令的分派逻辑都固化在java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。invokespecial 和invokestatic 都不可能重写代码示例:public void invoke() { //情况1:类实例构造器方法:<init>() Date date = new Date(); Thread t1 = new Thread(); //情况2:父类的方法 super.toString(); //情况3:私有方法 methodPrivate(); } private void methodPrivate() {}对应的字节码如下://方法调用指令:invokestatic public void invoke() { methodstatic(); //0 invokestatic #2 <com/test/Demo.methodstatic> //3 return } private static void methodstatic() {}//方法调用指令:invokeinterface public void invoke() { Thread t1 = new Thread(); ((Runnable) t1).run(); Comparable<Integer> com = null; com.compareTo(123); }对应的字节码如下:方法返回指令方法调用结束前,需要进行返回。方法返回指令是根据返回值的类型区分的。包括ireturn(当返回值是boolean、byte、char、short和int 类型时使用)、lreturn、freturn、dreturn和areturn另外还有一条return 指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。返回类型返回指令voidreturnint (boolean,byte,char,short)ireturnlonglreturnfloatfreturndoubledreturnreferenceareturn注意:通过ireturn指令,将当前函数操作数栈的顶层元素弹出,并将这个元素压入调用者函数的操作数栈中(因为调用者非常关心函数的返回值),所有在当前函数操作数栈中的其他元素都会被丢弃。如果当前返回的是synchronized方法,那么还会执行一个隐含的monitorexit指令,退出临界区。最后,会丢弃当前方法的整个帧,恢复调用者的帧,并将控制权转交给调用者。代码示例:public float returnFloat() { int i = 10; return i; }对应的字节码如下:操作数栈管理指令这类指令包括如下内容:将一个或两个元素从栈顶弹出,并且直接废弃:pop,pop2;复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup,dup2,dup_×1,dup2_×1,dup_×2,dup2_×2;将栈最顶端的两个slot数值位置交换:swap。Java虚拟机没有提供交换两个64位数据类型( long、doub1e)数值的指令。指令nop,是一个非常特殊的指令,它的字节码为exee。和汇编语言中的nop一样,它表示什么都不做。这条指令一般可用于调试、占位等。这些指令属于通用型,对栈的压入或者弹出无需指明数据类型。说明:不带x的指令是复制栈顶数据并压入栈顶。包括两个指令,dup和dup2。dup的系数代表要复制的Slot个数。dup开头的指令用于复制1个slot的数据。例如1个int或1个reference类型数据dup2开头的指令用于复制2个Slot的数据。例如1个long,或2个int,或1个int+1个float类型数据带_x的指令是复制栈顶数据并插入栈顶以下的某个位置。共有4个指令_dup_×1,dup2_×1,dup_×2,dup2_×2对于带_x的复制插入指令,只要将指令的dup和x的系数相加,结果即为需要插入的位置。因此dup_×1插入位置:1+1=2,即栈顶2个slot下面dup_×2插入位置:1+2=3,即栈顶3个slot下面dup2_×1插入位置:2+1=3,即栈顶3个Slot下面dup2_×2插入位置:2+2=4,即栈顶4个Slot下面pop:将栈顶的1个slot数值出栈。例如1个short类型数值pop2:将栈顶的2个slot数值出栈。例如1个double类型数值,或者2个int类型数值控制转移指令程序流程离不开条件控制,为了支持条件跳转,虚拟机提供了大量字节码指令,大体上可以分为:比较指令条件跳转指令比较条件转指令多条件分支跳转指令无条件跳转指令等比较指令比较指令的作用是比较栈顶两个元素的大,并将比较结果入栈。比较指令有:dcmpg,dcmpl、fcmpg、fcmpl、lcmp。与前面讲解的指令类似,首字符d表示double类型,f表示float,l表示long。对于double和float类型的数字,由于NaN的存在,各有两个版本的比较指令。以float为例,有fcmpg和fcmpl两个指令,它们的区别在于在数字比较时,若遇到NaN值,处理结果不同。当有1个值为NaN时, fcmpg指令会将1压入栈,而fcmpl值会将-1压入栈。指令dcmpl和dcmpg也是类似的,根据其命名可以推测其含义,在此不再赘述。指令lcmp针对long型整数,由于long型整数没有NaN值,故无需准备两套指令。条件跳转指令条件跳转指令通常和比较指令结合使用。在条件跳转指令执行前,一般可以先用比较指令进行栈顶元素的准备,然后进行条件跳转。条件跳转指令有:ifeq,iflt,ifle,ifne,ifgt,ifge,ifnull,ifnonnull。这些指令都接收两个字节的操作数,用于计算跳转的位置(16位符号整数作为当前位置的offset)。它们的统一含义为:弹出栈顶元素,测试它是否满足某一条件,如果满足条件,则跳转到给定位置。指令说明ifeq当栈顶int类型数值等于0时跳转ifne当栈顶int类型数值不等于0时跳转iflt当栈顶int类型数值小于0时跳转ifle当栈顶int类型数值小于等于0时跳转ifgt当栈顶int类型数值大于0时跳转ifge当栈顶int类型数值大于等于0时跳转ifnull为null时跳转ifnonnull不为null时跳转注意:与前面运算规则一致:对于boolean、byte、char、short类型的条件分支比较操作,都是使用int类型的比较指令完成对于long、float、double类型的条件分支比较操作,则会先执行相应类型的比较运算指令,运算指令会返回一个整型值到操作数栈中,随后再执行int类型的条件分支比较操作来完成整个分支跳转由于各类型的比较最终都会转为int类型的比较操作,所以Java虚拟机提供的int类型的条件分支指令是最为丰富。代码示例:public void compare() { int a = 0; if (a == 0) { a = 10; } else { a = 20; } }比较条件跳转指令比较条件跳转指令类似于比较指令和条件跳转指令的结合体,它将比较和跳转两个步骤合二为一。这类指令有:if_icmpeqif_icmpneif_icmpltif_icmpgtif_icmpleif_icmpgeif_acmpeqif_acmpne其中指令助记符加上“if_”后,以字符“i”开头的指令针对int型整数操作(也包括short和byte类型),以字符“a”开头的指令表示对象引用的比较。指令说明if_icmpeq比较栈顶两个int类型数值大小,当前者等于后者时跳转if_icmpne比较栈顶两个int类型数值大小,当前者不等于后者时跳转if_icmplt比较栈顶两个int类型数值大小,当前者小于后者时跳转if_icmple比较栈顶两个int类型数值大小,当前者小于等于后者时跳转if_icmpgt比较栈顶两个int类型数值大小,当前者大于后者时跳转if_icmpge比较栈顶两个int类型数值大小,当前者大于等于后者时跳转if_acmpeq比较栈顶两个引用类型数值,当结果相等时跳转if_acmpne比较栈顶两个引用类型数值,当结果不相等时跳转代码示例:public void compare() { int i = 10; int j = 20; System.out.println(i > j); }public void compare() { Object obj1 = new Object(); Object obj2 = new Object(); // new 指向堆内存地址不一样 System.out.println(obj1 == obj2); //false System.out.println(obj1 != obj2);//true }多条件分支跳转指令多条件分支跳转指令是专为switch-case语句设计的,主要有tableswitch和lookupswitch。从助记符上看,两者都是switch语句的实现,它们的区别:tableswitch要求多个条件分支值是连续的,它内部只存放起始值和终止值,以及若干个跳转偏移量,通过给定的操作数index,可以立即定位到跳转偏移量位置,因此效率比较高指令lookupswitch内部存放着各个离散的case-offset对,每次执行都要搜索全部的case-offset对,找到匹配的case值,并根据对应的offset计算跳转地址,因此效率较低。指令tableswitch的示意图如下图所示。由于tableswitch的case值是连续的,因此只需要记录最低值和最高值,以及每一项对应的offset偏移量,根据给定的index值通过简单的计算即可直接定位到offset。指令lookupswitch处理的是离散的case值,但是出于效率考虑,将case-offset对按照case值大小排序,给定index时,需要查找与index相等的case,获得其offset,如果找不到则跳转到default。指令lookupswitch如下图所示。代码示例:public void switchTest(int select) { int num; switch (select) { case 1: num = 10; break; case 2: num = 20; // break; case 3: num = 30; break; default: num = 40; } }public void switchTest(int select) { int num; switch (select) { case 100: num = 10; break; case 500: num = 20; break; case 200: num = 30; break; default: num = 40; } }无条件跳转指令目前主要的无条件跳转指令为goto。指令goto接收两个字节的操作数,共同组成一个带符号的整数,用于指定指令的偏移量,指令执行的目的就是跳转到偏移量给定的位置处。如果指令偏移量太大,超过双字节的带符号整数的范围,则可以使用指令goto_w,它和goto有相同的作用,但是它接收4个字节的操作数,可以表示更大的地址范围。指令jsr、jsr_w、ret虽然也是无条件跳转的,但主要用于try-finally语句,且已经被虚拟机逐渐废弃。public void whileInt() { int i = 0; while (i < 100) { String s = "Jack"; i++; } }异常处理指令抛出异常指令athrow指令在Java程序中显示抛出异常的操作(throw语句)都是由athrow指令来实现。除了使用throw语句显示抛出异常情况之外,JVM规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。例如,在之前介绍的整数运算时,当除数为零时,虚拟机会在idiv或ldiv指令中抛出ArithmeticException异常。注意:正常情况下,操作数栈的压入弹出都是一条条指令完成的。唯一的例外情况是在抛异常时,Java虚拟机会清除操作数栈上的所有内容,而后将异常实例压入调用者操作数栈上。代码示例:public void throwZero(int i) { if (i == 0) { throw new RuntimeException("参数错误"); } }异常处理与异常表处理异常在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(早期使用jsr、ret指令),而是采用异常表来完成的。异常表如果一个方法定义了一个try-catch或者try-finally的异常处理,就会创建一个异常表。它包含了每个异常处理或者finally块的信息。异常表保存了每个异常处理信息。比如:起始位置·结束位置程序计数器记录的代码处理的偏移地址被捕获的异常类在常量池中的索引当一个异常被抛出时,JVM会在当前的方法里寻找一个匹配的处理,如果没有找到,这个方法会强制结束并弹出当前栈帧,并且异常会重新抛给上层调用的方法(在调用方法栈帧)。如果在所有栈帧弹出前仍然没有找到合适的异常处理,这个线程将终止。如果这个异常在最后一个非守护线程里抛出,将会导致JVM自己终止,比如这个线程是个main线程。不管什么时候抛出异常,如果异常处理最终匹配了所有异常类型,代码就会继续执行。在这种情况下,如果方法结束后没有抛出异常,仍然执行finally块,在return前,它直接跳到finally块来完成目标代码示例:public void tryCatch() { try { File file = new File("hello.txt"); FileInputStream fis = new FileInputStream(file); String info = "hello!"; } catch (FileNotFoundException e) { e.printStackTrace(); } catch (RuntimeException e) { e.printStackTrace(); } }同步控制指令java虚拟机支持两种同步结构:方法级的同步和方法内部一段指令序列的同步,这两种同步都是使用monitor来支持的。方法级的同步方法级的同步:是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法。当调用方法时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否设置。如果设置了,执行线程将先持有同步锁,然后执行方法。最后在方法完成(无论是正常完成还是非正常完成)时释放同步锁在方法执行期间,执行线程持有了同步锁,其他任何线程都无法再获得同一个锁。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的锁将在异常抛到同步方法之外时自动释放。public synchronized void test() {}方法内指定指令序列的同步同步一段指令集序列: 通常是由java中的synchronized语句块来表示的。jvm的指令集有monitorenter和monitorexit两条指令来支持synchronized关键字的语义。当一个线程进入同步代码块时,它使用monitorenter指令请求进入。如果当前对象的监视器计数器为0,则它会被准许进入,若为1,则判断持有当前监视器的线程是否为自己,如果是,则进入,否则进行等待,直到对象的监视器计数器为0,才会被允许进入同步块。当线程退出同步块时,需要使用monitorexit声明退出。在Java虚拟机中,任何对象都有一个监视器与之相关联,用来判断对象是否被锁定,当监视器被持有后,对象处于锁定状态。指令monitorenter和monitorexit在执行时,都需要在操作数栈顶压入对象,之后monitorenter和monitorexit的锁定和释放都是针对这个对象的监视器进行的。下图展示了监视器如何保护临界区代码不同时被多个线程访问,只有当线程4离开临界区后,线程1、2、3才有可能进入。代码示例:private int i = 0; private Object obj = new Object(); public void subtract() { synchronized (obj) { i--; } }
线程核心一:实现多线程的正确姿势实现多线程到底有几种网上有说 2 种,3 种,4 种,6 种等等 🤦♂️我们看 Oracle 官网 API 是怎么描述的。官方描述为两种:继承 Thread 类实现 Runnable 接口有两种方法可以创建新的执行线程。 一种是将一个类声明为 Thread 的子类。 该子类应重写 Thread 类的 run 方法。 然后可以分配并启动子类的实例。public class ThreadTest extends Thread { @Override public void run() { System.out.println("线程执行...."); } public static void main(String[] args) { new ThreadTest().start(); } }创建线程的另一种方法是声明一个实现 Runnable 接口的类。 然后,该类实现 run 方法。 然后可以分配该类的实例,在创建 Thread 时将其作为参数传递并启动。public class RunnableTest implements Runnable{ @Override public void run() { System.out.println("线程执行...."); } public static void main(String[] args) { new Thread(new RunnableTest()).start(); } }两种方式的对比实现 Runnable 接口相对于继承 Thread 类来说,有如下显著的好处:1、适合多个相同程序代码的线程去处理同一资源的情况,把虚拟 CPU(线程)同程序的代码,数据有效的分离,较好地体现了面向对象的设计思想。2、可以避免由于 Java 的单继承特性带来的局限。我们经常碰到这样一种情况,即当我们要将已经继承了某一个类的子类放入多线程中,由于一个类不能同时有两个父类,所以不能用继承 Thread 类的方式,那么,这个类就只能采用实现 Runnable 接口的方式了。3、有利于程序的健壮性,代码能够被多个线程共享,代码与数据是独立的。当多个线程的执行代码来自同一个类的实例时,即称它们共享相同的代码。多个线程操作相同的数据,与它们的代码无关。当线程被构造时,需要的代码和数据通过一个对象作为构造函数实参传递进去,这个对象就是一个实现了 Runnable 接口的类的实例。两种方法的本质区别:通过 Thread 的 run()方法源码我们可以看到如下代码:@Override public void run() { if (target != null) { target.run(); } }如果 target 不等于 null 则,调用 target 的 run 方法,因此我们可以猜到 target 就是 Runnable 对象。/* What will be run. */ private Runnable target;由于我们通过继承 Thread 类的时候已经重写了 run()方法,所以并不会执行这段代码。如果我们是通过实现 Runnable 接口的话,在创建 Thread 对象的时候就通过构造器传入了当前实现 Runnable 接口的对象,所以 target 不等于 null。由此我们可以知道:继承 Thread 类:run()方法整个被重写实现 Runnable 接口:最终调用 target.run()思考:如果同时继承了 Thread 类又实现了 Runnable 会出现什么情况?public static void main(String[] args) { new Thread(() -> { System.out.println("我来自Runnable"); }) { @Override public void run() { System.out.println("我来自Thread"); } }.start(); }我来自Thread简单点一句话来说,就是我们覆盖了 Thread 类的 run(),里面的 target 那几行代码都被我们覆盖了。所以肯定不会执行 Runnable 的 run()方法了。总结准确的讲,创建线程只有一种方式那就是构造 Thread 类,而实现线程的执行单元有两种方式。实现 Runnable 接口的 run()方法,并把 Runnable 实例传给 Thread 类。继承 Thread 类,重写 Thread 的 run()方法。典型错误观点分析线程池创建线程也算是一种新建线程的方式我们通过线程池源码,可以看到底层还是通过 Thread 类来新建一个线程传入了我们的 Runnable。通过 Callable 和 FutureTask 创建线程,也算是一种新建线程的方式就不过过多赘述了,很清楚的可以看到,还是使用到了 Thread 类,和实现 Runnable 接口。无返回值是实现 Runnable 接口,有返回值是实现 callable 接口,所以 callable 是新的实现线程的方式还是通过 Runnable 接口来实现的。典型错误观点总结多线程的实现方式,在代码中写法千变万化,但其本质万变不离其宗。线程核心二:多线程启动的正确姿势start()方法和 run()方法区别是什么?代码演示:public static void main(String[] args) { Runnable runnable = () -> { System.out.println(Thread.currentThread().getName()); }; runnable.run(); new Thread(runnable).start(); }main Thread-0我们可以发现执行了 run()方法是有主线程来执行的,并不是新建了一个线程。run()和 start()的区别可以用一句话概括:单独调用 run()方法,是同步执行;通过 start()调用 run(),是异步执行。start()方法原理解读start()方法含义:启动新线程start()方法调用后,并不意味着该线程立马运行,只是通知 JVM 在一个合适的时间运行该线程。 有可能很长时间都不会运行,比如遇到饥饿的情况。调用 start()的先后顺序并不能决定线程执行的顺序。准备工作首先会让自己处于就绪状态。就绪状态指的是,我已经获取到除了 CPU 以外的其他资源。比如该线程已经设置了上下文,栈,线程状态,以及 PC, 做完准备工作后,线程才可以被 JVM 或者操作系统进一步调度到执行状态。调度到执行状态后,等待获取 CPU 资源,然后才会进入到运行状态,执行 run()方法里面的代码。不能重复调用start()方法不然会出现异常:java.lang.IllegalThreadStateExceptionstart()方法源码解析启动新线程检查线程状态public synchronized void start() { /** * This method is not invoked for the main method thread or "system" * group threads created/set up by the VM. Any new functionality added * to this method in the future may have to also be added to the VM. * * A zero status value corresponds to state "NEW". */ if (threadStatus != 0) throw new IllegalThreadStateException(); /* Notify the group that this thread is about to be started * so that it can be added to the group's list of threads * and the group's unstarted count can be decremented. */ group.add(this); boolean started = false; try { start0(); started = true; } finally { try { if (!started) { group.threadStartFailed(this); } } catch (Throwable ignore) { /* do nothing. If start0 threw a Throwable then it will be passed up the call stack */ } } }我们看到第一行语句就是:if (threadStatus != 0) throw new IllegalThreadStateException();而 threadStatus 初始化就是 0。/* Java thread status for tools, * initialized to indicate thread 'not yet started' */ private volatile int threadStatus = 0;加入线程组调用本地方法 start0()run()方法原理解读在 Thread 类源码中我们之前已经看过了只有三行代码,其实只是一个普普通通的方法。@Override public void run() { if (target != null) { target.run(); } }线程核心三:线程停止、中断的正确姿势如何正确停止线程?使用 interrupt()方法 来通知,而不是强制。interrupt() 字面上是中断的意思,但在 Java 里 Thread.interrupt()方法实际上通过某种方式通知线程,并不会直接中止该线程。因为相对于开发人员,被停止线程的本身更清楚什么时候停止。与其说如何正确停止线程,不如说是如何正确通知线程。代码示例:public class ThreadTest implements Runnable { public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(new ThreadTest()); thread.start(); Thread.sleep(500); thread.interrupt(); } @Override public void run() { int i = 0; while (i <= Integer.MAX_VALUE / 2) { if (i % 20000 == 0) { System.out.println(i); } i++; } System.out.println("任务执行完成"); } }由于打印出来数据特别的多,我就只展示最后一部分输出结果:1073680000 1073700000 1073720000 1073740000 任务执行完成感兴趣的话,可以试一下该段代码,可以发现在 0.5 秒后发起的通知线程中断并没有反应,我们的 run() 方法还是执行到了最后。(执行时间超过 0.5 秒)这样我们也证实了 interrupt () 方法的确是没有立即暂停线程。我们需要在 while 条件里增加一个判断,在每一次循环时候判断是否已经发起通知中断请求。public class ThreadTest implements Runnable { public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(new ThreadTest()); thread.start(); Thread.sleep(500); thread.interrupt(); } @Override public void run() { int i = 0; while (!Thread.currentThread().isInterrupted() && i <= Integer.MAX_VALUE / 2) { if (i % 20000 == 0) { System.out.println(i); } i++; } System.out.println("任务执行完成"); } }运行结果:70900000 70920000 70940000 70960000 任务执行完成我们可以很清楚的看到,1073740000 70960000 两个数值差距非常大,证明的确是在 0.5 秒后就中断了线程。另外一种情况就是在线程睡眠的时候我们通知中断会怎样?上代码:public class ThreadTest { public static void main(String[] args) { Runnable runnable = () -> { try { int i = 0; while (!Thread.currentThread().isInterrupted() && i <= 300) { if (i % 100 == 0) { System.out.println(i); } i++; } Thread.sleep(5000); } catch (InterruptedException e) { System.out.println("线程在睡眠中被吵醒了!"); e.printStackTrace(); } }; Thread thread = new Thread(runnable); thread.start(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } thread.interrupt(); } }运行结果:0 100 200 300 线程在睡眠中被吵醒了! java.lang.InterruptedException: sleep interrupted at java.lang.Thread.sleep(Native Method) at com.suanfa.thread.ThreadTest.lambda$main$0(ThreadTest.java:16) at java.lang.Thread.run(Thread.java:748)通过代码我们可以看到在睡眠中我们进行通知中断的话会报出InterruptedException异常,所以在写代码的过程中要及时处理 InterruptedException 才能正确停止线程。另还有一种情况就是在循环中每次线程都会睡眠的时候我们通知中断会怎样?上代码:public class ThreadTest { public static void main(String[] args) { Runnable runnable = () -> { try { int i = 0; while (!Thread.currentThread().isInterrupted() && i <= 30000) { if (i % 100 == 0) { System.out.println(i); } i++; Thread.sleep(10); } } catch (InterruptedException e) { System.out.println("线程在睡眠中被吵醒了!"); e.printStackTrace(); } }; Thread thread = new Thread(runnable); thread.start(); try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } thread.interrupt(); } }运行结果:0 100 200 300 400 线程在睡眠中被吵醒了! java.lang.InterruptedException: sleep interrupted at java.lang.Thread.sleep(Native Method) at com.suanfa.thread.ThreadTest.lambda$main$0(ThreadTest.java:14) at java.lang.Thread.run(Thread.java:748)结果还是会在 5 秒后抛出了InterruptedException异常。但是我们其实不用在 while 条件中加入!Thread.currentThread().isInterrupted()的判断,因为在通知中断时候,发现线程在 sleep 中的话,也会进行中断。如果循环中包含sleep或者wait等方法则不需要在每次循环中检查是否已经收到中断请求。实际开发中的两种最佳实践第一种:优先选择:传递中断我们先看一段代码:public class ThreadTest { public static void main(String[] args) { Runnable runnable = () -> { while(!Thread.currentThread().isInterrupted()){ System.out.println("执行了while里的代码"); throwInMethod(); } }; Thread thread = new Thread(runnable); thread.start(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } thread.interrupt(); } private static void throwInMethod() { try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } } }这段代码看起来貌似没什么问题。但是请注意一定一定不要在最内层来进行try/catch。否则就会如下结果所示:执行了while里的代码 java.lang.InterruptedException: sleep interrupted at java.lang.Thread.sleep(Native Method) at com.suanfa.thread.ThreadTest.throwInMethod(ThreadTest.java:24) at com.suanfa.thread.ThreadTest.lambda$main$0(ThreadTest.java:9) at java.lang.Thread.run(Thread.java:748) 执行了while里的代码 执行了while里的代码 执行了while里的代码 执行了while里的代码 执行了while里的代码我们发现异常是抛出来了,但是外面的 run()方法依旧在进行 while 循环。并且由于已经抛出了InterruptedException异常,我们的 while 条件中的!Thread.currentThread().isInterrupted()已经被重置了。所以会一直循环下去,稍不注意线程就无法被回收。解决办法:将异常抛给 run() 方法来解决。public class ThreadTest { public static void main(String[] args) { Runnable runnable = () -> { try { while (!Thread.currentThread().isInterrupted()) { System.out.println("执行了while里的代码"); throwInMethod(); } } catch (InterruptedException e) { e.printStackTrace(); } }; Thread thread = new Thread(runnable); thread.start(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } thread.interrupt(); } private static void throwInMethod() throws InterruptedException { Thread.sleep(2000); } }运行结果:执行了while里的代码 java.lang.InterruptedException: sleep interrupted at java.lang.Thread.sleep(Native Method) at com.suanfa.thread.ThreadTest.throwInMethod(ThreadTest.java:27) at com.suanfa.thread.ThreadTest.lambda$main$0(ThreadTest.java:10) at java.lang.Thread.run(Thread.java:748)一定要注意,不要将 try/cath 写在 while 里面!否则还是会无限循环第二种:不想或无法传递:恢复中断这种情况下我们在最内层的方法中 try/catch 了以后一定要在 catch 或者 finally 中重新设置中断即可。public class ThreadTest { public static void main(String[] args) { Runnable runnable = () -> { while (!Thread.currentThread().isInterrupted()) { System.out.println("执行了while里的代码"); throwInMethod(); } }; Thread thread = new Thread(runnable); thread.start(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } thread.interrupt(); } private static void throwInMethod() { try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } finally { Thread.currentThread().interrupt(); } } }运行结果:执行了while里的代码 java.lang.InterruptedException: sleep interrupted at java.lang.Thread.sleep(Native Method) at com.suanfa.thread.ThreadTest.throwInMethod(ThreadTest.java:24) at com.suanfa.thread.ThreadTest.lambda$main$0(ThreadTest.java:9) at java.lang.Thread.run(Thread.java:748)响应中断的方法总结列表Object.wait()Thread.sleep()Thread.join()BlockingQueue.take() / put()Lock.lockInterruptibly()CountDownLatch.await()CyclicBarrier.await()Exchanger.exchange()java.nio.channels.InterruptibleChannel 的相关方法java.nio.channels.Selector 的相关方法错误的停止方法被弃用的stop()、suspend()、resume()方法用volatile设置 boolean 标志位volatile 可以使用但是要分场景:在没有阻塞的时候,可以使用 volatile在有阻塞的情况下,volatile 不再适用停止线程相关重要函数解析判断是否已被中断相关方法static boolean interrupted() :返回当前线程是否已经被中断在 Thread 里中源码我们可以看到他传入了一个 true,这个参数的意思是是否清除中断状态。public static boolean interrupted() { return currentThread().isInterrupted(true); }boolean isInterrupted() :返回当前线程是否已经被中断在 Thread 里中源码我们可以看到他传入了一个 false ,也就是不清除标志位状态。public boolean isInterrupted() { return isInterrupted(false); }Thread.interrupted()的目的对象我们下面代码:public class ThreadTest implements Runnable { public static void main(String[] args) throws InterruptedException { Thread thread01 = new Thread(new ThreadTest()); thread01.start(); Thread.sleep(500); thread01.interrupt(); System.out.println(thread01.isInterrupted()); //true System.out.println(Thread.interrupted()); //false System.out.println(thread01.isInterrupted()); //true } @Override public void run() { while (true){} } }可能有些人会觉得答案不应该是 true true false 吗?重点就是在这句Thread.interrupted(),执行这句话的是 main 线程,所以判断的就是 main 线程的状态,结果为 false,因为并没有清除掉 Thread-0 线程的标志位状态,所以他还是 true。下面例子可以表达的很明确:public class ThreadTest implements Runnable { public static void main(String[] args) throws InterruptedException { Thread thread01 = new Thread(new ThreadTest()); thread01.start(); Thread.sleep(500); System.out.println("在main方法里判断:"+thread01.isInterrupted()); } @Override public void run() { Thread.currentThread().interrupt(); System.out.println("在run方法里判断:"+Thread.currentThread().isInterrupted()); System.out.println("调用清除标志位的判断方法:" + Thread.interrupted()); System.out.println("在run方法里判断:"+Thread.currentThread().isInterrupted()); } }运行结果:在run方法里判断:true 调用清除标志位的判断方法:true 在run方法里判断:false 在main方法里判断:false线程核心四:解释线程声明周期的正确姿势线程一共有六种状态新建状态(New)新创建了一个线程对象,但还没有调用 start()方法。可运行状态(Runnable)在 Java 虚拟机中执行的线程处于此状态。此状态说明了线程已经获得 cpu 的执行权力,但包含两种执行状态:1、Ready:线程可执行,但当前 cpu 被其他正在执行的线程占用,而处于等待中。2、Running:线程可执行,且正在 cpu 中处于执行状态。阻塞状态(Blocked)线程阻塞于锁。无限等待状态(Waiting)进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。限时等待状态(Timed Waiting)线程因调用 sleep 方法处于休眠状态中,规定时间后可醒来,回到可运行状态(Runnable)。终止状态(Terminated)表示该线程已经执行完毕。下面代码先展示一下新建状态、可运行状态、终止状态public class ThreadTest implements Runnable { public static void main(String[] args) { Thread thread = new Thread(new ThreadTest()); System.out.println(thread.getState()); thread.start(); System.out.println(thread.getState()); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(thread.getState()); } @Override public void run() { for (int i = 0; i < 10; i++) { try { Thread.sleep(10); System.out.print(i); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println(Thread.currentThread().getState()); } }运行结果:NEW RUNNABLE 0123456789RUNNABLE TERMINATED可以看到即使在运行中,状态也是 Runnable 而不是 Running。接下来我们看一下限时等待状态、无限等待状态、阻塞状态public class ThreadTest implements Runnable { public static void main(String[] args) { ThreadTest threadTest = new ThreadTest(); Thread thread1 = new Thread(threadTest); Thread thread2 = new Thread(threadTest); thread1.start(); thread2.start(); System.out.println(thread1.getState()); System.out.println(thread2.getState()); try { Thread.sleep(1300); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(thread1.getState()); } @Override public void run() { sync(); } private synchronized void sync() { try { Thread.sleep(1000); wait(); } catch (InterruptedException e) { e.printStackTrace(); } } }运行结果:TIMED_WAITING BLOCKED WAITING一般习惯而言,把 Blocked、Waiting、Timed_waiting 都成为阻塞状态线程核心五:解释 Thread 和 Object 类中线程方法的正确姿势方法概览类方法简介Threadsleep在指定的毫秒数内让当前“正在执行的线程”休眠(暂停执行)Threadjoin等待其它线程执行完毕Threadyield放弃已经获得到的 cpu 资源ThreadcurrentThread获取当前执行线程的引用Threadstart、run启动线程相关Threadinterrtupt中断线程Threadstop、suspend已废弃Objectwait、notify、notifyAll让线程暂停休息和唤醒wait、notify、notifyAll 方法详解wait当调用了 wait()方法后,当前线程进入阻塞阶段,同时会释放锁,直到以下四种情况之一发生时,才会被唤醒。另一个线程调用这个对象的 notify()方法,随机唤醒的正好是当前线程。另一个线程调用这个对象的 notifyAll()。设置了 wait(long timeout)的超时时间,如果传入 0 就是永久等待。线程自身调用了 interrupt()方法。notify、notifyAllnotify 的作用就是唤醒某一个线程(随机唤醒)。notifyAll 的作用就是唤醒所有等待的线程。代码演示 wait 和 notify 的基本用法:/** * 演示wait和notify的基本用法 * 1. 研究代码执行顺序 * 2. 证明wait释放锁 */ public class ThreadTest { public static Object object = new Object(); public static void main(String[] args) { Thread01 thread01 = new Thread01(); thread01.start(); try { Thread.sleep(200); } catch (InterruptedException e) { e.printStackTrace(); } Thread02 thread02 = new Thread02(); thread02.start(); } static class Thread01 extends Thread { @Override public void run() { synchronized (object) { System.out.println(Thread.currentThread().getName() + "开始执行!"); try { object.wait(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "又获得到锁!"); } } } static class Thread02 extends Thread { @Override public void run() { synchronized (object) { System.out.println(Thread.currentThread().getName() + "执行notify()方法!"); object.notify(); } } } }运行结果:Thread-0开始执行! Thread-1执行notify()方法! Thread-0又获得到锁! 代码演示 notifyAll: public class ThreadTest implements Runnable { public static final Object object = new Object(); public static void main(String[] args) { Runnable r = new ThreadTest(); Thread threadA = new Thread(r, "thread01"); Thread threadB = new Thread(r, "thread02"); threadA.start(); threadB.start(); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } Thread thread03 = new Thread(() -> { synchronized (object) { System.out.println(Thread.currentThread().getName() + "开始notifyAll!"); object.notifyAll(); } }, "thread03线程"); thread03.start(); } @Override public void run() { synchronized (object) { System.out.println(Thread.currentThread().getName() + "开始执行!"); try { object.wait(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "又获得到锁!"); } } }运行结果:thread01开始执行! thread02开始执行! thread03线程开始notifyAll! thread02又获得到锁! thread01又获得到锁!由此结果可以知道,先 start 的在 notifyAll 后不一定先获得到锁。需要注意的是,wait只是释放当前锁wait、notify、notifyAll 特点、性质使用之前必须先获取到monitor,也就是获得到锁,不然会抛出IllegalMonitorStateException只能唤醒其中一个属于Object类wait 只是释放当前锁类似功能的Condition实现生产者消费者设计模式public class ProducerConsumer { public static void main(String[] args) { EventStorage eventStorage = new EventStorage(); Producer producer = new Producer(eventStorage); Consumer consumer = new Consumer(eventStorage); new Thread(producer).start(); new Thread(consumer).start(); } } class Producer implements Runnable { private EventStorage storage; public Producer(EventStorage storage) { this.storage = storage; } @Override public void run() { for (int i = 0; i < 20; i++) { storage.put(); } } } class EventStorage { private int maxSize; private LinkedList<Date> storage; public EventStorage() { maxSize = 10; storage = new LinkedList<>(); } public synchronized void take() { while (storage.size() == 0) { try { wait(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("拿到了" + storage.poll() + ",现在仓库还剩下" + storage.size()); notify(); } public synchronized void put() { while (storage.size() == maxSize) { try { wait(); } catch (InterruptedException e) { e.printStackTrace(); } } storage.add(new Date()); System.out.println("仓库目前有" + storage.size() + "个产品了"); notify(); } } class Consumer implements Runnable { private EventStorage storage; public Consumer(EventStorage storage) { this.storage = storage; } @Override public void run() { for (int i = 0; i < 20; i++) { storage.take(); } } }运行结果:仓库目前有1个产品了 仓库目前有2个产品了 仓库目前有3个产品了 仓库目前有4个产品了 仓库目前有5个产品了 仓库目前有6个产品了 仓库目前有7个产品了 仓库目前有8个产品了 仓库目前有9个产品了 仓库目前有10个产品了 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下9 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下8 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下7 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下6 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下5 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下4 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下3 仓库目前有4个产品了 仓库目前有5个产品了 仓库目前有6个产品了 仓库目前有7个产品了 仓库目前有8个产品了 仓库目前有9个产品了 仓库目前有10个产品了 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下9 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下8 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下7 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下6 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下5 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下4 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下3 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下2 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下1 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下0 仓库目前有1个产品了 仓库目前有2个产品了 仓库目前有3个产品了 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下2 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下1 拿到了Mon Sep 14 16:27:28 CST 2020,现在仓库还剩下0wait、notify、notifyAll 常见面试题手写生产着消费者设计模式为什么 wait()需要在同步代码块中使用?如果我们不从同步上下文中调用 wait() 或 notify() 方法,我们将在 Java 中收到 IllegalMonitorStateException。但是为什么呢?比如生产者是两个步骤:count+1;notify();消费者也是两个步骤:检查 count 值;睡眠或者减一;万一这些步骤混杂在一起呢?比如说,初始的时候 count 等于 0,这个时候消费者检查 count 的值,发现 count 小于等于 0 的条件成立;就在这个时候,发生了上下文切换,生产者进来了,噼噼啪啪一顿操作,把两个步骤都执行完了,也就是发出了通知,准备唤醒一个线程。这个时候消费者刚决定睡觉,还没睡呢,所以这个通知就会被丢掉。紧接着,消费者就睡过去了……为什么线程通信方法 wait(),notify()和 notifyAll()被定义在 Object 类中?Wait-notify 机制是在获取对象锁的前提下不同线程间的通信机制。在 Java 中,任意对象都可以当作锁来使用,由于锁对象的任意性,所以这些通信方法需要被定义在 Object 类里。在 java 的内置锁机制中,每个对象都可以成为锁,也就是说每个对象都可以去调用 wait,notify 方法,而 Object 类是所有类的一个父类,把这些方法放在 Object 中,则 java 中的所有对象都可以去调用这些方法了。一个线程可以拥有多个对象锁,wait,notify,notifyAll 跟对象锁之间是有一个绑定关系的,比如你用对象锁 Object 调用的 wait()方法,那么你只能通过 Object.notify()或者 Object.notifyAll()来唤醒这个线程,这样 jvm 很容易就知道应该从哪个对象锁的等待池中去唤醒线程,假如用 Thread.wait(),Thread.notify(),Thread.notifyAll()来调用,虚拟机根本就不知道需要操作的对象锁是哪一个。wait 方法属于 Object 对象,那调用 Thread.wait 会怎样?首先,Thread 是一个普通的对象,但是 Thread 类有点特殊。在线程结束的时候,JVM 会自动调用线程对象的 notifyAll 方法(为了配合 join 方法)。避免在 Thread 对象上使用 wait 方法。sleep 方法详解作用:只想让线程在预期的时间执行,其它时间不要占用 cpu 资源。特点:不释放锁,包括 synchronized,Lock。演示 sleep 不释放锁代码:public class ThreadTest implements Runnable { public static void main(String[] args) { ThreadTest threadTest = new ThreadTest(); new Thread(threadTest).start(); new Thread(threadTest).start(); } @Override public void run() { synchronized (this) { System.out.println(Thread.currentThread().getName() + "开始执行!"); try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "退出同步代码块!"); } } }运行结果:Thread-0开始执行! Thread-0退出同步代码块! Thread-1开始执行! Thread-1退出同步代码块!总结sleep 方法可以让线程进入 Waiting 状态,并且不占用 CPU 资源,但是不释放锁,直到规定时间后再执行,休眠期间如果被中断,会抛出异常并清除中断状态。join 方法详解作用:是等待这个线程结束。用法:t.join()方法阻塞调用此方法的线程进入 TIMED_WAITING 状态,直到线程 t 完成,此线程再继续;代码演示:public class ThreadTest implements Runnable { public static void main(String[] args) throws InterruptedException { ThreadTest threadTest = new ThreadTest(); Thread thread1 = new Thread(threadTest); thread1.start(); Thread thread2 = new Thread(threadTest); thread2.start(); thread1.join(); thread2.join(); System.out.println("主线程执行完毕!"); } @Override public void run() { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+"执行完毕!"); } }运行结果:Thread-0执行完毕! Thread-1执行完毕! 主线程执行完毕!但是如果我们注释掉两行 join()会怎样呢?运行如下: 主线程执行完毕! 这就是 join()的基本用法。在join期间mian线程状态为WAITING.分析 join 源码public final void join() throws InterruptedException { join(0); } public final synchronized void join(long millis) throws InterruptedException { long base = System.currentTimeMillis(); long now = 0; if (millis < 0) { throw new IllegalArgumentException("timeout value is negative"); } if (millis == 0) { while (isAlive()) { wait(0); } } else { while (isAlive()) { long delay = millis - now; if (delay <= 0) { break; } wait(delay); now = System.currentTimeMillis() - base; } } }参数 millis 传递为 0 的意思是无限睡眠时间。第一个 if 就是如果参数小于 0 就抛出异常。如果等于 0 或者不等 0 最终都会进行 wait,而如果 == 0 我们 wait(0)的话,我们知道这样就会无限睡眠。可是我们的代码并没有进行 notify,他是如何醒的呢?其实前面我们已经讲过一次了。在线程结束的时候,JVM 会自动调用线程对象的 notifyAll 方法(为了配合 join 方法)。join 方法 常见面试题join 期间,线程处于哪有线程状态?在 join 期间线程处于WAITING状态。yield 方法详解作用:释放我的 CPU 时间片。需要注意的是,执行 yield()后,线程状态依然是RUNNABLE状态,并不会释放锁,也不会阻塞。可能会在下一秒 CPU 调度又会调度当前线程。定位:JVM 不保证遵循总结:举个例子:一帮人在排队上公交车,轮到 Yield 的时候,他突然说:我不想先上去了,咱们大家来竞赛上公交车。然后所有人就一块冲向公交车。有可能是其他人先上车了,也有可能是 Yield 先上车了。但是线程是有优先级的,优先级越高的人,就一定能第一个上车吗?这是不一定的,优先级高的人仅仅只是第一个上车的概率大了一点而已,最终第一个上车的,也有可能是优先级最低的人。并且所谓的优先级执行,是在大量执行次数中才能体现出来的。线程核心六:了解线程各属性的正确姿势概览属性用途编号(ID)每个线程都有自己的 ID,用于标识不同的线程名称(Name)让用户或者程序员在开发、调试中更容易区分和定位问题是否是守护线程(isDaemon)true 代表【守护线程】,false 代表【用户线程】优先级(Priority)这个属性目的是告诉线程调度器,我们希望那些线程多运行,哪些少运行线程IDid 是自增有小到大,从 0 开始。但是在一开始就进行了自增操作所以是从1开始。线程IDid 是自增有小到大,从 0 开始。但是在一开始就进行了自增操作所以是从1开始。 private static synchronized long nextThreadID() { return ++threadSeqNumber; }代码演示:public class ThreadTest { public static void main(String[] args) { System.out.println("主线程id为:"+Thread.currentThread().getId()); Thread thread = new Thread(); System.out.println("子线程id为:"+thread.getId());运行结果:主线程id为:1 子线程id为:10为什么是 10 呢?其实不难理解,因为在伴随着 JVM 启动的时候也会有一些线程跟着启动,比如:Finalizer 、 Reference Handler 、 Signal Dispatcher 等。线程名称其实就是在一开始的时候线程初始化分配的默认名称,同时调用的 nextThreadNum()方法是被 synchronized 修饰的,并且从 0 开始,不会出现重复的名称。public Thread() { init(null, null, "Thread-" + nextThreadNum(), 0); } private static int threadInitNumber; private static synchronized int nextThreadNum() { return threadInitNumber++; }我们接下来看一下修改名字的源码public final synchronized void setName(String name) { checkAccess(); if (name == null) { throw new NullPointerException("name cannot be null"); } this.name = name; if (threadStatus != 0) { setNativeName(name); } }前面做了检查之后,我们进行 name 赋值,但是线程状态如果是未启动状态下,我们可以修改本地 native 方法去修改线程名称。如果在已经启动阶段,我们只能修改 Java 中的线程名称。守护线程作用:给用户线程提供服务。比如:垃圾收集线程守护线程的三个特性:线程类型默认继承自父线程被谁启动不影响JVM 退出守护线程和普通线程的区别整体无区别唯一区别在于是否能影响 JVM 的退出。只要用户线程执行完毕 JVM 就会关闭退出。线程优先级线程包含 10 个级别,最小为 1,最大为 10,默认为 5。/** * The minimum priority that a thread can have. */ public final static int MIN_PRIORITY = 1; /** * The default priority that is assigned to a thread. */ public final static int NORM_PRIORITY = 5; /** * The maximum priority that a thread can have. */ public final static int MAX_PRIORITY = 10;但我们程序设计不应该依赖优先级。因为不同操作系统不一样。在 win 中只有 7 个级别,而 linux 中没有优先级别。并且优先级会被操作系统改变。总结属性名称用途注意事项编号(ID)标识不同的线程唯一性,不允许被修改名称(Name)定位问题清晰有意义的名字;默认的名称是否是守护线程(isDaemon)守护线程、用户线程二选一;继承父线程;setDaemon()优先级(Priority)相对多运行默认和父线程优先级相等,共有 10 个等级,不应该依赖线程核心七:处理线程异常的正确姿势为什么需要 UncaughtExceptionHandler?因为在子线程中发生的异常并不会影响主线程的运行。public class ThreadTest implements Runnable { public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(new ThreadTest()); thread.start(); int num = 0; TimeUnit.SECONDS.sleep(3); for (int i = 0; i < 1000; i++) { num ++; } System.out.println(num); } @Override public void run() { throw new RuntimeException(); } }运行结果:Exception in thread "Thread-0" java.lang.RuntimeException at com.suanfa.thread.ThreadTest.run(ThreadTest.java:25) at java.lang.Thread.run(Thread.java:748) 1000子线程异常无法用传统方法捕获。public class ThreadTest implements Runnable { public static void main(String[] args) { try { new Thread(new ThreadTest()).start(); new Thread(new ThreadTest()).start(); new Thread(new ThreadTest()).start(); } catch (RuntimeException e) { System.out.println("捕获到了异常"); } } @Override public void run() { throw new RuntimeException(); } }运行结果:Exception in thread "Thread-0" Exception in thread "Thread-1" Exception in thread "Thread-2" java.lang.RuntimeException at com.suanfa.thread.ThreadTest.run(ThreadTest.java:24) at java.lang.Thread.run(Thread.java:748) java.lang.RuntimeException at com.suanfa.thread.ThreadTest.run(ThreadTest.java:24) at java.lang.Thread.run(Thread.java:748) java.lang.RuntimeException at com.suanfa.thread.ThreadTest.run(ThreadTest.java:24) at java.lang.Thread.run(Thread.java:748)我们发现还是会抛出三个异常,因为 try/catch 只处理当前线程也就是主线程的异常,所以会失效。两种解决方案方案一,在每个 run 方法里进行 try/catch(不推荐)方案二,利用 UncaughtExceptionHandler(推荐)UncaughtExceptionHandler 接口在这个接口中只包含一个方法@FunctionalInterface public interface UncaughtExceptionHandler { /** * Method invoked when the given thread terminates due to the * given uncaught exception. * <p>Any exception thrown by this method will be ignored by the * Java Virtual Machine. * @param t the thread * @param e the exception */ void uncaughtException(Thread t, Throwable e); }实现一个简单的异常处理器:public class MyUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler { @Override public void uncaughtException(Thread t, Throwable e) { Logger logger = Logger.getAnonymousLogger(); logger.log(Level.WARNING,"线程异常,已终止"+t.getName(),e); } }public class ThreadTest extends MyUncaughtExceptionHandler implements Runnable { public static void main(String[] args) { Thread.setDefaultUncaughtExceptionHandler(new MyUncaughtExceptionHandler()); new Thread(new ThreadTest()).start(); new Thread(new ThreadTest()).start(); } @Override public void run() { throw new RuntimeException(); } }运行结果:九月 14, 2020 8:33:23 下午 com.suanfa.thread.MyUncaughtExceptionHandler uncaughtException 警告: 线程异常,已终止Thread-0 java.lang.RuntimeException at com.suanfa.thread.ThreadTest.run(ThreadTest.java:20) at java.lang.Thread.run(Thread.java:748) 九月 14, 2020 8:33:23 下午 com.suanfa.thread.MyUncaughtExceptionHandler uncaughtException 警告: 线程异常,已终止Thread-1 java.lang.RuntimeException at com.suanfa.thread.ThreadTest.run(ThreadTest.java:20) at java.lang.Thread.run(Thread.java:748)线程核心八:理解线程安全的正确姿势什么是线程安全《Java Concurrency In Practice》的作者 Brian Goetz 对 “线程安全”有一个比较恰当的定义:“当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行其它的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。”什么情况下会出现线程安全问题,怎么避免?第一种:运行结果出错a++多线程下出现结果错误问题代码演示:public class ThreadTest implements Runnable { static int num = 0; public static void main(String[] args) throws InterruptedException { ThreadTest threadTest = new ThreadTest(); Thread thread = new Thread(threadTest); thread.start(); Thread thread1 = new Thread(threadTest); thread1.start(); thread.join(); thread1.join(); System.out.println(num); } @Override public void run() { for (int i = 0; i < 10000; i++) { num++; } } }运行结果:14876结果可以看到没有达到 20000,随机性十足。简单理解就是我们线程 1 进行加 1 后,但是并没有赋值给 i,这个时候 cpu 调度切换到了线程 2,线程 2 这个时候拿到的 i 还是 1,也进行了加 1,最后进行赋值为 2,而线程 1 也赋值 2。结果就会导致不满 20000 的问题。第二种:死锁死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象。代码演示死锁:public class ThreadTest implements Runnable { static Object o1 = new Object(); static Object o2 = new Object(); int flag = 1; public static void main(String[] args) { ThreadTest threadTest01 = new ThreadTest(); ThreadTest threadTest02 = new ThreadTest(); threadTest02.flag = 2; new Thread(threadTest01).start(); new Thread(threadTest02).start(); } @Override public void run() { if (flag == 1) { synchronized (o1) { System.out.println("我拿到flag = " + flag + "拿到第一把锁!"); try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (o2) { System.out.println("我拿到flag = " + flag + "拿到第二把锁!"); } } } else { synchronized (o2) { System.out.println("我拿到flag = " + flag + "拿到第二把锁!"); try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (o1) { System.out.println("我拿到flag = " + flag + "拿到第一把锁!"); } } } } }运行结果:我们可以发现 flag = 1 的迟迟获得不到第二把锁,flag = 2 的也迟迟获得不到第一把锁。就造成了死锁,相互等待的局面。对象发布和初始化时候的安全问题什么是发布?发布(publish)对象意味着其作用域之外的代码可以访问操作此对象。例如将对象的引用保存到其他代码可以访问的地方,或者在非私有的方法中返回对象的引用,或者将对象的引用传递给其他类的方法。什么是逸出?1.方法返回一个 private 对象public class ThreadTest { private List<Integer> list = new ArrayList<>(); public List<Integer> getList() { return list; } }2.还未完成初始化(构造函数未完全执行完毕)就把对象提供给外界在构造函数中未初始化完毕就 this 赋值隐式逸出--注册监听事件构造函数中运行线程public class ThreadTest { static Test test; public static void main(String[] args) throws InterruptedException { new Thread(() ->{ new Test(); }).start(); TimeUnit.SECONDS.sleep(1); System.out.println(test); } } class Test { private int i; private int j; public Test() { this.i = 1; ThreadTest.test = this; try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } this.j = 2; } @Override public String toString() { return "Test{" + "i=" + i + ", j=" + j + '}'; } }运行结果:Test{i=1, j=0}可以发现并没有初始化完 j。如何避免逸出方法返回一个 private 对象我们返回一个"副本"private List<Integer> list = new ArrayList<>(); public List<Integer> getList() { return new ArrayList<>(list); }还未完成初始化(构造函数未完全执行完毕)就把对象提供给外界导致 this 引用逸出需要满足两个条件:一个是在构造函数中创建内部类(EventListener),另一个是在构造函数中就把这个内部类给发布了出去(source.registerListener)。因此,我们要防止这一类 this 引用逸出的方法就是避免让这两个条件同时出现。也就是说,如果要在构造函数中创建内部类,那么就不能在构造函数中把他发布了,应该在构造函数外发布,即等构造函数执行完初始化工作,再发布内部类。正如如下所示,使用一个私有的构造函数进行初始化和一个公共的工厂方法进行发布。public class ThreadTest { public final int id; public final String name; private final EventListener listener; private ThreadTest() { id = 1; listener = new EventListener() { public void onEvent(Object obj) { System.out.println("id: " + ThreadTest.this.id); System.out.println("name: " + ThreadTest.this.name); } }; name = "Thread"; } public static ThreadTest getInstance(EventSource<EventListener> source) { ThreadTest safe = new ThreadTest(); source.registerListener(safe.listener); return safe; } } class EventSource<E> { public void registerListener(E listener) { } }总结:需要考虑线程安全的情况访问共享的变量或资源所有依赖时序的操作不同的数据之间存在捆绑关系的时候线程核心九:深入浅出 Java 内存模型的正确姿势JVM 内存结构、Java 内存模型、Java 对象模型对别JVM 内存结构:和 Java 虚拟机的运行时区域有关。Java 内存模型:和 Java 的并发编程有关。Java 对象模型:和 Java 对象在虚拟机中的表现形式有关。Java 内存模型是什么Java Memory Model(JMM)是一组规范,需要各个 JVM 的实现来遵守 JMM 规范,以便于开发者可以利用这些规范,更方便地开发多线程程序。如果没有这样的一个 JMM 内存模型来规范,那么很可能经过了不同 JVM 内存模型来规范,那么很可能经过了不同 JVM 的不同规则的重排序后,导致不同的虚拟机上运行的结果不一样。volatile、synchronized、Lock 等原理都是 JMM如果没有 JMM,那就需要我们自己指定书目时候用内存栅栏等,那是相当麻烦的,幸好有 JMM,我们只需要用同步工具和关键字就可以开发并行程序。JMM 最重要的三点:重排序、可见性、原子性。重排序我们看下面代码例子:public class ThreadTest { static int a = 0; static boolean flag = false; public static void main(String[] args) { while (true) { Thread t1 = new Thread(() -> { a = 1; flag = true; }); Thread t2 = new Thread(() -> { if (flag && a == 0) { System.out.println("----------指令重排序------------"); } }); t1.start(); t2.start(); a = 0; flag = false; } } }运行结果:----------指令重排序------------ ----------指令重排序------------我们可以看到在 t1 线程里面代码顺序和我们想的并不一样,在有些情况下执行出来的结果是先给 flag 赋值为 true,但 a 还是 0,所以在线程 2 中判断出来之后打印了指令重排序。由此我们可以很清楚的知道,代码指令并不是严格按照代码语句的顺序执行的。那为什么需要重排序呢?因为这样可以提高处理速度我们看下图没有重排序是什么样子的:先 load 一个 a 然后 set 一个 3 保存,b 也同理,最后 load a 加成 4 保存。但是排序之后就是这个样子:可见性我们看下面代码例子:public class ThreadTest { int i = 0; boolean flag = true; public static void main(String[] args) throws InterruptedException { ThreadTest threadTest = new ThreadTest(); new Thread(() -> { System.out.println("初始状态为:" + threadTest.flag); while (threadTest.flag) { threadTest.i++; } System.out.println(threadTest.i); }).start(); Thread.sleep(3000L); threadTest.flag = false; System.out.println("最后状态为:" + threadTest.flag); } }运行结果:我们明明已经改为了 false,但是并没有打印出来 i 的值,程序也没有停止。主要导致的原因其实就是,主线程中修改了 flag,在子线程中不可见。 一部分原因是 CPU 高速缓存在极短的时间内不可见,另外一点即使 flag 已经同步到了主内存中,但是子线程中还是没有读到 flag。CPU 高速缓存在极短的时间内不可见的,一段时间后还是会同步到主内存中,但是 while 是一个循环不停的从主内存中获取 flag 的值,每次都是 true 这是因为 JVM 和 JIT 优化导致的,方法体中的循环被多次执行,JIT 将循环体中缓存到了方法区,每次运行直接从方法区中读取缓存,而方法区缓存的 flag=true,导致 while 循环不能被终止。其实就是主线程写和子线程读的原因。使用 volatile 关键字public class ThreadTest { int i = 0; volatile boolean flag = true; public static void main(String[] args) throws InterruptedException { ThreadTest threadTest = new ThreadTest(); new Thread(() -> { System.out.println("初始状态为:" + threadTest.flag); while (threadTest.flag) { threadTest.i++; } System.out.println(threadTest.i); }).start(); Thread.sleep(3000L); threadTest.flag = false; System.out.println("最后状态为:" + threadTest.flag); } }运行结果:初始状态为:true 最后状态为:false 1967391405我们加上 volatile 后 i 的值打印了出来,程序也正常的结束了。为什么 volatile 关键可以解决这个问题呢? volatile 如何实现它的语义的呢?禁止缓存:volatile 变量的访问控制符会加上 ACC_VOLATILE对 volatile 变量相关的指令不做重排序JVM 的规范中,文档对 ACC_VOLATILE 的解释:Declared volatile;cannot be cached.(不能够被缓存)加上 volatile 后会强制(flush)将 flag 的值刷入到主内存中,子线程就可以读取到修改后的值每个线程都有自己的工作内存,而可能存在 writer-thread 到写入操作,还没有同步到主内存,reader-thread 会从主内存中读取。volatile 会将写入操作,强制刷入到主内存中。为什么会有可见性问题CPU 有多级缓存,导致读的数据过期高速缓存的容量比主内存小,但是速度仅次于寄存器,所以在 CPU 和主内存之间就多了 Cache 层。线程间的对于共享变量的可见性问题不是直接由多核引起的,而是由多缓存引起的。如果所有的核心都只用一个缓存,那么也就不存在内存可见性问题。每个核心都会将自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入到主存中,所以会导致有些核心读取的值是一个过期的值。JMM 的抽象:主内存和本地内存Java 作为高级语言,屏蔽了这些底层细节,用 JMM 定义了一套读写内存数据的规范,虽然我们不在需要关心以及缓存和二级缓存的问题,但是 JMM 抽象了主内存和本地内存的概念。这里说的本地内存并不是真的是一块给每个线程分配的内存,而是 JMM 的一个抽象。所有的变量都存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝。线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后在同步到主内存中。主内存是多个线程共享的,但线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转换来完成所有但共享变量存在于主内存中,每个线程有自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题。Happens-Before 原则Happens-Before 原则是用来解决可见性问题的:在时间上,动作 A 发生在动作 B 之前,B 保证能看见 A,这就是 Happens-Before。两个操作可以用 Happens-Before 来确定它们的执行顺序:如果一个操作 Happens-Before 于另一个操作,那么我们说第一个操作对于第二个操作是可见的。当程序包含两个没有被 Happens-Before 关系排序的冲突访问时,就存在数据竞争,遵循了这个原则,也就意味着有些代码不能进行重排序,有些数据不能缓存。Happens-Before 的规则有哪些?单线程原则(Happens-Before 并不影响重排序)锁操作(解锁前的所有操作,都对加锁后的可见)volatile(修改数据后会立即刷新到主存,通知其他线程来更新)线程启动(在子线程启动的时候,能获得到主线程之前的语句发生的结果)线程 join(在 join 之后的语句一定能看到 join 之前所有语句发生的结果)传递性:如果 hb(A,B)而且 hb(B,C),那么可以推出 hb(A,C)中断:一个线程被其他线程 interrupt 时,那么检测中断(isInterrupted)或者抛出 InterruptedException 一定能看到。构造方法:对象构造方法的最后一行指令 Happens-Before 于 finalize()方法的第一行指令。工具类的 Happens-Before 原则线程安全的容器,get 一定能看到在此之前的 put 等存入操作CountDownLatchSmaphore(信号量)Future线程池CyclicBarriervolatile 关键字详解volatile 是什么?volatile 是一种同步机制,比 synchronized 或者 Lock 更为轻量级,因为使用 volatile 并不会发生上下文切换等开销很大的行为。如果一个变量被修饰成 volatile,那么 JVM 就知道这个变量可能会被并发修改。开销小,那么响应的能力也小,虽然 volatile 是用来同步的保证线程安全的,但是 volatile 做不到 synchronized 那样的原子保护,volatile 仅在很有限的场景下才能发挥作用。volatile 的适用场合不适用:a++还是直接上代码:public class ThreadTest implements Runnable { volatile int a; public static void main(String[] args) throws InterruptedException { ThreadTest threadTest = new ThreadTest(); Thread thread01 = new Thread(threadTest); Thread thread02 = new Thread(threadTest); thread01.start(); thread02.start(); thread01.join(); thread02.join(); System.out.println("结果为:" + threadTest.a); } @Override public void run() { for (int i = 0; i < 10000; i++) { a++; } } }运行结果:结果为:16639适用场合boolean flag,如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用 volatile 来代替。synchronized 或者代替原子变量,因为赋值资深是有原子性的,而 volatile 又保证了可见性,所以就足以保证线程安全。并不是所有 boolean 都可以,如果不是一个明确的值则会有线程安全问题。boolean flag = true; private void test(){ flag = true; //安全 flag = !flag; //不安全 }作为刷新之前变量的触发器触发器就是充当,之前的操作都是被其他线程可见的,在如下代码中让 b 来充当触发器,当线程 2 读到 b=0 的时候,那么线程 1 的修改肯定是对线程 2 可见的。int a = 1; volatile int b = 2; int abc = 1; int abcd = 1; private void change() { abc = 7; abcd = 70; a = 3; b = 0; } private void print() { if (b == 0) { //当b = 0当时候,可以确保b之前的所有操作都是可见的 System.out.println("b=" + b + " a=" + a); } }volatile 的两点作用可见性:读一个 volatile 变量之前,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个 volatile 属性会立即刷入到主内存中。指令重排序:解决单例双重锁乱序问题volatile 小结volatile 修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如 boolean flag,或者作为触发器,实现轻量级同步。volatile 属性的读写操作都是无锁的,它不能替代 synchronized,因为他没有提供原子性,互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以它是低成本的。volatile 只作用于属性,用 volatile 修饰属性,这样编译器就不会对这个属性做指令重排序。volatile 提供了可见性,任何一个线程对其修改将立马对其他线程可见。volatile 属性不会被线程缓存,始终从主存中读取。volatile 提供了 Happens-Before 保证,对 volatile 变量 v 的写入 Happens-Before 所有其他线程后续对 v 的读操作都是最新的值。volatile 可以使得 long 和 double 的赋值是原子的,后续会讲 long 和 double 的原子性。原子性什么是原子性一系列的操作,要么全部执行成功,要么全部不执行,不会出现执行一半的情况,是不可分割的。volatile 小结Java 中的原子操作有哪些除了 long 和 double 之外的基本类型(int,byte,boolean,short,char,float)的赋值操作所有引用 reference 的赋值操作,不管是 32 位的机器还是 64 位机器java.concurrent.Atomic.* 包中所有类的原子操作long 和 double 的原子性官方文档的描述: 非原子化处理 double 和 long,出于 Java 编程语言存储器模型的目的,对非易失性 long 或 double 值的单个写入被视为两个单独的写入:每个 32 位半写一个。这可能导致线程从一次写入看到 64 位值的前 32 位,而另一次写入看到第二次 32 位的情况。 volatile long 和 double 的写入和读取始终是原子的。对引用的写入和读取始终是原子的,无论它们是实现为 32 位还是 64 位。在 32 位上的 JVM 上 long 和 double 的操作不是原子的,但是在 64 位的 JVM 上是原子的。实际开发中:商用 Java 虚拟机中不会出现。原子操作 + 原子操作 != 原子操作简单地把原子操作组合在一起,并不能保证整体依然具有原子性比如 ATM 机两次取钱是两次独立的原子操作,但是期间有可能银行卡被借走了,被其他线程打断并修改全同步的 HashMap 也不完全安全synchronized 详解synchronized是什么?synchronized 是 Java 中解决并发问题的一种最常用的方法,也是最简单的一种方法。Synchronized 的作用主要有三个:原子性:确保线程互斥的访问同步代码。可见性:保证共享变量的修改能够及时可见,其实是通过 Java 内存模型中的 “对一个变量 unlock 操作之前,必须要同步到主内存中;如果对一个变量进行 lock 操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中 load 操作或 assign 操作初始化变量值” 来保证的。有序性:有效解决重排序问题,即 “一个 unlock 操作先行发生(Happen-Before)于后面对同一个锁的 lock 操作”。从语法上讲,synchronized 可以把任何一个非 null 对象作为"锁",在 HotSpot JVM 实现中,锁有个专门的名字:对象监视器(Object Monitor)。synchronized 总共有三种用法:当 synchronized 作用在实例方法时,监视器锁(monitor)便是对象实例(this)。当 synchronized 作用在静态方法时,监视器锁(monitor)便是对象的 Class 实例,因为 Class 数据存在于永久代,因此静态方法锁相当于该类的一个全局锁。当 synchronized 作用在某一个对象实例时,监视器锁(monitor)便是括号括起来的对象实例。synchronized 能够保证在同一时刻最多只有一个线程执行该段代码,以达到保证并发安全的效果。synchronized 的地位synchronized 是 Java 的关键字,被 Java 语言原生支持。是最基本的互斥同步手段。代码演示:i++问题之前我们已经使用过 volatile 来保证在并发环境下结果正确的情况。我们使用 synchronized 来演示一下。public class ThreadTest implements Runnable { int a; public static void main(String[] args) throws InterruptedException { ThreadTest threadTest = new ThreadTest(); Thread thread01 = new Thread(threadTest); Thread thread02 = new Thread(threadTest); thread01.start(); thread02.start(); thread01.join(); thread02.join(); System.out.println("结果为:" + threadTest.a); } @Override public synchronized void run() { for (int i = 0; i < 10000; i++) { a++; } } }运行结果:结果为:20000在一个线程拿到锁后,另一个线程只能阻塞等待其他线程释放这把锁,释放了之后他们会进行争抢这把锁,而不是有序性。也就是说 synchronized 是非公平锁。synchronized 的两个用法对象锁包括方法锁(默认锁对象为 this 当前实例对象)和同步代码块锁(自己指定锁对象)。i++问题的另一种对象锁写法:@Override public void run() { synchronized (this) { for (int i = 0; i < 10000; i++) { a++; } } }锁某一个对象:Object lock = new Object(); @Override public void run() { synchronized (lock) { for (int i = 0; i < 10000; i++) { a++; } } }类锁指 synchronized 修饰静态的方法或者指定锁为 Class 对象。i++问题的类锁写法(Class 对象):@Override public void run() { synchronized (ThreadTest.class) { for (int i = 0; i < 10000; i++) { a++; } } }i++问题的另一种类锁写法(synchronized 加在 static 方法上):public class ThreadTest implements Runnable { static int a; public static void main(String[] args) throws InterruptedException { ThreadTest threadTest = new ThreadTest(); Thread thread01 = new Thread(threadTest); Thread thread02 = new Thread(threadTest); thread01.start(); thread02.start(); thread01.join(); thread02.join(); System.out.println("结果为:" + ThreadTest.a); } @Override public void run() { method(); } private static synchronized void method() { for (int i = 0; i < 10000; i++) { a++; } } }synchronized 性质性质一:可重入什么是可重入?可重入指的是统一线程的外层函数获得锁之后,内层函数可以直接再次获得该锁。优点:避免死锁。粒度:证明同一个方法是可重入的。public class ThreadTest { int a; public static void main(String[] args) { new ThreadTest().method1(); } private synchronized void method1() { System.out.println(Thread.currentThread().getName()+"进入method1..."); if (a == 0) { a++; method1(); } } }运行结果:main进入method1... main进入method1...因为这些方法输出了相同的线程名称,表明即使递归使用 synchronized 也没有发生死锁,证明其是可重入的。证明可重入的不要求是同一个方法public class ThreadTest { public static void main(String[] args) { new ThreadTest().method1(); } private synchronized void method1() { System.out.println(Thread.currentThread().getName() + "进入method1..."); method2(); } private synchronized void method2() { System.out.println(Thread.currentThread().getName() + "进入method2..."); } }运行结果:main进入method1... main进入method2...证明可重入的不要求是同一个类中的public class ThreadTest extends FatherTest { public static void main(String[] args) { new ThreadTest().method1(); } private synchronized void method1() { System.out.println(Thread.currentThread().getName() + "进入method1..."); method2(); } } class FatherTest { public synchronized void method2() { System.out.println(Thread.currentThread().getName() + "进入父类method2..."); } }运行结果:main进入method1... main进入父类method2...性质二:不可中断什么是不可中断性质?一旦这个锁已经被别人获得了,如果我还想获得,我只能选择等待或者阻塞,直到别的线程释放这个锁,如果别人永远不释放锁,那么我只能永远等下去。加锁和释放锁的原理每一个 java 对象都可以用作 monitor,monitor 被称为内置锁,线程在进入同步代码块之前会自动获取 monitor lock,并且它在退出同步代码块的时候,会自动释放 monitor lock(无论是正常执行返回还是抛出异常退出)。所以获取 monitor lock 的唯一途径就是进入到 Synchronized 所保护的方法中。通过反编译我们可以看到下图字节码:这个时候我们想要的 monitorenter 和 monitorexit 出现了。monitorenter 入口只有一个,但是 monitorexit 的出口有多个,因为程序异常也会执行 monitorexit。可重入原理:加速次数计数器JVM 负责跟踪对象被加锁的次数。线程第一次给对象加锁的时候,计数器变为 1,每当这个相同的线程在此对象上再次获得锁时,计数器会递增。每当任务离开时候,计数器递减,当计数器为 0 的时候,锁被释放。可见性原理在释放锁之前一定会将数据写回主内存一旦一个代码块或者方法被 synchronized 所修饰,那么它执行完毕之后,被锁住的对象所做的任何修改都要在释放之前,从线程内存写回到主内存。也就是说它不会存在线程内存和主内存内容不一致的情况。在获取锁之后一定从主内存中读取数据同样的,线程在进入代码块得到锁之后,被锁定的对象的数据也是直接从主内存中读取出来的,由于上一个线程在释放的时候会把修改好的内容回写到主内存,所以线程从主内存中读取到数据一定是最新的。就是通过这样的原理,synchronized 关键字保证了我们每一次的执行都是可靠的,它保证了可见性。
概述垃圾收集机制是 Java 的招牌能力,极大的提高了开发效率。如今,垃圾收集几乎成为了现代语言的标配,即使经过了如此长时间的发展,Java 的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景都对垃圾收集提出了新的挑战,也是面试的热门考点。什么是垃圾垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。如果不及时对内存中的垃圾进行清理,那么这些垃圾所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其它对象所使用,甚至可能导致内存溢出。为什么需要 GC对于高级语言来说,不进行垃圾回收,内存迟早都会被消耗完。释放没用的独享,垃圾回收也可以清除内存里的记录碎片,碎片整理将所有占用的堆内存到堆的一段,以便JVM将整理出的内存分配给的新的对象。随着应付业务越来越庞大、复杂、用户越来越多,没有GC就不能保证应用程序的正常进行,经常造成 STW 的 GC 又跟不上实际的需求,所以才会不短地尝试对 GC 进行优化。Java 自动内存管理介绍自动内存管理,无需开发人员手动参与内存的分配与回收,降低内存泄漏和内存溢出的风险。自动内存管理机制,将开发人员从繁重的内存管理中释放出来,可以更专注与业务开发。坏处对于 Java 开发人员而言,自动内存管理就像一个黑匣子,如果过度依赖于自动内存管理,那么可能会弱化开发人员在程序中出现内存溢出时定位问题和解决问题的能力。所以了解 JVM 的自动内存分配和内存回收原理就显得非常重要,只有在真正了解 JVM 是如何管理内存后,我们才能够在遇见 OutOfMemoryError 时,快速的根据错误日志定位问题和解决问题。当需要排查各种内存溢出、内存泄露问题时、当垃圾收集成为系统并发量的瓶颈时,我们就必须对"自动化"技术实施必须要的监控和调优。GC 发生的区域垃圾回收器可以对年轻代回收,也可以对老年代回收,甚至是整个堆和方法区的回收。其中 Java堆是垃圾收集器的工作重点。从次数上讲:频繁收集 Young 区较少收集 Old 区基本不收集 Perm 区(元空间)垃圾回收相关算法垃圾标记阶段之引用计数算法垃圾标记阶段:对象存活判断在堆里存放着几乎所有的 Java 对象实例,在 GC 执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象,只有被标记已经死亡的对象,GC 才会执行垃圾回收时,释放掉其所占内存空间,因此这个过程我们可以称为垃圾标记阶段。那么在 JVM 中是如何标记一个死亡对象呢?简单来说,就是当一个对象已经不再被任何的存活对象继续引用时,就可以宣判死亡。判断对象存活一般有两种方式:引用计数算法和可达性分析算法。引用计数算法引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。对于一个对象 A,只要有任何一个对象引用了 A,则 A 的引用计数器就加 1;当引用失效的时候就减 1。只要对象 A 的引用计数器为 0,即表示对象 A 不可能在被使用,则可以进行回收。优点实现简单,垃圾对象便于辨识,判定效率高,回收没有延迟性。缺点需要单独的字段存储计数器,这样的做法增加了存储空间的开销。每次赋值都要更新计数器,增加了时间开销。引用计数器有个严重的问题,即无法处理循环引用的情况。这个问题是致命的,所以导致在 Java 的垃圾回收器中没有使用这类算法。垃圾标记阶段之可达性分析算法相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。这样类型的垃圾收集通常也叫做追踪性垃圾收集(Tracing Garbage Collection)。所谓GC Roots根集合就是一组必须活跃的引用。基本思路:可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。使用可达性分析算法后,内存中的存货对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)。如果目标对象没有任何引用链相连,则说明是不可达的,意思着该对象已经死亡,可以标记为垃圾对象。在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。可以作为 GC Roots 的对象有哪些?虚拟机栈中引用的对象,比如:各个线程被调用的方法中使用到的参数、局部变量等。本地方法栈内引用的对象。方法区中静态属性引用变量。方法区中常量引用的对象,比如:字符串常量池(String Table)里的引用。所有被同步锁 synchronized 持有的对象。Java 虚拟机内部的引用。基本数据类型对象的 Class 对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器。反应 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。对象的 finalization 机制Java 语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。当垃圾回收器发现没有引用指向一个对象,就会调用这个对象的 finalize()方法。finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理工作,比如关闭文件、数据库连接等。不要主动的调用 finalize()方法,应该交给垃圾回收机制来调用。原因:finalize()可能导致对象复活。finalize()执行时间没有保障,完全由 GC 线程决定,极端情况下,若不发生 GC,finalize()就不会被调用。一个糟糕的 finalize()会影响 GC 的性能。由于 finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态。如果从所有的根节点都无法访问到某对象,说明对象已经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是"非死不可"的,这时候它们暂时处于"缓刑"阶段。一个无法触及的对象有可能在某一个条件下"复活"自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:可触及的:从根节点开始,可以到达这个对象。可复活的:对象的所有引用都被释放,但是对象有可能在 finalize()中复活。不可触及的:对象的 finalize()被调用,并且没有复活,那么就会进入不可触及状态,不可触及的对象不可能被复活,因为finalize()只会被调用一次。以上三种状态中,是由于 finalize()方法的存在,进行的区分,只有在对象不可触及时才可以被回收。具体过程判断一个对象是否可以回收,至少要经历两次标记过程:如果对象到 GC Roots 没有引用链,则进行第一次标记。进行筛选,判断此对象是否有必要执行 finalize()方法: ① 如果对象没有重写 finalize()方法,或者 finalize()方法以及被虚拟机调用过, 则虚拟机视为"没有必要执行",当前对象判定为不可触及的。 ② 如果对象重写了 finalize()方法,且还未执行过,那么当前对象会被插入到 F- Queue 队列中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其 finalize()方法执行。 ③ finalize()方法是对象逃脱死亡的最后机会,稍后 GC 会对 F-Queue 队列 中的对象进行第二次标记,如果对象在 finalize()方法终于引用链上的任何一个对 象建立了联系,那么在第二次标记时,对象会被移出"即将回收"的集合,之后,如 果对象再次出现没有引用存在的情况,finalize()方法不会再次被调用,对象会直 接变成不可触及的状态,也就是说,一个对象的 finalize()方法只会被调用一次。代码示例:/** * 测试Object类中finalize()方法,即对象的finalization机制。 */ public class CanReliveObj { public static CanReliveObj obj;//类变量,属于 GC Root //finalize方法只能被调用一次 @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("调用当前类重写的finalize()方法"); obj = this;//当前待回收的对象在finalize()方法中与引用链上的一个对象obj建立了联系。 obj是GC Roots,this是当前对象,也就是要被回收的对象。 } public static void main(String[] args) { try { obj = new CanReliveObj(); // 对象第一次成功拯救自己 obj = null; System.gc();//调用垃圾回收器。第一次GC的时候,会执行finalize方法,在finalize方法中,由于obj指向了this,obj变量是一个GC Root,要被回收的对象与引用链上的对象建立了又联系,所以对象被复活了 System.out.println("第1次 gc"); // 因为Finalizer线程优先级很低,暂停2秒,以等待它 Thread.sleep(2000); if (obj == null) { System.out.println("obj is dead"); } else { System.out.println("obj is still alive"); // 这句会输出 } System.out.println("第2次 gc"); // 下面这段代码与上面的完全相同,但是这次自救却失败了 obj = null; System.gc(); // 第二次调用GC,由于finalize只能被调用一次,所以对象会直接被回收 // 因为Finalizer线程优先级很低,暂停2秒,以等待它 Thread.sleep(2000); if (obj == null) { System.out.println("obj is dead"); // 这句会输出 } else { System.out.println("obj is still alive"); } } catch (InterruptedException e) { e.printStackTrace(); } } }运行结果:第1次 gc 调用当前类重写的finalize()方法 obj is still alive 第2次 gc obj is dead垃圾清除阶段之标记-清除算法概述当成功区分出内存中存活对象和死亡对象后, GC 接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。目前在 JVM 中比较常见的三种垃圾收集算法是标记-清除算法(Mark-Sweep)。复制算法(Copying)。标记-压缩算法(Mark-Compact)。标记-清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,该算法被 J.McCarthy 等人在 1960 年提出并并应用于 Lisp 语言。执行过程当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为 stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。标记: Collector 从引用根节点开始遍历,标记所有被引用的对象,一般是在对象的 Header 中记录为可达对象。清除: Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收。什么是清除?这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放覆盖原有的地址。关于空闲列表:如果内存规整采用指针碰撞的方式进行内存分配如果内存不规整虚拟机需要维护一个列表空闲列表分配缺点效率不算高。在进行 GC 的时候,需要停止整个应用程序,导致用户体验差。这种方式清理出来的空闲内存是不连续的,会产生内存碎片,需要维护一个空闲列表。垃圾清除阶段之复制算法背景为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky 于 1963 年发表了著名的论文,“使用双存储区的 Lisp 语言垃圾收集器 CA LISP Garbage Collector Algorithm Using Serial Secondary Storage)”。M.L.Minsky 在该论文中描述的算法被人们称为复制(Copying)算法,它也被 M.L.Minsky 本人成功地引入到了 Lisp 语言的一个实现版本中。核心思想将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。优点没有标记和清除过程,实现简单,运行高效。复制过去以后保证空间的连续性,不会出现“碎片”问题。缺点此算法的缺点也是很明显的,就是需要两倍的内存空间。对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销也不小。注意如果系统中的存活对象很多,那么复制算法的效率就会大打折扣。理想状态下需要复制的存活对象数量并不会太大,或者说非常低才行。在新生代,对常规应用的垃圾回收,一次通常可以回收 70% - 99% 的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。垃圾清除阶段之标记-压缩算法背景复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。标记一清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以 JvM 的设计者需要在此基础之上进行改进。标记-压缩(Mark-Compact)算法由此诞生。1970 年前后,G.L.Steele、C.J.Chene 和 D.s.Wise 等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本。执行过程第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象。第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间。标记-清除和标记-压缩的区别标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。优点消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可。消除了复制算法当中,内存减半的高额代价。缺点从效率上来说,标记-整理算法要低于复制算法。移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。移动过程中,需要全程暂停用户应用程序。即:STW。小结标记清除标记压缩复制速率中等最慢最快空间开销少(但会堆积碎片)少(不堆积碎片)通常需要活对象的 2 倍空间(不堆积碎片)移动对象否是是效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段。分代收集算法前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。目前几乎所有的GC都采用分代收集算法执行垃圾回收的。在 HotSpot 中,基于分代的概念,GC 所使用的内存回收算法必须结合年轻代和老年代各自的特点。年轻代(Young Gen)年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过 hotspot 中的两个 survivor 的设计得到缓解。老年代(Tenured Gen)老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。Mark 阶段的开销与存活对象的数量成正比。Sweep 阶段的开销与所管理区域的大小成正相关。Compact 阶段的开销与存活对象的数据成正比。以 HotSpot 中的 CMS 回收器为例,CMS 是基于 Mark-Sweep 实现的,对于对象的回收效率很高。而对于碎片问题,CMS 采用基于 Mark-Compact 算法的 Serial old 回收器作为补偿措施:当内存回收不佳(碎片导致的 Concurrent Mode Failure 时),将采用 serial old 执行 FullGC 以达到对老年代内存的整理。分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代.增量收集算法概述上述现有的算法,在垃圾回收过程中,应用软件将处于一种stop the world的状态。在 stop the world 状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting)算法的诞生。如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。缺点使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。分区算法一般来说,在相同条件下,堆空间越大,一次 Gc 时所需要的时间就越长,有关 GC 产生的停顿也越长。为了更好地控制 GC 产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次 GC 所产生的停顿。分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。 每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。垃圾回收相关概念System.gc()的理解在默认情况下,通过 system.gc()或者 Runtime.getRuntime().gc() 的调用,会显式触发FullGC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。然而 system.gc() 调用附带一个免责声明,无法保证对垃圾收集器的调用。(不能确保立即生效)JVM 实现者可以通过 system.gc() 调用来决定 JVM 的 GC 行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用 System.gc()。代码演示:public class SystemGCTest { public static void main(String[] args) { new SystemGCTest(); // 提醒JVM进行垃圾回收 System.gc(); //强制调用使用引用的对象的finalize()方法 //System.runFinalization(); } @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("执行了 finalize()方法"); } }运行结果不一定会触发销毁的方法,调用 System.runFinalization()会强制调用 失去引用对象的 finalize()。手动 GC 来理解不可达对象的回收public class LocalVarGC { /** * 触发Minor GC没有回收对象,然后在触发Full GC将该对象存入old区 */ public void localvarGC1() { byte[] buffer = new byte[10*1024*1024]; System.gc(); } /** * 触发YoungGC的时候,已经被回收了 */ public void localvarGC2() { byte[] buffer = new byte[10*1024*1024]; buffer = null; System.gc(); } /** * 不会被回收,因为它还存放在局部变量表索引为1的槽中 */ public void localvarGC3() { { byte[] buffer = new byte[10*1024*1024]; } System.gc(); } /** * 会被回收,因为它还存放在局部变量表索引为1的槽中,但是后面定义的value把这个槽给替换了 */ public void localvarGC4() { { byte[] buffer = new byte[10*1024*1024]; } int value = 10; System.gc(); } /** * localvarGC5中的数组已经被回收 */ public void localvarGC5() { localvarGC1(); System.gc(); } public static void main(String[] args) { LocalVarGC localVarGC = new LocalVarGC(); localVarGC.localvarGC3(); } }内存溢出(OOM)内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。由于 GC 一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现 OOM 的情况。大多数情况下,GC 会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的 Full GC 操作,这时候会回收大量的内存,供应用程序继续使用。javadoc 中对 OutOfMemoryError 的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。首先说没有空闲内存的情况:说明 Java 虚拟机的堆内存不够。原因如下:Java 虚拟机的堆内存设置不够。比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定 JVM 堆大小或者指定数值偏小。我们可以通过参数-Xms 、-Xmx 来调整。代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)对于老版本的 oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似 intern 字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:“java.lang.OutOfMemoryError:PermGen space"。随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 OOM 有所改观,出现 OOM 的异常信息则变成了:“java.lang.OutOfMemoryError:Metaspace"。直接内存不足,也会导致 OOM。这里面隐含着一层意思是,在抛出 OutofMemoryError 之前,通常垃圾收集器会被触发,尽其所能去清理出空间。例如:在引用机制分析中,涉及到 JVM 会去尝试回收软引用指向的对象等。在 java.nio.BIts.reserveMemory()方法中,我们能清楚的看到,System.gc()会被调用,以清理空间。当然,也不是在任何情况下垃圾收集器都会被触发的。比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM 可以判断出垃圾收集并不能解决这个问题,所以直接抛出 OutOfMemoryError。内存泄露(Memory Leak)严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏。但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致 00M,也可以叫做宽泛意义上的“内存泄漏”。尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现 OutOfMemoryError,导致程序崩溃。举例单例模式单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。一些提供 close 的资源未关闭导致内存泄漏数据库连接(dataSourse.getConnection() ),网络连接(socket)和 io 连接必须手动 close,否则是不能被回收的。Stop-The-WorldStop-The-World,简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW。可达性分析算法中枚举根节点(GC Roots)会导致所有 Java 执行线程停顿。分析工作必须在一个能确保一致性的快照中进行。一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上。如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。被 STW 中断的应用程序线程会在完成 GC 之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少 STW 的发生。STW 事件和采用哪款 GC 无关,所有的 GC 都有这个事件。哪怕是 G1 也不能完全避免 Stop-the-world 情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。STW 是 JVM 在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。开发中不要用 System.gc(); 会导致 Stop-The-World 的发生。垃圾回收的并行与并发并发在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。并发不是真正意义上的“同时进行”,只是 CPU 把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于 CPU 处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。并行当系统有一个以上 CPU 时,当一个 CPU 执行一个进程时,另一个 CPU 可以执行另一个进程,两个进程互不抢占 CPU 资源,可以同时进行,我们称之为并行(Parallel)。其实决定并行的因素不是 CPU 的数量,而是 CPU 的核心数量,比如一个 CPU 多个核也可以并行。适合科学计算,后台处理等弱交互场景。并发和并行对比并发,指的是多个事情,在同一时间段内同时发生了。并行,指的是多个事情,在同一时间点上同时发生了。并发的多个任务之间是互相抢占资源的。并行的多个任务之间是不互相抢占资源的。只有在多 CPU 或者一个 CPU 多核的情况中,才会发生并行。否则,看似同时发生的事情,其实都是并发执行的。并发和并行,在谈论垃圾收集器的上下文语境中,它们可以解释如下:并行(Parallel)指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。如 ParNew、Parallel Scavenge、Parallel old;串行(Serial)相较于并行的概念,单线程执行。如果内存不够,则程序暂停,启动 JM 垃圾回收器进行垃圾回收。回收完,再启动程序的线程。并发(Concurrent)指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。>用户程序在继续运行,而垃圾收集程序线程运行于另一个 CPU 上;如:CMS、G1安全点与安全区域安全点(Safepoint)程序执行时并非在所有地方都能停顿下来开始 GC,只有在特定的位置才能停顿下来开始 GC,这些位置称为“安全点(Safepoint)”。Safe Point 的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为 Safe Point,如方法调用、循环跳转和异常跳转等。如何在 cc 发生时,检查所有线程都跑到最近的安全点停顿下来呢?抢先式中断:(目前没有虚拟机采用了)首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。主动式中断:设置一个中断标志,各个线程运行到 Safe Point 的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。(有轮询的机制)。安全区域(Safe Region)Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safepoint。但是,程序“不执行”的时候呢?例如线程处于 sleep 状态或 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全点去中断挂起,JVM 也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 Gc 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safepoint。执行流程:当线程运行到 Safe Region 的代码时,首先标识已经进入了 Safe Relgion,如果这段时间内发生 GC,JVM 会忽略标识为 Safe Region 状态的线程。当线程即将离开 Safe Region 时,会检查 JVM 是否已经完成 GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开 Safe Region 的信号为止;Java 中几种不同引用的概述再谈引用我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。【既偏门又非常高频的面试题】强引用、软引用、弱引用、虚引用有什么区别?具体使用场景是什么? 在 JDK1.2 版之后,Java 对引用的概念进行了扩充,将引用分为:强引用(Strong Reference)软引用(Soft Reference)弱引用(Weak Reference)虚引用(Phantom Reference)这4种引用强度依次逐渐减弱。除强引用外,其他 3 种引用均可以在 java.lang.ref 包中找到它们的身影。如下图,显示了这 3 种引用类型对应的类,开发人员可以在应用程序中直接使用它们。1.强引用(StrongReference)最普遍的一种引用方式,如 String s = "abc",变量 s 就是字符串“abc”的强引用,只要强引用存在,则垃圾回收器就不会回收这个对象。2.软引用(SoftReference)用于描述还有用但非必须的对象,如果内存足够,不回收,如果内存不足,则回收。一般用于实现内存敏感的高速缓存,软引用可以和引用队列 ReferenceQueue 联合使用,如果软引用的对象被垃圾回收,JVM 就会把这个软引用加入到与之关联的引用队列中。3.弱引用(WeakReference)弱引用和软引用大致相同,弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。4.虚引用(PhantomReference)就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。 虚引用主要用来跟踪对象被垃圾回收器回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。强引用(Strong Reference)在 Java 程序中,最常见的引用类型是强引用(普通系统99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型。当在 Java 语言中使用 new 操作符创建一个新的对象,并将其赋值给一个变量的时候,这个变量就成为指向该对象的一个强引用。强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 nu11,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。相对的,软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成Java内存泄漏的主要原因之一。强引用的案例说明StringBuffer str = new StringBuffer("hello world");局部变量 str 指向 stringBuffer 实例所在堆空间,通过 str 可以操作该实例,那么 str 就是 stringBuffer 实例的强引用。对应内存结构:如果此时,再运行一个赋值语句StringBuffer str1 = str;对应的内存结构为:那么我们将 str = null; 则原来堆中的对象也不会被回收,因为还有其它对象指向该区域。总结本例中的两个引用,都是强引用,强引用具备以下特点:强引用可以直接访问目标对象。强引用所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出 OOM 异常,也不会回收强引用所指向对象。强引用可能导致内存泄漏。软引用(Soft Reference)不足即回收软引用是用来描述一些还有用,但非必需的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。第一次回收是不可达的对象。软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列(Reference Queue)。类似弱引用,只不过 Java 虚拟机会尽量让软引用的存活时间长一些,迫不得已才清理。在 JDK1.2 版之后提供了 SoftReference 类来实现软引用// 声明强引用 Object obj = new Object(); // 创建一个软引用 SoftReference<Object> sf = new SoftReference<>(obj); obj = null; //销毁强引用弱引用(Weak Reference)发现即回收弱引用也是用来描述那些非必需对象,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统 GC 时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。在 JDK1.2 版之后提供了 WeakReference 类来实现弱引用// 声明强引用 Object obj = new Object(); // 创建一个弱引用 WeakReference<Object> sf = new WeakReference<>(obj); obj = null; //销毁强引用弱引用对象与软引用对象的最大不同就在于,当 GC 在进行回收时,需要通过算法检查是否回收软引用对象,而对于弱引用对象,GC 总是进行回收。弱引用对象更容易、更快被GC回收。虚引用(Phantom Reference)对象回收跟踪也称为“幽灵引用”或者“幻影引用”,是所有引用类型中最弱的一个。一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。它不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的 get()方法取得对象时,总是 null。为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。虚引用无法获取到我们的数据在 JDK1.2 版之后提供了 PhantomReference 类来实现虚引用。// 声明强引用 Object obj = new Object(); // 声明引用队列 ReferenceQueue phantomQueue = new ReferenceQueue(); // 声明虚引用(还需要传入引用队列) PhantomReference<Object> sf = new PhantomReference<>(obj, phantomQueue); obj = null;我们使用一个案例,来结合虚引用,引用队列,finalize() 方法进行讲解:public class PhantomReferenceTest { // 当前类对象的声明 public static PhantomReferenceTest obj; // 引用队列 static ReferenceQueue<PhantomReferenceTest> phantomQueue = null; @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("调用当前类的finalize方法"); obj = this; } public static void main(String[] args) { Thread thread = new Thread(() -> { while (true) { if (phantomQueue != null) { PhantomReference<PhantomReferenceTest> objt = null; try { objt = (PhantomReference<PhantomReferenceTest>) phantomQueue.remove(); } catch (Exception e) { e.getStackTrace(); } if (objt != null) { System.out.println("追踪垃圾回收过程:PhantomReferenceTest实例被GC了"); } } } }, "t1"); thread.setDaemon(true); thread.start(); phantomQueue = new ReferenceQueue<>(); obj = new PhantomReferenceTest(); // 构造了PhantomReferenceTest对象的虚引用,并指定了引用队列 PhantomReference<PhantomReferenceTest> phantomReference = new PhantomReference<>(obj, phantomQueue); try { System.out.println(phantomReference.get()); // 去除强引用 obj = null; // 第一次进行GC,由于对象可复活,GC无法回收该对象 System.out.println("第一次GC操作"); System.gc(); Thread.sleep(1000); if (obj == null) { System.out.println("obj 是 null"); } else { System.out.println("obj 可用"); } System.out.println("第二次GC操作"); obj = null; System.gc(); Thread.sleep(1000); if (obj == null) { System.out.println("obj 是 null"); } else { System.out.println("obj 可用"); } } catch (Exception e) { e.printStackTrace(); } finally { } } }运行结果:null 第一次GC操作 调用当前类的finalize方法 obj 可用 第二次GC操作 追踪垃圾回收过程:PhantomReferenceTest实例被GC了 obj 是 null终结器引用它用于实现对象的 finalize() 方法,也可以称为终结器引用。无需手动编码,其内部配合引用队列使用。在 GC 时,终结器引用入队。由 Finalizer 线程通过终结器引用找到被引用对象调用它的 finalize()方法,第二次 GC 时才回收被引用的对象。垃圾回收器概述垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的 JVM 来实现。由于 JDK 的版本处于高速迭代过程中,因此 Java 发展至今已经衍生了众多的 GC 版本。从不同角度分析垃圾收集器,可以将 GC 分为不同的类型。垃圾回收器分类按线程数分,可用分为串行垃圾回收器和并行垃圾回收器。串行回收指的是在同一时间段内只允许有一个 CPU 用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。在诸如单 CPU 处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的Client模式下的JVM中。在并发能力比较强的 CPU 上,并行回收器产生的停顿时间要短于串行回收器。和串行回收相反,并行收集可以运用多个 CPU 同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“Stop-The-World”机制。按照工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器。并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。独占式垃圾回收器(Stop-The-World)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。按碎片处理方式分,可分为压缩武垃圾回收器和非压缩式垃圾回收器。压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片,使用指针碰撞进行分配。非压缩式的垃圾回收器不进行这步操作,使用空闲列表进行分配。按工作的内存区间分,又可分为年轻代垃圾回收器和老年代垃圾回收器。评估 GC 的性能指标吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间 = 程序的运行时间 + 内存回收的时间)。垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。收集频率:相对于应用程序的执行,收集操作发生的频率。内存占用:Java 堆区所占的内存大小。快速:一个对象从诞生到被回收所经历的时间。吞吐量、暂停时间、内存占用 这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果。 简单来说,主要抓住两点:吞吐量暂停时间性能指标:吞吐量吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量=运行用户代码时间 /(运行用户代码时间+垃圾收集时间)。比如:虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的。吞吐量优先,意味着在单位时间内,STW 的时间最短:0.2+0.2=0.4性能指标:暂停时间“暂停时间”是指一个时间段内应用程序线程暂停,让 GC 线程执行的状态。例如,GC 期间 100 毫秒的暂停时间意味着在这 100 毫秒期间内没有应用程序线程是活动的。暂停时间优先,意味着尽可能让单次 STW 的时间最短:0.1+0.1 + 0.1+ 0.1+ 0.1=0.5吞吐量 VS 暂停时间高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。直觉上,吞吐量越高程序运行越快。低暂停时间(低延迟)较好因为从最终用户的角度来看不管是 GC 还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序。不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致 GC 需要更长的暂停时间来执行内存回收。相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降。在设计(或使用)GC 算法时,我们必须确定我们的目标:一个 GC 算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。现在标准:在最大吞吐量优先的情况下,降低停顿时间。不同的垃圾回收器概述7 种经典的垃圾收集器串行回收器:Serial、Serial Old并行回收器:ParNew、Parallel Scavenge、Parallel Old并发回收器:CMS、G17 种经典的垃圾收集器与垃圾分代之间的关系新年代收集器:Serial、ParNew、Parallel Scavenge。老年代收集器:Serial Old、Parallel Old、CMS。整堆收集器:G1。垃圾收集器的组合关系两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;其中 Serial Old 作为 CMS 出现"Concurrent Mode Failure"失败的后备预案。(红色虚线)由于维护和兼容性测试的成本,在 JDK 8 时将 Serial+CMS、ParNew+Serial Old 这两个组合声明为废弃(JEP173),并在 JDK9 中完全取消了这些组合的支持(JEP214),即:移除。(绿色虚线)JDK14 中:弃用 Parallel Scavenge 和 Serial Old GC 组合(JEP366)(青色虚线)JDK14 中:删除 CMS 垃圾回收器(JEP363)。为什么要有很多收集器,一个不够吗?因为 Java 的使用场景很多,移动端,服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器。如何查看默认垃圾收集器-XX:+PrintcommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)使用命令行指令:jinfo -flag 相关垃圾回收器参数 进程 IDSerial 回收器:串行回收Serial 收集器是最基本、历史最悠久的垃圾收集器了。JDK1.3 之前回收新生代唯一的选择。Serial 收集器作为 HotSpot 中 Client 模式下的默认新生代垃圾收集器。Serial收集器采用复制算法、串行回收和“Stop-The-World”机制的方式执行内存回收。除了年轻代之外,Serial 收集器还提供用于执行老年代垃圾收集的 Serial Old 收集器。Serial Old收集器也采用了串行回收和“Stop-The-World”机制,只不过内存回收算法使用的是标记-压缩算法。Serial Old 是运行在 Client 模式下默认的老年代垃圾回收器。Serial Old 在 server 模式下主要由两个用途:与新生代的 Parallel Scavenge 配合使用。作为老年代 CMS 收集器的后备垃圾收集方案。这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)。优势:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。运行在 client 模式下的虚拟机是个不错的选择。在用户的桌面应用场景中,可用内存一般不大(几十 MB 至一两百 MB),可以在较短时间内完成垃圾收集(几十 ms 至一百多 ms),只要不频繁发生,使用串行回收器是可以接受的。在 HotSpot 虚拟机中,使用-XX:+UseSerialGC 参数可以指定年轻代和老年代都使用串行收集器。等价于新生代用 Serial GC,且老年代用 Serial old GC。总结这种垃圾收集器大家了解,现在已经不用串行的了。而且在限定单核 cpu 才可以用。现在都不是单核的了。对于交互较强的应用而言,这种垃圾收集器是不能接受的。一般在 Java web 应用程序中是不会采用串行垃圾收集器的。ParNew 回收器:并行回收如果说 serialGC 是年轻代中的单线程垃圾收集器,那么 ParNew 收集器则是 serial 收集器的多线程版本。Par 是 Parallel 的缩写,New:只能处理的是新生代。ParNew 收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew 收集器在年轻代中同样也是采用复制算法、"Stop-The-World"机制。ParNew 是很多 JVM 运行在 Server 模式下新生代的默认垃圾收集器。对于新生代,回收次数频繁,使用并行方式高效。对于老年代,回收次数少,使用串行方式节省资源。(CPU 并行需要切换线程,串行可以省去切换线程的资源)。目前只有 ParNew GC 能与 CMS 收集器配合工作在程序中,开发人员可以通过选项"-XX:+UseParNewGC"手动指定使用 ParNew 收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。-XX:ParallelGCThreads 限制线程数量,默认开启和 CPU 数据相同的线程数。Parallel 回收器:吞吐量优先HotSpot 的年轻代中除了拥有 ParNew 收集器是基于并行回收的以外,Parallel Scavenge 收集器同样也采用了复制算法、并行回收和"Stop The World"机制。那么 Parallel 收集器的出现是否多此一举?和 ParNew 收集器不同,Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。自适应调节策略也是 Parallel Scavenge 与 ParNew 一个重要区别。高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。Parallel 收集器在 JDK1.6 时提供了用于执行老年代垃圾收集的 Parallel Old 收集器,用来代替老年代的 Serial Old 收集器。Parallel Old 收集器采用了标记-压缩算法,但同样也是基于并行回收和"Stop-The-World"机制。在程序吞吐量优先的应用场景中,Parallel 收集器和 Parallel Old 收集器的组合,在 server 模式下的内存回收性能很不错。在 Java8 中,默认是此垃圾收集器。参数配置-XX:+UseParallelGC 手动指定年轻代使用 Parallel 并行收集器执行内存回收任务。-XX:+UseParallelOldGC手动指定老年代使用并行回收收集器。分别适用于新生代和老年代,默认 JDK8 是开启的。上面两个参数,默认开启一个后,另一个也会被开启(互相激活)。-XX:ParallelGCThreads设置年轻代并行收集器的线程数。一般最好与 CPU 数量相等,以避免过多的线程数影响垃圾收集性能。在默认情况下,当 CPU 数量小于 8 个,ParallelGcThreads 的值等于 CPU 数量。当 CPU 数量大于 8 个,ParallelGCThreads 的值等于 3+[5*CPU Count]/8]。-XX:MaxGCPauseMillis设置垃圾收集器最大停顿时间(即 STW 的时间)。单位是毫秒。为了尽可能的把停顿时间控制在 MaxGCPauseMillis 以内,收集器在工作时会调整 Java 堆大小或者其它一些参数。对于用户来说,停顿时间越短体验越好。但是在服务端,我们注重高并发,整体的吞吐量,所以服务器端适合 Parallel ,进行控制。需要谨慎使用该参数。-XX:GCTimeRatio垃圾收集时间占总时间的比例(= 1 / (N + 1))。用于衡量吞吐量的大小。取值范围(0,100)。默认值 99,也就是垃圾回收时间不超过 1%。与前一个-xx:MaxGCPauseMillis 参数有一定矛盾性。暂停时间越长,Radio 参数就容易超过设定的比例。-XX:+UseAda[tiveSizePolicy设置 Parallel Scavenge 收集器具有自适应调节策略在这种模式下,年轻代的大小、Eden 和 Survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMillis),让虚拟机自己完成调优工作。CMS 回收器:低延迟在 JDK1.5 时期,Hotspot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:cMS(Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。CMS 收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。CMS 的垃圾收集算法采用标记-清除算法,并且也会"Stop-The-World"。不幸的是,CMS 作为老年代的收集器,却无法与 JDK1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。在 G1 出现之前,CMS 使用还是非常广泛的。一直到今天,仍然有很多系统使用 CMS GC。CMS 整个过程比之前的收集器要复杂,整个过程分为 4 个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。(涉及 STW 的阶段主要是:初始标记 和 重新标记)初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为"Stop-The-World"机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记GC Roots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程,由于直接关联对象比较小,所以这个阶段速度非常快。并发标记(Concurrent-Mark)阶段:从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。尽管 CMS 收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“Stop-The-World”,只是尽可能地缩短暂停时间。由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在 CMS 工作过程中依然有足够的空间支持应用程序运行。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure” 失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。CMS 收集器的垃圾收集算法采用的是标记清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么 CMS 在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。CMS 为什么不使用标记压缩算法?答案其实很简答,因为当并发清除的时候,用 Compact 整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响。Mark Compact 更适合“Stop The World” 这种场景下使用。优点并发收集低延迟缺点会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发 FullGC。CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。CMS收集器无法处理浮动垃圾。可能出现“Concurrent Mode Failure"失败而导致另一次 Full GC 的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS 将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行 GC 时释放这些之前未被回收的内存空间。参数配置-XX:+UseConcMarkSweepGC 手动指定使用 CMS 收集器执行内存回收任务。开启该参数后会自动将-XX:+UseParNewGC 打开。即:ParNew(Young 区用)+CMS(Old 区用)+Serial Old 的组合。-XX:CMSInitiatingoccupanyFraction 设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。JDK5 及以前版本的默认值为 68,即当老年代的空间使用率达到 68%时,会执行一次 CMS 回收。JDK6 及以上版本默认值为 92% 如果内存增长缓慢,则可以设置一个稍大的值,大的阀值可以有效降低 CMS 的触发 频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程 序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。 因此通过该选项便可以有效降低 Full GC 的执行次数。-XX:+UseCMSCompactAtFullCollection 用于指定在执行完 Full GC 后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。-XX:CMSFullGCsBeforecompaction 设置在执行多少次 Full GC 后对内存空间进行压缩整理。-XX:ParallelCMSThreads 设置 CMS 的线程数量。CMS 默认启动的线程数是(ParallelGCThreads+3)/4,ParallelGCThreads 是年轻代并行收集器的线程数。当 CPU 资源比较紧张时,受到 CMS 收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。小结HotSpot 有这么多的垃圾回收器,那么如果有人问,Serial GC、Parallel GC、Concurrent Mark Sweep GC 这三个 GC 有什么不同呢?请记住以下口令:如果你想要最小化地使用内存和并行开销,请选 Serial GC。如果你想要最大化应用程序的吞吐量,请选 Parallel GC。如果你想要最小化 GC 的中断或停顿时间,请选 CMS GC。G1 回收器:区域分代化既然我们已经有了前面几个强大的 GC,为什么还要发布 Garbage First(G1)?原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有 GC 就不能保证应用程序正常进行,而经常造成 STW 的 GC 又跟不上实际的需求,所以才会不断地尝试对 GC 进行优化。G1(Garbage-First)垃圾回收器是在 Java7 update4 之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。官方给 G1 设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。为什么名字叫 Garbage First(G1)呢?因为 G1 是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的 Region 来表示 Eden、幸存者 0 区,幸存者 1 区,老年代等。G1 GC 有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给 G1 一个名字:垃圾优先(Garbage First)。G1(Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核 CPU 及大容量内存的机器,以极高概率满足 GC 停顿时间的同时,还兼具高吞吐量的性能特征。在 JDK1.7 版本正式启用,移除了 Experimental 的标识,是 JDK9 以后的默认垃圾回收器,取代了 CMS 回收器以及 Parallel+Parallel Old 组合。被 oracle 官方称为“全功能的垃圾收集器”。与此同时,CMS 已经在 JDK9 中被标记为废弃(deprecated)。在 jdk8 中还不是默认的垃圾回收器,需要使用-XX:+UseG1GC 来启用。G1 垃圾收集器的优点与其他 GC 收集器相比,G1 使用了全新的分区算法,其特点如下所示:并行与并发并行性:G1 在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力。此时用户线程 STW。并发性:G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。分代收集从分代上看,G1依然属于分代型垃圾回收器,它会气氛年轻代和老年代,年轻代依然有 Eden 区和 Survivor 区,但从堆结构上看,它不要求整个 Eden 区、年轻代、或者老年代都是连续的,也不再坚持固定大小和固定数量。将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其它收集器,或者工作在年轻代,或者工作在老年代。G1 的分代,已经不是下面这样的了而是如下图这样的一个区域空间整合CMS:“标记-清除” 算法、内存碎片、若干次 GC 后进行一次碎片整理。G1 将内存划分为一个个 region。内存的回收是以 region 作为基本单位的。Region之间使用的是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact)算法,两种算法都可以避免内存碎片,这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发 GC,尤其是当 Java 堆非常大的时候,G1 的优势更加明显。可预测的停顿时间模型(即:软实时 soft real-time)这是 G1 相对于 CMS 的另一大优势,G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。由于分区的原因,G1 可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多。G1 垃圾收集器的缺点相较于 CMS,G1 还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1 无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(overload)都要比 CMS 要高。从经验上来说,在小内存应用上 CMS 的表现大概率会优于 G1,而 G1 在大内存应用上则发挥其优势。平衡点在 6-8GB 之间。G1 参数设置-XX:+UseG1GC:手动指定使用 G1 垃圾收集器执行内存回收任务。-XX:G1HeapRegionSize设置每个 Region 的大小。值是 2 的幂,范围是 1MB 到 32MB 之间,目标是根据最小的 Java 堆大小划分出约 2048 个区域。默认是堆内存的 1/2000。-XX:MaxGCPauseMillis 设置期望达到的最大 Gc 停顿时间指标(JVM 会尽力实现,但不保证达到)。默认值是 200ms。-XX:+ParallelGcThread 设置 STW 工作线程数的值。最多设置为 8。-XX:ConcGCThreads 设置并发标记的线程数。将 n 设置为并行垃圾回收线程数(ParallelGcThreads)的 1/4 左右。-XX:InitiatingHeapoccupancyPercent 设置触发并发 Gc 周期的 Java 堆占用率阈值。超过此值,就触发 GC。默认值是 45。G1 收集器的常见操作步骤G1 的设计原则就是简化 JVM 性能调优,开发人员只需要简单的三步即可完成调优:第一步:开启 G1 垃圾收集器第二步:设置堆的最大内存第三步:设置最大的停顿时间G1 中提供了三种垃圾回收模式:Young GC、Mixed GC 和 Full GC,在不同的条件下被触发。G1 收集器的适用场景面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)最主要的应用是需要低 GC 延迟,并具有大堆的应用程序提供解决方案;如:在堆大小约 6GB 或更大时,可预测的暂停时间可以低于 0.5 秒;(G1 通过每次只清理一部分而不是全部的 Region 的增量式清理来保证每次 GC 停顿时间不会过长)。用来替换掉 JDK1.5 中的 CMS 收集器;在下面的情况时,使用 G1 可能比 CMS 好:超过 50% 的 Java 堆被活动数据占用;对象分配频率或年代提升频率变化很大;GC 停顿时间过长(长于 0.5 至 1 秒)HotSpot 垃圾收集器里,除了 G1 以外,其他的垃圾收集器使用内置的 JVM 线程执行 GC 的多线程操作,而 G1 GC 可以采用应用线程承担后台运行的 GC 工作,即当 JVM 的 GC 线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。分区 Region:化整为零使用 G1 收集器时,它将整个 Java 堆划分成约 2048 个大小相同的独立 Region 块,每个 Region 块大小根据堆空间的实际大小而定,整体被控制在 1MB 到 32MB 之间,且为 2 的 N 次幂,即 1MB,2MB,4MB,8MB,16MB,32MB。可以通过 XX:G1HeapRegionsize 设定。所有的Region大小相同,且在JVM生命周期内不会被改变。虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。通过 Region 的动态分配方式实现逻辑上的连续。一个 region 有可能属于 Eden,Survivor 或者 old/Tenured 内存区域。但是一个 region 只可能属于一个角色。图中的 E 表示该 region 属于 Eden 内存区域,s 表示属于 survivor 内存区域,o 表示属于 Old 内存区域。图中空白的表示未使用的内存空间。G1 垃圾收集器还增加了一种新的内存区域,叫做 Humongous 内存区域,如图中的 H 块。主要用于存储大对象,如果超过 1.5 个 region,就放到 H。设置 H 的原因对于堆中的大对象,默认直接会被分配到老年代,但是如果它是个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1 划分了一个 Humongous 区,它用来专门存放大对象。如果一个 H 区装不下一个大对象,那么 G1 会寻找连续的 H 区来存储。为了能找到连续的H区,有时候不得不启动 Full GC 。G1 的大多数行为都把 H 区作为老年代的一部分来看待。每个 Region 都是通过指针碰撞来分配空间,还可以为每个线程分配 TLAB。G1 垃圾回收器的回收过程G1 GC 的垃圾回收过程主要包括如下三个环节:年轻代 GC(Young GC)老年代并发标记过程(Concurrent Marking)混合回收(Mixed GC)如果需要,单线程、独占式、高强度的 Full GC 还是继续存在的。它针对 GC 的评估失败提供了一种失败保护机制,即强力回收。应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1 的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC 暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。当堆内存使用达到一定值(默认 45%)时,开始老年代并发标记过程。标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC 从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的 G1 回收器和其他 GC 不同,G1 的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的 Region 就可以了。同时,这个老年代 Region 是和年轻代一起被回收的。Remembered Set(记忆集)一个对象被不同区域引用的问题一个 Region 不可能是孤立的,一个 Region 中的对象可能被其他任意 Region 中对象引用,判断对象存活时,是否需要扫描整个 Java 堆才能保证准确?在其他的分代收集器,也存在这样的问题(而 G1 更突出)回收新生代也不得不同时扫描老年代?这样的话会降低 MinorGC 的效率;解决方法:无论 G1 还是其他分代收集器,JVM 都是使用 Remembered Set 来避免全局扫描:每个 Region 都有一个对应的 Remembered Set;每次 Reference 类型数据写操作时,都会产生一个 Write Barrier 暂时中断操作;然后检查将要写入的引用指向的对象是否和该 Reference 类型数据在不同的 Region(其他收集器:检查老年代对象是否引用了新生代对象);如果不同,通过 CardTable 把相关引用信息记录到引用指向对象的所在 Region 对应的 Remembered Set 中;当进行垃圾收集时,在 GC 根节点的枚举范围加入 Remembered Set;就可以保证不进行全局扫描,也不会有遗漏。G1 回收过程一:年轻代 GCJVM 启动时,G1 先准备好 Eden 区,程序在运行过程中不断创建对象到 Eden 区,当 Eden 空间耗尽时,G1 会启动一次年轻代垃圾回收过程。年轻代垃圾回收只会回收Eden区和Survivor区。Young GC 时,首先 G1 停止应用程序的执行(STW),G1 创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代 Eden 区和 Survivor 区所有的内存分段。然后开始如下回收过程:第一阶段,扫描根根是指 static 变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同 RSet 记录的外部引用作为扫描存活对象的入口。第二阶段,更新 RSet处理 dirty card queue 中的 card,更新 RSet。此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用。对于应用程序的引用赋值语句 object.field=object,JVM 会在之前和之后执行特殊的操作以在 dirty card queue 中入队一个保存了对象引用信息的 card。在年轻代回收的时候,G1 会对 dirty card queue 中所有的 card 进行处理,以更新 RSet,保证 RSet 实时准确的反映引用关系。那为什么不在引用赋值语句处直接更新 RSet 呢?这是为了性能的需要,RSet 的处理需要线程同步,开销会很大,使用队列性能会好很多。第三阶段,处理 RSet识别被老年代对象指向的 Eden 中的对象,这些被指向的 Eden 中的对象被认为是存活的对象。第四阶段,复制对象此阶段,对象树被遍历,Eden 区内存段中存活的对象会被复制到 Survivor 区中空的内存分段,Survivor 区内存段中存活的对象如果年龄未达阈值,年龄会加 1,达到阀值会被会被复制到 o1d 区中空的内存分段。如果 Survivor 空间不够,Eden 空间的部分数据会直接晋升到老年代空间。第五阶段,处理引用处理 Soft,Weak,Phantom,Final,JNI Weak 等引用。最终 Eden 空间的数据为空,GC 停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。G1 回收过程二:并发标记过程初始标记阶段标记从根节点直接可达的对象。这个阶段是 STW 的,并且会触发一次年轻代 GC。根区域扫描(Root Region Scanning)G1 GC 扫描 survivor 区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在 Young GC 之前完成。并发标记(Concurrent Marking)在整个堆中进行并发标记(和应用程序并发执行),此过程可能被 Young GC 中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。再次标记(Remark)由于应用程序持续进行,需要修正上一次的标记结果。是 STW 的。G1 中采用了比 CMS 更快的初始快照算法:snapshot-at-the-beginning(SATB)。独占清理(cleanup,STW)计算各个区域的存活对象和 GC 回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是 STW 的。这个阶段并不会实际上去做垃圾的收集。并发清理阶段识别并清理完全空闲的区域。G1 回收过程三: 混合回收当越来越多的对象晋升到老年代 Old Region 时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,该算法并不是一个 Old GC,除了回收整个 Young Region,还会回收一部分的 Old Region。这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些 Old Region 进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是 Mixed GC 并不是 Full GC。并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分 8 次(可以通过-XX:G1MixedGCCountTarget 设置)被回收混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden 区内存分段,Survivor 区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。由于老年代中的内存分段默认分 8 次回收,G1 会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为 65%,意思是垃圾占内存分段比例要达到 65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。混合回收并不一定要进行 8 次。有一个阈值-XX:G1HeapWastePercent,默认值为 10%,意思是允许整个堆内存中有 10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于 10%,则不再进行混合回收。因为 GC 会花费很多的时间但是回收到的内存却很少。G1 回收过程三: Full GCG1 的初衷就是要避免 Full GC 的出现。但是如果上述方式不能正常工作,G1 会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。要避免 Full GC 的发生,一旦发生需要进行调整。什么时候会发生 Full GC 呢?比如堆内存太小,当 G1 在复制存活对象的时候没有空的内存分段可用,则会回退到 Full GC,这种情况可以通过增大内存解决。导致 G1 Full GC 的原因可能有两个:EVacuation 的时候没有足够的 to-space 来存放晋升的对象。并发处理过程完成之前空间耗尽。垃圾回收器总结截止 JDK1.8,一共有 7 款不同的垃圾收集器。每一款的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。GC 发展阶段:Seria l=> Parallel(并行)=> CMS(并发)=> G1 => ZGC不同厂商、不同版本的虚拟机实现差距比较大。HotSpot 虚拟机在 JDK7/8 后所有收集器及组合如下图怎么选择垃圾回收器Java 垃圾收集器的配置对于 JVM 优化来说是一个很重要的选择,选择合适的垃圾收集器可以让 JVM 的性能有一个很大的提升。怎么选择垃圾收集器?优先调整堆的大小让 JVM 自适应完成。如果内存小于 100M,使用串行收集器。如果是单核、单机程序,并且没有停顿时间的要求,串行收集器。如果是多 CPU、需要高吞吐量、允许停顿时间超过 1 秒,选择并行或者 JVM 自己选择。如果是多 CPU、追求低停顿时间,需快速响应(比如延迟不能超过 1 秒,如互联网应用),使用并发收集器。官方推荐 G1,性能高。现在互联网的项目,基本都是使用 G1。最后需要明确一个观点:没有最好的收集器,更没有万能的收集;调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器。GC 日志分析通过阅读 Gc 日志,我们可以了解 Java 虚拟机内存分配与回收策略。 内存分配与垃圾回收的参数列表-XX:+PrintGC 输出 GC 日志。类似:-verbose:gc-XX:+PrintGCDetails 输出 GC 的详细日志-XX:+PrintGCTimestamps 输出 GC 的时间戳(以基准时间的形式)-XX:+PrintGCDatestamps 输出 GC 的时间戳-XX:+PrintHeapAtGC 在进行 GC 的前后打印出堆的信息-Xloggc:../logs/gc.log 日志文件的输出路径-verbose:gc打开 GC 日志:-verbose:gc这个只会显示总的 GC 堆的变化,如下:参数解析:PrintGCDetails打开 GC 日志:-verbose:gc -XX:+PrintGCDetails输入信息如下:参数解析:Allocation Failure 表明本次引起 GC 的原因是因为在年轻代中没有足够的空间能够存储新的数据了。Young GC DetailFull GC DetailGC 回收举例public class GCUseTest { static final Integer _1MB = 1024 * 1024; public static void main(String[] args) { byte [] allocation1, allocation2, allocation3, allocation4; allocation1 = new byte[2 *_1MB]; allocation2 = new byte[2 *_1MB]; allocation3 = new byte[2 *_1MB]; allocation4 = new byte[4 *_1MB]; } }-Xms20m -Xmx20m -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:UseSerialGC首先我们会将 3 个 2M 的数组存放到 Eden 区,然后后面 4M 的数组来了后,将无法存储,因为 Eden 区只剩下 2M 的剩余空间了,那么将会进行一次 Young GC 操作,将原来 Eden 区的内容,存放到 Survivor 区,但是 Survivor 区也存放不下,那么就会直接晋级存入 Old 区。然后我们将 4M 对象存入到 Eden 区中。革命性的 ZGCZGC 与 shenandoah 目标高度相似,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停颇时间限制在十毫秒以内的低延迟。《深入理解 Java 虚拟机》一书中这样定义 ZGC:ZGC 收集器是一款基于 Region 内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器。ZGC 的工作过程可以分为 4 个阶段:并发标记 - 并发预备重分配 - 并发重分配 - 并发重映射 等。ZGC 几乎在所有地方并发执行的,除了初始标记的是 STW 的。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。吞吐量对比:停顿时间对比:JDK14 之前,ZGC 仅 Linux 才支持。尽管许多使用 ZGC 的用户都使用类 Linux 的环境,但在 Windows 和 Mac OS 上,人们也需要 ZGC 进行开发部署和测试。许多桌面应用也可以从 ZGC 中受益。因此,ZGC 特性被移植到了 Windows 和 Mac OS 上。现在 Mac 或 Windows 上也能使用 ZGC 了,示例如下:-XX:+UnlockExperimentalVMOptions-XX:+UseZGC
简介动态规划(英语:Dynamic programming,简称 DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。斐波那契数列学习动态规划之前,我们先了解一下斐波那契数列是什么。斐波那契数列又译为费波拿契数、斐波那契数列、费氏数列、黄金分割数列。用文字来说,就是斐波那契数列由0和1开始,之后的斐波那契数就是由之前的两数相加而得出。首几个斐波那契数是:0,1,1,2,3,5,8,13,21,34,55,89 ...fib(n) = fib(n - 1) + fib(n - 2);从中我们可以发现从第三个数开始的值是前两个值的和。假设我在如下图中得到fib(7)的斐波那契数列,我们可以知道想获得7的斐波那契数列的话7就要获取fib(6) + fib(5) 的值,但fib(6) 又需要获取 fib(5) + fib(4)... 我们会发现有很多重复的计算,导致时间复杂度为O(2n)我们发现左侧已经进行过fib(5)的计算,右侧又有一个fib(5),其实我们完全可以在左侧计算fib(5)的时候将值保存起来,这样我们在右侧直接使用这个值就可以了。如果我们想算fib(6)的话,首先我们知道1 和 2 都等于1,依次相加得出如下:数组 1 2 3 4 5 6 斐波那契数 1 1 2 3 5 8我们从递归改为递推的话,时间复杂度就会变成O(n)。我们先用两个经典的问题来引出动态规划算法。最优打工问题如上图,我们一共有八个任务可以进行打工,对应的红色数字就是打工所赚取的钱,长度就是时间,在同一时间只能做一个任务,我们如何赚取最多的钱?其实在面临一个选择的时候,只有两个选择,选 or 不选我们需要找到一个最优解,如果选择opt(8)我们看看有什么结果。图中我们可以看出来,我们选择第8号任务的话,我们可以赚取4块钱,但是我们就无法再做6、7号任务了,我们只考虑时间紧挨着的任务的最优解,我们就可以在前5个中再选取最优解即可。如果我们不选,很简单,只要从前7个中获取最优解opt(7)。然后我们就要在选和不选中选择赚钱最多的那个就ok了。再举个例子,如果要做第7号任务,4、5、6、8都不能做,我们可以看到紧挨着的只有3号任务所以代码就是: prev(7) = 3 。我们看一下prev函数是怎么计算出来的。如果我们要做1号任务,由于我们只考虑前面的所以如果要做1号任务的话,我们前面就没有任务可以做,所以就是0,以此类推我直接把表格列出来| i | prev(i) || --- | --- || 1 | 0 || 2 | 0 || 3 | 0 || 4 | 1 || 5 | 0 || 6 | 2 || 7 | 3 || 8 | 5 |展开的树图如下:我们在图中发现出现了我们之前所说的重叠的问题。我们需要做的就是保存已经展开过的值,减少不必要的多次展开的问题,通过数组来保存已经有过的值。| 任务编号 | 之前可做任务编号 | 当前任务最大收益 | 做 | 不做 | 所选任务 || --- | --- | --- | --- | --- | --- || i | prev(i) | opt(i) | opt(i) + opt(prev(i)) | opt(i-1) |[i] || 1 | 0 | 5 | 5 | 0 | [1] || 2 | 0 | 5 | 1 | 5 | [1] || 3 | 0 | 8 | 8 | 5 | [3] || 4 | 1 | 9 | 9 | 8 | [1,4] || 5 | 0 | 9 | 6 | 9 | [1,4] || 6 | 2 | 9 | 4 | 9 | [1,4] || 7 | 3 | 10 | 10 | 9 | [3,7] || 8 | 5 | 13 | 13 | 10 | [1,4,8] |这样我们就可以很容易的到最大收益的任务编号。背包问题题目:现在有四个物品,背包总容量为8,背包最多能装下价值为多少的物品?物品编号1234物品体积2345物品价值3456我们用一个表格来展示我们的背包数据。**我们第一行表示背包容量,第一列表示物品的编号。**每个格子表示在当前背包容量的情况下,考虑前n个物品的最佳组合,所能装入的最大价值为多少,就填入方框中。012345678901234首先我们开始填入背包编号和容量,第一行全部是0,因为第一行表示前0个物品的最佳组合,也就是没有物品,所以我们不需要管背包容量有多么大,我们没有物品,所以都是0。第一列也全部是0,因为我们对应的背包容量是0,所以不管有多少物品,我们没有容量,所以都是0。012345678000000000010203040我们继续往右填,物品编号为1,背包容量为1,我们考虑前1个物品在容量为1的情况下,所能装入的最大价值为多少。在每次装入之前我们需要考虑当前物品是否能装入背包通过我们的已知条件,我们知道物品编号为1的物品它的体积为2。他比我们的背包容量还要大,所以我们只有一个选择,就是不装入1号物品。所以我们填入 0 。0123456780000000000100203040我们继续往右填,物品编号为1,背包容量为2,我们的物品体积为2,现在我们就面临两个选择,装?or 不装?我们一步步分析,如果我们选择不装,那我们就和前0号物品所对应最大价值是一样的,也就是 0 。如果我们选择装入1号物品,物品编号为1,背包容量为2,物品的体积也是2,物品的价值为3。我们装入后背包容量 2-2=0,这时候我们的价值为3 。由此我们可以知道,装 = 价值为3 or 不装 = 价值为 0,这时候我们选取最大的那个,也就是装。我们填入值为3。(后续填法一致,就不过多赘述)0123456780000000000100333333320033040我们继续填,现在来到物品编号为2,背包容量为3这个位置,我们发现物品编号为2的体积为3,装入后背包容量 3-3=0,这时候我们的价值为4 。所以我们现在又来到了 装 or 不装 问题,装的话就是4,不装的话就选取前n个物品在此背包的最大价值也就是3,4>3所以我们选择装入。当我们按照此方法以此类推我们可以得到如下表格:012345678000000000010033333332003447777300345789940034578910总结:如果装不下当前物品,那么前n个物品的最佳组合和前n-1个物品的最佳组合是一样的。如果装得下当前物品则有两种情况: 假设1: 装入当前物品,在给当前物品预留了相应空间的情况下,前n-1个物品的最 佳组合加上当前物品的价值就是总价值。 假设2:不装当前物品,那么前n个物品的最佳价值组合,和前n-1个物品的最佳价值 组合是一样的。 然后我们选取假设1和假设2中较大的价值,为当前组合的最大价值。背包问题回溯问题进阶:在背包内总价值最大的情况下,背包内装了哪些物品呢?我们知道背包内最大总价值为10,我们需要查看当前物品编号为4是否放入了背包,进行n-1我们发现物品编号为3 容量为8时价值为9,9 != 10 由此可以知道物品编号4的确放入了背包,物品编号4对应的体积为5,我们找到n-1也就是物品编号为3且背包容量8-5=3的位置,如下图:首先需要知道当前物品有没有放进去,进行n-1发现最大价值是一样的,所以可以知道当前物品没有放进去,进行往上移动,来到背包2的位置,我们还是需要知道当前物品有没有放进去,进行n-1发现最大价值为3 当前最大价值为4,所以知道物品2放入了背包。然后我们进行当前容量减去物品2的容量就是3-3=0,找到物品编号为1,背包容量为0的位置进行判断。而背包容量为0肯定没有放任何东西,所以继续回溯就到了物品编号为0,背包容量为0,回溯结束。题目练习现在有这么一道题 数组如下:arr[1,2,4,1,7,8,3]我们在选择某个数的时候不能选择相邻的两个数,选择1就不能选择2,选择7就不能选择1和8,如何得出选择的是最大的和呢?假设我们选择下标为6的,arr[6]会是怎样的呢?我把流程画出来。这个时候很明显,我们又遇见了之前说的重叠问题。直接上代码第一种递归写法(不推荐)public static void main(String[] args) { int[] arr = new int[]{1, 2, 4, 1, 7, 8, 3}; System.out.println(resOpt(arr, 6)); } private static int resOpt(int[] arr, int i) { if (i == 0) { return arr[0]; } else if (i == 1) { return Math.max(arr[1], arr[0]); } else { int case1 = resOpt(arr, i - 2) + arr[i]; int case2 = resOpt(arr, i - 1); return Math.max(case1, case2); } }结果为:15但是这种写法有个很严重的问题,就是我们没有考虑重叠的问题!效率非常低,时间复杂度为O(2n)。第二种:动态规划public static void main(String[] args) { int[] arr = new int[]{1,2,4,1,7,8,3}; System.out.println(dpOpt(arr)); } private static int dpOpt(int[] arr) { int[] opt = new int[arr.length]; opt[0] = arr[0]; opt[1] = Math.max(arr[1], arr[0]); for (int i = 2; i < arr.length; i++) { int case1 = opt[i - 2] + arr[i]; int case2 = opt[i - 1]; opt[i] = Math.max(case1, case2); } return opt[opt.length - 1]; }题目练习给定一个整数数组 arr 和一个目标值 target,请是否有一组数字加起来等于target,找到返回true,未找到返回false。给定 arr = [3,34,4,12,5,2], target = 9 因为 nums[2] + nums[5] = 4 + 5 = 9 所以返回 true如果我们先选择了 arr[5],我们还是面临两种情况,选 or 不选。选择了arr[5] 也就是之前要有数组加起来是7,7+2=9 , 如果不选,也就是arr[4]之前要等于9。具体还是和背包问题一样,填入表格即可。最终获取最右下角的值,就是是否有匹配到的结果。public static void main(String[] args) { int[] arr = {3, 34, 4, 12, 5, 2}; System.out.println(dpSubSet(arr, 9)); } public static boolean dpSubSet(int[] arr, int target) { boolean[][] dp = new boolean[arr.length + 1][target + 1]; for (int k = 1; k <= target; k++) { //为第一行赋初值 dp[0][k] = false; } for (int k = 1; k <= arr.length; k++) { //为第一列赋初值 dp[k][0] = true; } dp[0][0] = true; for (int i = 1; i <= arr.length; i++) { for (int j = 1; j <= target; j++) { if (arr[i - 1] > j) { dp[i][j] = dp[i - 1][j]; } else { dp[i][j] = dp[i - 1][j] || dp[i - 1][j - arr[i - 1]]; } } } return dp[arr.length][target]; }结果:true
选择排序(Selection sort)1.什么是选择排序选择排序是一种简单直观的排序算法,无论什么数据进去都是 O(n²) 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。2.算法步骤首先在排序序列中找到最小(最大)元素,存放到排序序列的起始位置。再从剩余排序序列中继续寻找最小(最大)元素,然后放到已排序序列的末尾。重复第二步,直到所有元素均排序完毕。3.动图演示4.代码演示public static void main(String[] args) { //初始化数组 int[] arr = {3,1,2,5,4}; for (int i = 0; i < arr.length - 1; i++) { //最小值的下标 int min = i;