首页 > 后端开发相关面经知识点汇总
头像
原来是笑傲君殿下
编辑于 2021-04-26 10:51
+ 关注

后端开发相关面经知识点汇总

以下是个人总结的后端开发面试中最常考察的一些知识点,内容涵盖JAVA基础,数据库,操作系统,计算机网络等,同时一些关键的地方,如hashmap等进行了源码的插入,希望能对大家有所帮助。Fighting!
tips:采用ctrl+F可以快速查找页面中关键字,加速检索哦!

JAVA基础

JAVA基本特性

抽象

​ 现实生活中的事物被抽象成对象,把具有相同属性和行为的对象被抽象成类,再从具有相同属性和行为的类中抽象出父类。

继承

​ 子类和父类之间的继承关系,子类可以获取到父类的属性和方法。

封装

​ 隐藏对象的属性和实现细节,仅仅对外公开接口。

多态

​ java语言允许某个类型的引用变量引用子类的实例,而且可以对这个引用变量进行类型转换。同时还有重写,子类可以对父类的方法进行重写,需要保证返回值一致和对应的方法名一致,同时参数不一致。(泛型也算是多态的一种)

跨平台原理

1、首先javac会将对应的.java源文件编译成对应的.class字节码文件。

2、之后,不同的jvm会将对应的.class字节码文件转换成对应操作系统下的机器码,并交给操作系统执行。

在这里插入图片描述

重写

​ @override,重写是子类对父类的允许访问的方法的实现过程进行重新编写, 形参都不能改变,返回值只能是其派生类。即外壳不变,核心重写!也就是说子类能够根据需要实现父类的方法。

重写规则

  • 参数列表与被重写方法的参数列表必须完全相同。
  • 返回类型与被重写方法的返回类型可以不相同,但是必须是父类返回值的派生类(java5 及更早版本返回类型要一样,java7 及更高版本可以不同)。
  • 访问权限不能比父类中被重写的方法的访问权限更低。例如:如果父类的一个方法被声明为 public,那么在子类中重写该方法就不能声明为 protected。
  • 父类的成员方法只能被它的子类重写。
  • 声明为 final 的方法不能被重写。声明为 static 的方法不能被重写,但是能够被再次声明。
  • 子类和父类在同一个包中,那么子类可以重写父类所有方法,除了声明为 private 和 final 的方法。子类和父类不在同一个包中,那么子类只能够重写父类的声明为 public 和 protected 的非 final 方法。
  • 重写的方法能够抛出任何非强制异常,无论被重写的方法是否抛出异常。但是,重写的方法不能抛出新的强制性异常,或者比被重写方法声明的更广泛的强制性异常,反之则可以。
  • 构造方法不能被重写。
  • 如果不能继承一个类,则不能重写该类的方法。

重载

​ @overload,重载是在一个类里面,方法名字相同,而参数不同。返回类型可以相同也可以不同。每个重载的方法(或者构造函数)都必须有一个独一无二的参数类型列表。最常用的地方就是构造器的重载。

  • 被重载的方法必须改变参数列表(参数个数或类型不一样);
  • 被重载的方法可以改变返回类型;
  • 被重载的方法可以改变访问修饰符;
  • 被重载的方法可以声明新的或更广的检查异常;
  • 方法能够在同一个类中或者在一个子类中被重载。
  • 无法以返回值类型作为重载函数的区分标准。

java创建对象的方法:

1、采用new方法,直接创建。

2、采用反射:

  • Object ----> getClass();

  • 任何数据类型(包括基本数据类型)都有一个“静态”的class属性

  • 通过Class类的静态方法:forName(String className)(常用)

        //第一种方式获取Class对象  
            Student stu1 = new Student();//这一new 产生一个Student对象,一个Class对象。
            Class stuClass = stu1.getClass();//获取Class对象
    
        //第二种方式获取Class对象。
        Student stu2 = new Student();//这一new 产生一个Student对象,一个Class对象。
      Student.class.getName();//采用对应的.class属性获取对象。
    
        //第三种方式获取Class对象。
        String className = "equals.Student";
        Class stuClass3 = Class.forName(className);

3、采用反序列化,借助于ObjectOutputStream将对象保存到文件中。

4、采用.clone()复制实现,

JDK/JRE/JVM

JDK

java development kit 的缩写,意思是JAVA开发工具,其主要包含三部分,

第一部分就是Java运行时环境,JRE

第二部分就是Java的基础类库,这个类库的数量还是非常可观的。

第三部分就是Java的开发工具,它们都是辅助你更好的使用Java的利器。

JRE

​ 其中包含了JAVA虚拟机(JVM),运行时类库(runtime class libraries)和JAVA应用加载器(Java application launcher),这些是运行Java程序的必要组件。

JVM

​ 它是整个java实现跨平台的最核心的部分,所有的java程序会首先被编译为.class的类文件,这种类文件可以在虚拟机上执行,是实现一次编译多处运行的关键。

异常/错误:

Exception

​ 其指的是异常,其是指当程序出现错误后,程序如何处理。具体来说,异常机制提供了程序退出的安全通道。当出现错误后,程序执行的流程发生改变,程序的控制权转移到异常处理器。Exception下主要分为两个大类:RuntimeException(运行时异常)NonRuntimeException(非运行时异常)。对于异常,一般采用try{...}catch{...}finally{...}进行处理。

RuntimeException(运行时异常)包括NullPointerException,ClassCastException(类型转换异常),IndexOutOfBoundsException(越界异常), IllegalArgumentException(非法参数异常),ArrayStoreException(数组存储异常),AruthmeticException(算术异常),BufferOverflowException(缓冲区溢出异常)等;

img

Error

​ 其是错误,对于所有的编译时期的错误以及系统错误都是通过Error抛出的。这些错误表示故障发生于虚拟机自身、或者发生在虚拟机试图执行应用时,如Java虚拟机运行错误(Virtual MachineError)类定义错误(NoClassDefFoundError)内存溢出(OutOfMemoryError)等。

JAVA泛型

泛型是在JDK1.5中引入的,泛型使类型(类和接口)在定义类、接口和方法时成为参数,好处在于:

  • 强化类型安全,由于泛型在编译期进行类型检查,从而保证类型安全,减少运行期的类型转换异常。拒绝类似用Object进行继承从而造成运行时错误的情况。
  • 提高代码复用,泛型能减少重复逻辑,编写更简洁的代码。
  • 类型依赖关系更加明确,接口定义更加优好,增强了代码和文档的易读性。

类型擦除:

类型擦除指的是通过类型参数合并,将泛型类型实例关联到同一份字节码上。编译器只为泛型类型生成一份字节码,并将其实例关联到这份字节码上。类型擦除的关键在于从泛型类型中清除类型参数的相关信息,并且再必要的时候添加类型检查和类型转换的方法。 类型擦除可以简单的理解为将泛型java代码转换为普通java代码,只不过编译器更直接点,将泛型java代码直接转换成普通java字节码

类型擦除的主要过程如下:类型检查----类型擦除----强制类型转换
1、将所有的泛型参数用其最左边界(最顶级的父类型)类型替换。(在泛型类被类型擦除的时候,之前泛型类中的类型参数如果没有指定上限,如<T>则会被转成普通的Object类型,如果指定了上限如<T extends String>,则类型参数就会被替换成类型上限,即String。)
2、移除所有的类型参数。

三、类型上下界

<? extends T>它通过确保类型必须是T的子类来设定类型的上界,

<? super T>它通过确保类型必须是T的父类来设定类型的下界

JAVA基本数据类型

byte,short,Int,boolean,float,double,long,char共八种。其与对应的包装类Byte、Short、Integer、Boolean、Float、Double、Long、Character的转换被称作拆箱装箱

ps: 0.1在java的表示中用二进制是无法整除的,因此会出现1.0-0.9!=0.1的情况。

String:

​ jdk1.8中底层实现是一个Final char[]数组,而且长度固定,在java中有一个方法区保存着一个常量池用来维护对应的String对象。在jdk1.9中,换成了final byte[]数组的形式,同时新加了一个coder的变量,用以判断对应的编码的方式。

StringBuffer:

多线程下的char数组,采用Synchronized进行了互斥访问,保证了多线程下的并发安全。

StringBuilder:

单线程下的char数组,可以插入新的字符,但其非线程安全的。

Object类基本方法:(https://fangjian0423.github.io/2016/03/12/java-Object-method/)

1、wait()/notify()/notifyAll(),主要用于线程通信阻塞和唤醒。;

2、toString(),该方法在打印对象时被调用,将对象信息变为字符串返回,默认输出对象地址。

3、equals(),比较两个对象的地址。

4、hashCode(),该方***返回对象的内存地址(但在java中不是这么实现的),常会和equals方法同时重写,确保相等的两个对象拥有相等的哈希值。hashcode在String类中被重写过:以“abc”为例子,hashcode的计算方式为a*31^2^+b*31^1^+c*31^0^,这里的a,b,c代表对应字母的Ascii码。采用31为幂的原因主要在于,31可以被编译器优化成左移5位后减1,有较高的性能
图片说明

ps:如果重写equals()那么必须对应的重写hashCode()代码,原因在于Object中的hashCode方法,是直接返回对应对象的虚拟地址,由此会产生两个对象所存储的类数据都相同即采用equals的时候结果相同,但是却得不出一样的hashcode(),就会导致差异性。

引用类型

1、强引用

​ 是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它

2、软引用

​ 如果一个对象只具有软引用,则内存空间充足时,垃圾回收器不会回收它;如果内存空间不足了,就会回收这些对象的内存,软引用可以和一个引用队列(ReferenceQueue)联合使用。如果软引用所引用对象被垃圾回收JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中。

 // 软引用
    String str = new String("abc");
    SoftReference<String> softReference = new SoftReference<String>(str);

3、弱引用

软引用的区别在于:只具有弱引用的对象拥有更短暂生命周期

String str = new String("abc");
WeakReference<String> weakReference = new WeakReference<>(str);
str = null;

4、虚引用

​ 顾名思义,就是形同虚设。与其他几种引用都不同,虚引用不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。;

对象引用:

主要的实现方式有两种,分别是句柄引用直接指针

拷贝

浅拷贝

浅拷贝是按位拷贝对象,它会创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值;如果属性是内存地址(引用类型),拷贝的就是内存地址 ,因此如果其中一个对象改变了这个地址,就会影响到另一个对象。

(1) 对于基本数据类型的成员对象,是直接将属性值赋值给新的对象。基础类型的拷贝,其中一个对象修改该值,不会影响另外一个。
(2) 对于引用类型,比如数组或者类对象,浅拷贝只是把内存地址赋值给了成员变量,它们指向了同一内存空间。改变其中一个,会对另外一个也产生影响。

深拷贝

​ 在拷贝引用类型成员变量时,为引用类型的数据成员另辟了一个独立的内存空间,实现真正内容上的拷贝。

(1) 对于基本数据类型的成员对象,所以是直接将属性值赋值给新的对象。基础类型的拷贝,其中一个对象修改该值,不会影响另外一个(和浅拷贝一样)。
(2) 对于引用类型,比如数组或者类对象,深拷贝会新建一个对象空间,然后拷贝里面的内容,所以它们指向了不同的内存空间。改变其中一个,不会对另外一个也产生影响。
(3) 对于有多层对象的,每个对象都需要实现 Cloneable 并重写 clone() 方法,进而实现了对象的串行层层拷贝。
(4) 深拷贝相比于浅拷贝速度较慢并且花销较大。

对象拷贝

​ 是直接将某个对象的内存地址赋值到另一个对象中,从而实现对象的拷贝。

JAVA三大集合类

List集合

ArrayList

​ 其采用数组实现,检索元素的速度较快。扩容是扩大约原本容量的1.5倍。

int newCapacity = oldCapacity + (oldCapacity >> 1);

在扩容后会采用Arrays.copyOf()方法将原数组中的数组复制过来。

  • remove方法
    该方法将被删除位置后的元素向前复制,底层调用的也是System.arrayCopy()方法,复制完成后,将数组元素的最后一个设置为null(因为向前复制一个位置,所以最后位置的元素是重复的),这样就解决了复制重复元素的问题
LinkedList

​ 采用双向链表实现,每个节点为Node有pre和next属性,插入删除的速度较快。

Vector

​ 其是ArrayList的改进版本,其采用Synchronized加锁的方式保证了线程安全。

Map集合

hashmap

​ hashmap采用Node数组+链表的结构(1.7引入了红黑树加快了检索的速度),以key-value的形式存储数据,解决冲突的方式主要是链表法。由于采用了头插法,会产生死循环和数据覆盖等不安全的情况。(在jdk1.8中采用了尾插法,避免了死循环的出现)。get()方法流程大致为: 首先判断当前的key是否为空,如果为空则对应的key=0,否则采用rehash的方式计算对应的index值。再判断当前的数据结构是链表还是红黑树,然后依次采用.equals()比较并得到最后的结果。

       transient Node<K,V>[] table; //Node数组,Node实现了Entry接口,本质上也是Entry.
        transient Set<Map.Entry<K,V>> entrySet;//Entry对象,
        public V get(Object key) {//获取数组对象
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
        final Node<K,V> getNode(int hash, Object key) {//get方法的源代码
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) { //这行计算
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
}

put()方法流程则为:先采用hashCode()计算对应的hashcode值(通过获取内存的值),再通过(hashcode & length-1)得到对应的index值,再按照红黑树或链表的方式加入到对应的index下标的元素中。并在添加后判断是否转换成红黑树以及是否需要扩容,如果需要则进行转换或扩容操作。

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {//putval的主体内容
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
          //如果为空或者长度为0,进行resize()因此可以猜想到resize()是类似构建表的操作。
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)//i=(n-1) & hash 哈希计算,找到对应的Node节点
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))//如果第一个就是对应节点
                e = p;
            else if (p instanceof TreeNode)//如果是红黑树
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {//如果发现当前的节点是空则尾部插入
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                      //否则修改对应的值,细节是能够==负责比较基础数据类型,equals负责比较引用对象类型。
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)//在插入后还会去判断是否可以扩容
            resize();
        afterNodeInsertion(evict);
        return null;
    }    

注意点:每次扩容的大小是2倍(newCap = oldCap << 1),一是因为位运算较快,二是因为在rehash的过程中,哈希函数依赖于数组长度为2的倍数来分散对应的节点位置,进而减少冲突。比如当前的数组长度为4,二进制为100,要插入一个2的hashcode的话,index= 010 & 100 = 0;对hashcode=3的情况 ,index= 011&100 = 0,两个index都为0,就发生了冲突。而如果采用length-1作为哈希过程,结果就会是 index2 = 010 & 011 = 010=2。index3 = 011 & 011=011 = 3,就很好的解决了冲突的问题。(但在jdk1.7中直接采用hashcode进行计算。) 具体的resize的代码如下:

  final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {//如果旧大小已经达到最大则返回
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
              //如果新长度小于最大长度,同时旧长度大于默认长度,则修改对应的阈值
                newThr = oldThr << 1; 
        }
        else if (oldThr > 0) 
            newCap = oldThr;
        else {               // 如果是初始创建的resize()
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {//设置新的阈值,默认max(Integer.MAX_VALUE,容量*负载因子);
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                  //重新哈希的过程
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                      //树节点情况下的重新扩容
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { 
                      //链表情况下的重新扩容
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {//十分巧妙的一步
                              //如果为0则此时不需要移动该元素,因为hash后的位置一致.
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                              //否则会是当前的位置的元素移动一定的距离.
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) { 
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                          //对应于上面的结论.
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

遍历hashmap的两种方式。

//第一种直接获取对应的entry对象 
Iterator<Map.Entry<String, Integer>> entryIterator = map.entrySet().iterator();
     while (entryIterator.hasNext()) {
       Map.Entry<String, Integer> next = entryIterator.next();
       System.out.println("key=" + next.getKey() + " value=" + next.getValue());
     }

//第二种获取key,再通过key去访问对应的对象。iterator采用了快速失败的机制。
 Iterator<String> iterator = map.keySet().iterator();
     while (iterator.hasNext()){
      String key = iterator.next();
      System.out.println("key=" + key + " value=" + map.get(key));
    }

hashmap的一些关键参数:

DEFAULT_INITIAL_CAPICITY = 1<<4; 默认大小为16
MAXIMUM_CAPACITY = 1<< 30; 最大容量
DEFAULT_LOAD_FACTOR = 0.75f; 默认负载因子
TREEIFY_THRESHOLD = 8; 
UNTREEIFY_THRESHOLD = 6; 树化和退化为链表的阈值,不采用7的原因是避免频繁的转换
MIN_TREEIFY_CAPACITY = 64; 链表转化为红黑树时需要的数组大小
threshold 表示扩用的阈值,大小为 数组大小*负载因子
hashtable

hashtable是hashmap的线程安全版本,采用Synchronized的方式进行上锁,性能不如Concurrenthashmap。需要注意⚠️,hashtable和ConcurrentHashmap都不允许插入空键和值,而hashmap则允许插入,因为在java中hashtable和Concurrenthashmap不能使用contains的方式判断当前是否包含这个键值,从而无法判断当前的key是空还是不存在。其判断两个元素是否相同的方式是:

  1. 判断两个对象先按照hashCode()计算出来的哈希值是否相同;
  2. 再采用equals()来判断对应的元素是否相同。
public synchronized V put(K key, V value) {//采用Synchronized进行同步
        if (value == null) {//不允许值为空
            throw new NullPointerException();
        }

        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        @SuppressWarnings("unchecked")
        Entry<K,V> entry = (Entry<K,V>)tab[index];
        for(; entry != null ; entry = entry.next) {
            if ((entry.hash == hash) && entry.key.equals(key)) {//判断相同的方式
                V old = entry.value;
                entry.value = value;
                return old;
            }
        }
        addEntry(hash, key, value, index);
        return null;
    }
concurrenthashmap

​ 其基本的架构同hashmap类似,不同的是在线程安全上,concurrenthashmap采用分段锁的机制。jdk1.7以前,基本结构为Segment+HashEntry+链表的形式,通过对每个Segment进行上锁来保证高并发。

​ 而在jdk1.8中采用Node数组+链表的形式,用Node节点代替了原有的segment结构,简化了结构。在putVal方法中采用CAS+Synchronized来进行上锁的操作,在Node为空或者是第一个的情况下,采用CAS进行修改,而在进入链表后就需要采用Synchronized进行Node加锁的操作。这样做的理由也很简单,因为不安全的情况出现在第一个节点后的链表中,因此在不进入链表的时候采用轻量锁就可以了。Node的定义如下:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;
        Node(int hash, K key, V val) {
            this.hash = hash;
            this.key = key;
            this.val = val;
        }
        Node(int hash, K key, V val, Node<K,V> next) {
            this(hash, key, val);
            this.next = next;
        }
        public final K getKey()     { return key; }
        public final V getValue()   { return val; }
        public final int hashCode() { return key.hashCode() ^ val.hashCode(); }
        public final String toString() {
            return Helpers.mapEntryToString(key, val);
        }
        public final V setValue(V value) {
            throw new UnsupportedOperationException();
        }
        public final boolean equals(Object o) {//重写了Node的equals,因此必须要重写hashcode
            Object k, v, u; Map.Entry<?,?> e;
            return ((o instanceof Map.Entry) &&
                    (k = (e = (Map.Entry<?,?>)o).getKey()) != null &&
                    (v = e.getValue()) != null &&
                    (k == key || k.equals(key)) &&
                    (v == (u = val) || v.equals(u)));
        }
        Node<K,V> find(int h, Object k) {
            Node<K,V> e = this;
            if (k != null) {
                do {
                    K ek;
                    if (e.hash == h &&
                        ((ek = e.key) == k || (ek != null && k.equals(ek))))
                        return e;
                } while ((e = e.next) != null);
            }
            return null;
        }
    }

重要参数:

    private static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量是2^31
    private static final int DEFAULT_CAPACITY = 16;//初始长度为16
Treemap

​ 其底层基于哈希数组+红黑树的结构进行实现,TreeMap的特点在于,你得到的结果是经过排序的。TreeMap是唯一的带有subMap()方法的Map,它可以返回一个子树。hash数组的默认大小是11,当hash数组的容量超过初始容量0.75时,增加的方式是old*2+1

LinkedHashMap

​ 其通过维护一个额外的双向链表保证了迭代顺序。特别地,该迭代顺序可以是插入顺序,也可以是访问顺序。因此,根据链表中元素的顺序可以将LinkedHashMap分为:保持插入顺序的LinkedHashMap 和 保持访问顺序的LinkedHashMap,其中LinkedHashMap的默认实现是按插入顺序排序的。

Set集合

hashset

​ 其属于无序集合其hashset底层与hashmap类似,其保持唯一性的方式是先通过hashCode()比较对应的哈希值是否相同,接着采用equal()方法比较两个对象是否一致。初始容量16,负载因子为0.75,

TreeSet

​ 属于有序集合,其底层与TreeMap一致,采用红黑树进行实现。

LinkedHashSet

​ 按放入顺序有序不重复。

快速失败机制(fail-fast):

在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出 Concurrent Modification Exception。

原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变 modCount 的值。每当迭代器使用 hashNext()/next() 遍历下一个元素之前,都会检测 modCount 变量是否为 expectedmodCount 值,是的话就返回遍历;否则抛出异常,终止遍历。

注意:这里异常的抛出条件是检测到 modCount != expectedmodCount 这个条件。如果集合发生变化时修改 modCount 值刚好又设置为了 expectedmodCount 值,则异常不会抛出。因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的 bug。

场景:java.util 包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)。

安全失败机制(fail—safe)

采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。

原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发 Concurrent Modification Exception。

缺点:基于拷贝内容的优点是避免了 Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。

场景:java.util.concurrent 包下的容器都是安全失败,可以在多线程下并发使用,并发修改。

类加载

​ 我们编写的java文件都是保存着业务逻辑代码。java编译器将 .java 文件编译成扩展名为 .class 的文件。.class 文件中保存着.java文件转换后,虚拟机将要执行的指令。当需要某个类的时候,java虚拟机会加载 .class 文件,并创建对应的class对象,将class文件加载到虚拟机的内存,这个过程被称为类的加载,主要流程如下所示。

在这里插入图片描述
加载

​ 在加载阶段,ClassLoader通过一个类的完全限定名查找此类字节码文件,并将这个字节码文件的静态数据结构转换成对应的运行时数据结构,并利用字节码文件创建一个class对象。加载的时机主要如下:

  • 遇到new、getstatic、putstatic或invokestatic这四个字节码的时候,会进行类的加载。
  • 使用反射方式创建某个类或者接口对象的Class对象,会进行加载。
  • 初始化某个类的子类,如果父类没有加载,则进行加载(双亲委派机制)。
  • 虚拟机启动时,需要指定一个执行的主类进行加载。
  • 使用JDK1.7的新加入的动态语言支持的时候,如果一个MethodHandle实例的最后解析结果为:REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial

实现自定义类加载器主要有两步。

1、继承ClassLoader
2、重写findClass,在findClass里获取类的字节码,并调用ClassLoader中的defineClass方法来加载类,获取class对象。代码如下:

public static class MyClassLoader  extends  ClassLoader{
        @Override
        protected Class<?> findClass(String name) throws ClassNotFoundException {
            byte[] data=null;
            try {
                 data= loadByte(name);
            } catch (IOException e) {
                e.printStackTrace();
            }
            return this.defineClass(data,0,data.length);
        }
        private byte[] loadByte(String name) throws IOException {
            File file = new File("/Users/admin/test/"+name);
            FileInputStream fi = new FileInputStream(file);
            int len = fi.available();
            byte[] b = new byte[len];
            fi.read(b);
            return b;
        }
    }

//待加载的类
public class Demo{
    public void say(){
        System.out.println("hello");
    }
}
MyClassLoader classLoader = new MyClassLoader();
Class clazz = classLoader.loadClass("Demo.class");
Object o = clazz.newInstance();
Method method = clazz.getMethod("say");    //反射
method.invoke(o);
双亲委派

​ 双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应该有自己的父类加载器,但是在双亲委派模式中父子关系采取的并不是继承的关系,而是采用组合关系来复用父类加载器的相关代码。双亲委派模式的好处是:

1、Java类随着它的类加载器一起具备一种带有优先级的层次关系,通过这种层级关系可以避免类的重复加载,当父亲已经加载了该类的时候,就没有必要子类加载器(ClassLoader)再加载一次。

2、是考虑到安全因素,Java核心API中定义类型不会被随意替换。

线程上下文类加载器

​ 在Java应用中存在着很多服务提供者接口,这些接口允许第三方为它们提供实现,如常见的 SPI 有 JDBC、JNDI等,这些 SPI 的接口属于 Java 核心库,一般存在rt.jar包中,由Bootstrap类加载器加载,而 SPI 的第三方实现代码则是作为Java应用所依赖的 jar 包被存放在classpath路径下,由于SPI接口中的代码经常需要加载具体的第三方实现类并调用其相关方法,但SPI的核心接口类是由引导类加载器来加载的,而Bootstrap类加载器无法直接加载SPI的实现类,同时由于双亲委派模式的存在,Bootstrap类加载器也无法反向委托AppClassLoader加载器SPI的实现类。在这种情况下,我们就需要一种特殊的类加载器来加载第三方的类库,而线程上下文类加载器就是很好的选择。

验证

​ 验证的原因在于,字节码是可以伪造的。因此验证目的在于确保class文件的字节信息中包含信息符合当前虚拟机要求,不会危害虚拟机自身的安全,主要包括四种验证:

文件格式验证:主要负责校验当前的.class文件是否符合对应的字节码规范。

元数据校验:主要对字节码中的数据进行语义分析,以确保符合Java的规定。

字节码校验:这个阶段是最复杂的一个阶段,该阶段通过数据流分析和控制流分析,以确保程序的语义符合逻辑。但即使程序经过了字节码校验,也不能保证这个程序就一定是不存在bug的。

符号引用校验:这个阶段发生在将符号引用转换为直接引用的时候发生的。主要校验的内容有通过全限定名是否能找到对应的类,可访问性是否可以访问(private、public等)。

准备

​ 为类中定义的static变量分配内存并且设置该类变量的初始值,该类变量通常会分配在方法区中,这里不包含final修饰的static ,因为final在编译的时候就已经分配了。在jdk1.8后,类变量就会随着Class对象一起存放到JAVA堆中了。这里不会为实例变量分配初始化,类变量会分配在方法区中,实例变量会随着对象分配到Java堆中。(需要注意的是,除了final修饰的变量,其余在这个阶段的静态变量的值是0,而不是对应的初始值。)

解析

​ 这里主要的任务是把常量池中的符号引用替换成直接引用。符号引用指的是以一组符号来描述对应的目标,而直接引用则是指直接指向目标的指针。如果此时对应的引用还没有生成,则也需要进行加载。

初始化

​ 这里是类加载的最后阶段,如果该类具有父类就进行对父类进行初始化,执行其静态初始化器(静态代码块)和静态初始化成员变量。(前面已经对static 初始化了默认值,这里我们对它进行赋值,成员变量也将被初始化)

JAVA多线程

线程安全:代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。

线程的实现方式主要有三种:1、内核线程实现(1:1);2、使用用户线程实现(1:N);3、用户线程+轻量级进程实现(N:M)

线程创建的方法有四种:

1、继承Thread类

(java只允许单类继承,因此一般采用实现的接口的方式会更好。)

class mythread extends Thread{
  @override
  public void run(){...}
}

2、实现Runnable接口

class mythread implements Runnable{
  @override
  public void run(){...}
}

3、实现Callable接口

​ 相较于Runnable接口,Callable接口可以返回值,且可以抛出异常。

class myThread1 implements Callable {
  @Override
  public Object call() throws Exception {...}
}

4、采用线程池进行创建

​ Java通过Executors(jdk1.5的concurrent包中)提供四种线程池,核心创建代码如下。

//1、创建一个长度不限的线程池,如果线程池存在时间超过设定,则可灵活回收空闲线程。
ExecutorService newExecutorService = Executors.newCachedThreadPool();

//2、创建一个固定数量线程池,可控制线程最大并发数,超出的线程会在队列中等待。
ExecutorService newExecutorService = Executors.newFixedThreadPool(3);

//3、创建一个定时长线程池,支持定时及周期性任务执行。
ScheduledExecutorService newScheduledThreadPool = Executors.newScheduledThreadPool(5);

//4、创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
ExecutorService newSingleThreadExecutor = Executors.newSingleThreadExecutor();

//线程池执行代码。当然也可以不采用匿名类的方法,而是采用之前 继承Thread类/实现Runnable接口的线程。
newExecutorService.execute(new Runnable() {
  @Override
  public void run() {...}
});

线程生命周期

线程的生命周期主要是创建(new )、就绪(采用start的时候处于就绪状态)、运行(.run处于运行状态)、等待(.wait进入阻塞状态)、计时等待、死亡,六个状态。

线程同步:==?待补充==

实现线程同步的方法:

1、volatile关键字。

2、lock关键字。

3、synchronized关键字。

4、ThreadLocal关键字,总的来说是为每个线程创建一个变量,从而避免冲突。

5、BlockQueue,阻塞队列。

6、AtomicInteger,原子整型类。

线程池

三大优点:

1、降低资源消耗:通过重复利用现有的线程来执行任务,避免多次创建和销毁线程。

2、提高响应速度:因为省去了创建线程这个步骤,所以在拿到任务时,可以立刻开始执行。

3、提供附加功能:线程池的可拓展性使得我们可以自己加入新的功能,比如说定时、延时来执行某些线程。

wKioL1lg5nGjTm-5AAtWWU6jVhw052.png-wh_50

线程池七大核心参数:

1、核心线程数

​ 指的是线程池核心线程的数目,核心线程不同于普通线程,其完成任务后不会被回收而是等待下一次执行任务。

2、最大线程数

​ 顾名思义,指的是线程池最多允许产生的线程数目。

3、线程存活时间

​ 线程允许存活的时间,通常是个数字。

4、线程存活时间单位

​ 定义线程的存活时间单位是毫秒、秒、分、小时等。

5、阻塞队列

​ 主要分为三大类,无界队列、有界队列、同步移交队列

  • 无界队列/有界队列:

    (1)DelayQueue, (延期阻塞队列,实现了BlockingQueue接口) 这个队列是无界的,并且没有指定长度的构造方法

    (2)ArrayBlockingQueue, (基于数组的并发阻塞队列) 必须设置长度,遵循先进先出的原则。

    (3)LinkedBlockingQueue, (基于链表的FIFO阻塞队列) 没有指定长度就是有界的反之是有界的,使用该队列做为阻塞队列时要尤其当心,当任务耗时较长时可能会导致大量新任务在队列中堆积最终导致OOM

    (4)LinkedBlockingDeque, (基于链表的FIFO双端阻塞队列) 没有指定长度就是有界的反之是有界的

    (5)PriorityBlockingQueue, (带优先级的无界阻塞队列) 这个只能传入Comperable接口的类型,不是有界的 ,可以自定义优先级。

  • 同步移交队列:

    (1)SynchronousQueue ,(并发同步阻塞队列)不能指定长度,只能传入一个值,有界。不是一个真正的队列,而是一种线程之间移交的机制。

    (2)ArrayDeque, (数组双端队列)

    (3)PriorityQueue, (优先级队列)

    (4)ConcurrentLinkedQueue, (基于链表的并发队列)

6、线程工厂

​ 采用了工厂的设计模式,可以使得线程具有相同的特性,方便我们创建对应的线程,以及监视线程的状态等;

7、拒绝策略

主要用于在阻塞队列已满,同时线程数达到最大线程数的时候,对任务的处理方式,共四种拒绝策略:

​ (1)ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。 主要用于关键的功能模块。

​ (2)ThreadPoolExecutor.DiscardPolicy:丢弃任务,但是不抛出异常。可以用于一些非关键的功能模块。

​ (3)ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务 。在一些对时间有要求的任务上可以采用。

​ (4)ThreadPoolExecutor.CallerRunsPolicy:由调用线程(提交任务的线程)处理该任务

核心线程设置原则:

IO密集型任务,对于该类任务,线程数是越多越好,但过多的线程会过度损耗CPU的资源,因此通常采用2*CPU核心数的线程数目。

CPU密集型任务(计算密集型任务),对于该类任务,只需要让CPU的各个核心都能被利用即可,同时要多出一个线程用于IO操作,因此线程数为:CPU核心数+1。

线程池的优点:1)降低资源消耗,减少了频繁创建线程的消耗;2)提高响应速度,直接将线程进行分配,不必再等待创建;3)提高线程的可管理性,会对被使用完成的线程进行回收,用于下次使用。

线程方法

  • wait()/notify()/notifyall():属于object基类下的一个方法,主要用于线程的通信,唤醒对应的线程,在调用的时候会释放对应的锁。
  • sleep():其属于Thread类下的一个静态方法,在调用的时候需要传入对应的参数,线程会根据传入的参数沉睡相应的秒数,不会释放对应的锁。
  • join():join() 定义在Thread.java中。其让当前占用CPU资源的“主线程”等待“子线程”结束之后才能继续运行。主要作用是同步,它可以使得线程之间的并行执行变为串行执行。在A线程中调用了B线程的join()方法时,表示只有当B线程执行完毕时,A线程才能继续执行。本质上也是采用wait方法,让主线程等待子线程完成。(ps: join()方法必须在线程start()方法调用之后调用才有意义)

主要代码:

  public final synchronized void join(long millis)
    throws InterruptedException {
        // 在循环中不断检测当前线程(B线程)是否活着
        while (isAlive()) {
            // 如果或者,调用者就在当前线程(B线程)对象上等待
            wait(0);
        }
    }
  • Start():启动一个线程使其进入就绪状态,真正实现了多线程运行,这时主线程无需等待run方法体代码执行完毕而直接继续执行下面的代码。
  • Run():线程的执行体,run()方法可以当作普通方法的方式调用,程序还是要顺序执行,还是要等待run方法体执行完毕后才可继续执行下面的代码,因此本质上直接调用Run方法没有使用到多线程。

ThreadLocal

​ ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。

原理:其主要通过维护一个Map,以Thread作为Key,而对应的值作为Value,从而对每个线程维护了一个唯一的副本,从而使得线程间的访问不会出现问题。需要注意的是ThreadLocal是一个弱引用,会在被设置为null或空间不足的时候被回收。早期jdk1.5中的设计如下,这么做有一个弊端:当线程回收时,该线程绑定的变量不能被自动的回收,因为变量存储在 ThreadLocal 里,必须显式的去回收。

e44d0f40ad4957c08410b883cb5d1180.png

JDK 1.8 的设计,每个 Thread 维护一个 ThreadLocalMap,这 map 的 key 是 ThreadLocal 实例本身,value 才是真正要存储的变量值。不同的ThreadLocal对应线程不同的变量副本。

63b044745bd6fb475069c2a216c0c864.png

hashmap与ThreadLocalMap十分类似,但在解决冲突上,HashMap 使用的拉链法,而 ThreadLocalMap 使用的线性探测法。对于父子线程如何共享变量的问题,主要使用 InheritableThreadLocal 来解决。

应用场景:

1、在进行对象跨层传递的时候,使用ThreadLocal可以避免多次传递,打破层次间的约束。

2、线程间数据隔离

3、进行事务操作,用于存储线程事务信息。

4、数据库连接,Session会话管理。

问题:存在内存泄漏的可能性,如果突然我们ThreadLocal被设置为null了,也就是要被垃圾回收器回收了,但是此时我们的ThreadLocalMap生命周期和Thread的一样,它不会回收,这时候就出现了一个现象。那就是ThreadLocalMap的key没了,但是value还在,这就造成了内存泄漏解决办法是使用完ThreadLocal后,显式执行remove操作,删除对应的ThreadLocal键和对应的变量副本,避免出现内存泄漏情况。

img

JAVA的锁分类

(1)悲观锁(lock和Synchronized )/乐观锁(CAS锁)

(2)自旋锁(CAS) /非自旋锁(Synchronized)

自旋锁可以不断的尝试获取锁,整体锁的效率更高;而非自旋锁则会进行阻塞,从而减少CPU的损耗。

(3)锁升级===>无锁/偏向锁/轻量级锁/重量级锁(Synchronized在jdk6引入的)

(4)公平锁/非公平锁;

非公平锁由于不需要判断当前阻塞队列是否有元素,因此其能更好的利用CPU时间碎片。而公平锁可以给系统带来更高的可操控性。

(5)可重入锁(ReentrantLock和synchronized)/非可重入锁;

可重入锁是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方***自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。在一定程度上避免了死锁的情况。

(6)共享锁/非共享锁:

共享锁(S锁):如果事务T对数据A加上共享锁后,则其他事务只能对A再加共享锁,不能加排他锁。获准共享锁的事务只能读数据,不能修改数据。
排他锁(X锁):如果事务T对数据A加上排他锁后,则其他事务不能再对A加任任何类型的封锁。获准排他锁的事务既能读数据,又能修改数据。

img

ReentrantLock/lock:

一、ReentrantLock底层实现:ReentrantLock主要采用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似。其中 AbstractQueuedSynchronizer简称AQS,是一个用于构建锁和同步容器的框架。AQS使用一个FIFO的队列表示排队等待锁的线程,队列头节点称作“哨兵节点”或者“哑节点”,它不与任何线程关联。其他的节点与等待线程关联,每个节点维护一个等待状态waitStatus。同时,ReentrantLock也属于重入锁

​ ReentrantLock的基本实现概括为:

1、先通过CAS尝试获取锁。

2、如果此时已经有线程占据了锁,那就加入AQS队列并且被挂起。

3、当锁被释放之后,排在CLH队列队首的线程会被唤醒,然后CAS再次尝试获取锁。

​ 基于公平锁和非公平锁又有些许的差别,在非公平锁下,如果同时还有另一个线程进来尝试获取,那么有可能会让这个线程抢先获取;而在公平锁下,如果同时还有另一个线程进来尝试获取,当它发现自己不是在队首的话,就会排到队尾,由队首的线程获取到锁。

img

​ 在ReetrantLock的tryLock(long timeout, TimeUnit unit) 提供了超时获取锁的功能。它的语义是在指定的时间内如果获取到锁就返回true,获取不到则返回false。这种机制避免了线程无限期的等待锁释放。另外,ReentrantLock既可以是公平锁也可以是非公平锁,在new对象的时候传入true参数可以构成公平锁。

Lock lock = new ReentrantLock(true); // 公平锁,
Lock lock = new ReentrantLock(false); //    非公平锁,默认为非公平锁。

AQS

AQS(AbstractQueuedSynchronizer,抽象队列同步器)是将每一条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node),源代码如下:

    static final class Node {
        static final Node SHARED = new Node();
        static final Node EXCLUSIVE = null;
        static final int CANCELLED =  1;
        static final int SIGNAL    = -1;
        static final int CONDITION = -2;
        static final int PROPAGATE = -3;
        volatile int waitStatus; //线程的等待状态
        volatile Node prev,next;        // 双向链表的结构 
        volatile Thread thread;//Node对象用来包装线程
        Node nextWaiter;        // 用来表明当前node的线程是想要获取共享锁还是独占锁
        final boolean isShared() {
            return nextWaiter == SHARED;
        }
    }
--------------------------------------------------------------------------------------------
private transient volatile Node head;// 头指针,固定是一个dummy node,因为它的thread成员固定为null
private transient volatile Node tail;// 尾节点,请求锁失败的线程,会包装成node,放到队尾

同时采用volatile修饰共享变量state,所以当state为0时,代表没有线程持有锁。当state为1时,代表有线程持有锁。当state>1时,代表有线程持有该锁,并且重入过该锁。所以state是否为0,可以作为判断是否有线程持有该独占锁的标准。。

private volatile int state;    //用于互斥访问使用。

由于本文分析的是独占锁,所以当state为0时,代表没有线程持有锁。当state为1时,代表有线程持有锁。当state>1时,代表有线程持有该锁,并且重入过该锁。所以state是否为0,可以作为判断是否有线程持有该独占锁的标准。线程通过CAS去改变state,成功则获取锁成功,失败则进入等待队列,等待被唤醒。

private transient Thread exclusiveOwnerThread;    //用于记录当前的线程使用者
在这里插入图片描述

​ 如图示,AQS维护了一个volatile int state和一个FIFO线程等待队列(双向队列),多线程争用资源被阻塞的时候就会进入这个队列。state就是共享资源,其访问方式有如下三种:getState();setState();compareAndSetState();

AQS 定义了两种资源共享方式:
1、Exclusive:独占,只有一个线程能执行,如ReentrantLock。
2、Share:共享,多个线程可以同时执行,如Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier

线程进入AQS的基本流程如下:

img

二、条件变量(Condition)

​ 条件变量很大一个程度上是为了解决Object.wait/notify/notifyAll难以使用的问题。创建一个condition对象是通过lock.newCondition()创建。其底层原理为,内部维护了一个单向等待队列,所有调用condition.await方法的线程会加入到等待队列中,并且线程状态转换为等待状态。

  1. Synchronized中,所有的线程都在同一个object的条件队列上等待。而ReentrantLock中,每个condition都维护了一个条件队列。
  2. 每一个Lock可以有任意数量的Condition对象,Condition是与Lock绑定的,所以就有Lock的公平性特性:如果是公平锁,线程为按照FIFO的顺序从Condition.await中释放,如果是非公平锁,那么后续的锁竞争就不保证FIFO顺序了。
  3. Condition接口定义的方法,await对应于Object.waitsignal对应于Object.notifysignalAll对应于Object.notifyAll。特别说明的是Condition的接口改变名称就是为了避免与Object中的wait/notify/notifyAll的语义和使用上混淆。

JAVA对象头

​ 对象头含有三部分:Mark Word(存储对象自身运行时数据)、Class Metadata Address(存储类元数据的指针)、Array length(数组长度,只有数组类型才有)。重点在Mark Word部分,Mark Word数据结构被设计成非固定的动态数据,会随着对象的不同状态而变化,如下所示。

img

对象的生命周期:

1、创建,如果当前没有进行过加载,则进行对应的类加载。

2、内存分配,默认从Eden区域中取内存空间分配给对象,如果空间不足进行minorGC,再不足进行

Synchronized

​ Synchronized底层主要是依靠对应的monitorentermonitorexit指令分别对应synchronized同步块的进入和退出,有两个monitorexit指令的原因是:为了保证抛异常的情况下也能释放锁,所以javac为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁。

锁升级

锁降级:JVM 进入安全点(SafePoint)的时候,会检查是否有闲置的 Monitor,然后试图进行降级)

1、无锁状态

​ 即对资源进行访问不需要先获取锁。

2、偏向锁

​ 当线程1访问代码块并获取锁对象时,会在java对象头(MarkWord)和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致,则需要进行以下的判断来进行锁升级:

    例如线程2要竞争锁对象,而偏向锁不会主动释放,因此还是存储的线程1的threadID,需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,线程2可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程1的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停线程1,撤销偏向锁,升级为轻量级锁,如果线程1不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
3、轻量级锁

​ 轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2进行CAS时,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。CAS在等待时间较小的情况下,可以有效避免线程的上下文切换。

​ 但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁

同时在自选这块,JDK中引入了自适应自选,即每次自选会根据上一次的自选选择合适的时间,从而避免无用的CPU资源消耗。

4、重量级锁

​ 采用monitor实现,在编译的时候会加上monitorentrymonitorexit,从而限制线程的进入。会自动的阻塞线程,不会占用CPU的内存,但同时锁的响应时间会较长。

Monitor可以理解为一种同步工具,也可理解为一种同步机制,常常被描述为一个Java对象。
    (1) 互斥:一个Monitor在一个时刻只能被一个线程持有,即Monitor中的所有方法都是互斥的。
    (2) signal机制:如果条件变量不满足,允许一个正在持有Monitor的线程暂时释放持有权,当条件变量满足时,当前线程可以唤醒正在等待该条件变量的线程,然后重新获取Monitor的持有权。所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。
    Monitor的本质是依赖于底层操作系统的管程实现,操作系统实现线程之间的切换需要从用户态到内核态的转换,成本非常高。
  • 锁粗化:锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。

  • 锁消除:Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间

Lock/Synchronized 区别

1、底层实现

​ synchronized 是JVM层面的锁,是Java关键字,依靠对应的monitorenter和monitorexiut对对应的代码块进行加锁的操作。而ReentrantLock则是基于CAS和AQS的实现,是API层面的实现。

2、绑定条件

​ 一个ReentrantLock可以绑定多个Condition对象,仅需多次调用new Condition()即可;而在synchronized中锁锁对象的wait()、notify()/notifyAll()可以实现一个隐含的条件,如果要和多余的条件关联,就不得不额外的增加一个锁。

3、是否需要手动释放

​ synchronized发生异常时,会自动释放线程占用的锁,故不会发生死锁现象。Lock发生异常,若没有主动释放,极有可能造成死锁,故需要在finally中调用unLock方法释放锁

4、是否可中断

​ Synchronized不可以响应中断,但是lock可以通过lockInterruptibly()方法响应中断。

5、是否公平的角度

​ Synchronized是非公平的锁,而lock、reentrantlock则是可以设置为公平锁。是否公平要判断是否会出现饥饿的现象。

6、从锁的对象来说

​ synchronzied锁的是对象,锁是保存在对象头里面的,根据对象头数据来标识是否有线程获得锁/争抢锁;ReentrantLock锁的是线程,根据进入的线程和int类型的state标识锁的获得/争抢。

两者的选择:在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级工具。当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的,可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用Synchronized

JVM线程内存模型

Java内存模型(Java Memory Model,JMM)主要是为了规定了线程和内存之间的一些关系。根据JMM的设计,系统存在一个主内存(Main Memory),Java中所有变量都储存在主存中,对于所有线程都是共享的。每条线程都有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作都是在工作内存中进行,线程之间无法相互直接访问,变量传递均需要通过主存完成。

img

JVM内部,Java内存模型把内存分成了两部分:线程栈区和堆区, JVM中运行的每个线程都拥有自己的线程栈,线程栈包含了当前线程执行的方法调用相关信息,我们也把它称作调用栈。随着代码的不断执行,调用栈会不断变化。

​ 特别需要注意的是,主内存和工作内存与JVM内存结构中的Java堆、栈、方法区等并不是同一个层次的内存划分,无法直接类比。《深入理解Java虚拟机》中认为,如果一定要勉强对应起来的话,从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分。工作内存则对应于虚拟机栈中的部分区域。

内存间的交互操作:

Lock:作用于主内存的变量,把一个变量标示为线程独占的状态。

unlock:作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才能被其余线程使用。

read:作用于主内存的变量,把变量的值传输到线程工作内存中。

Load:作用于工作内存的变量,将工作内存的变量传输到工作内存的副本中。

Use:作用于工作内存的变量,把工作内存的变量值传递给执行引擎。当虚拟机遇到一个需要使用变量的字节码指令的时候,会执行这个操作。

Assign:把执行引擎接受的值传递给工作内存中的变量,虚拟机遇到变量赋值的字节码的时候采用这个命令。

Store:作用于工作内存的变量,把工作内存的值传递到主内存。

Write:作用于主内存的变量,把Store操作从工作内存中得到的变量放入到主内存变量中。

虚拟机内存交互关系

原子性:

​ 在Java中,为了保证原子性,提供了两个高级的字节码指令monitorentermonitorexit。在synchronized的实现原理文章中,介绍过这两个字节码,在Java中对应的关键字就是synchronized。因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。而Volatile关键字在不满足下述两个条件的时候,需要采用Synchronized来进行同步。

  • 运算结果不依赖变量的当前值,或能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他状态变量共同参与不变约束。

可见性:

​ Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性,Java中的synchronized和final两个关键字也可以实现可见性。

  • Volatile的可见性原理:

    ​ 第一,Volatile给指令加上了对应的内存屏障,从而阻止对应的指令进行重排序达到可见性的目的。第二,volatile保证了修饰的共享变量在转换为汇编语言时,会加上一个以lock为前缀的指令(内存屏障)。当CPU发现一个变量被volatile修饰时,其会遵循下面的规则来确保可见性:

    1. 每次使用变量,都必须从主内存刷新最新的值,确保看到别人的改动。
    2. 每次修改变量,都必须重新刷新到主内存中,以确保其他线程看到线程对变量V的修改。
    3. 同时要求JVM不可以对volatile修饰的变量进行指令重排序,确保执行顺序相同。

    重排序,主要分为三种:

    ​ 1、编译器优化,编译器(包括 JVM、JIT 编译器等)出于优化的目的,例如当前有了数据 a,把对 a 的操作放到一起效率会更高,避免读取 b 后又返回来重新读取 a 的时间开销,此时在编译的过程中会进行一定程度的重排

    ​ 2、CPU重排序,CPU 同样会有优化行为,这里的优化和编译器优化类似,都是通过乱序执行的技术来提高整体的执行效率。

    ​ 3、内存的“重排序”,内存系统内不存在真正的重排序,但是内存会带来看上去和重排序一样的效果。由于内存有缓存的存在,在 JMM 里表现为主存和本地内存,而主存和本地内存的内容可能不一致,所以这也会导致程序表现出乱序的行为。在JAVA中,指令重排序遵循happens-before的原则,即两个线程的重排序结果不会影响原有程序执行的效果。

  • Synchronized的可见性原理

    ​ Synchronized的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存“这条规则获得的。

  • Final可见性原理:

    被final修饰的字段在构造器中一旦初始化,且构造器没有把“this”的引用传递出去那么其他线程就能看见final字段的值。

有序性:

​ 在Java中,可以使用synchronizedvolatile来保证多线程之间操作的有序性。实现方式有所区别:volatile关键字会禁止指令重排,synchronized关键字保证同一时刻只允许一条线程操作。读者可能发现了,好像synchronized关键字是万能的,他可以同时满足以上三种特性,这其实也是很多人滥用synchronized的原因。但是synchronized是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。

先行发生原则:

1、程序次序规则。在一个线程内,书写在前面的代码先行发生于后面的。确切地说应该是,按照程序的控制流顺序,因为存在一些分支结构。

2、Volatile变量规则。对一个volatile修饰的变量,对他的写操作先行发生于读操作。

3、线程启动规则。Thread对象的start()方法先行发生于此线程的每一个动作。

4、线程终止规则。线程的所有操作都先行发生于对此线程的终止检测。

5、线程中断规则。对线程interrupt()方法的调用先行发生于被中断线程的代码所检测到的中断事件。

6、对象终止规则。一个对象的初始化完成(构造函数之行结束)先行发生于发的finilize()方法的开始。

7、传递性。A先行发生B,B先行发生C,那么,A先行发生C。

8、管程锁定规则。一个unlock操作先行发生于后面对同一个锁的lock操作。

共享变量的线程安全

​ 当10个客户端同时请求同一个接口,这样就产生了10个线程,当这10个线程需要共享一个变量时,就可能出现脏读等线程安全问题。解决的方案主要有:

一、ThreadLocal

ThreadLocal会把每一个线程变量的值存储到本地,线程之间不共用数据,从而杜绝数据脏读等问题

二、InheritableThreadLocal

ThreadLocal确实从一定程度上解决了线程安全的问题,但也有缺点,那就是父子线程之间不能进行值传递。我们先了解一下父子线程,其是指一个接口请求另一个接口,第二个接口的线程是由第一个接口的线程引发的,第一个接口的线程则为第二个接口线程的父线程。

三、TransmittableThreadLocal

JVM内存模型

JMM是java的内存模型,大体上其分为三个部分,主要构造如下图:

img

1、类加载器,主要负责类加载模块,将对应的字节码加载到内存区中。

2、运行时数据区,其又可以划分成五大部分:

(1),属于线程共享的部分,其是OOM故障最主要的发源地,它存储着几乎所有的实例对象,由垃圾收集器负责管理回收,堆区由各子线程共享使用;堆的内存空间既可以固定大小,也可运行时动态地调整,通过参数-Xms设定初始值、-Xmx设定最大值。

(2)方法区,线程共享,用来存储已被虚拟机加载的类信息、常量、静态变量、JIT(just in time,即时编译技术)编译后的代码等数据。运行时常量池是方法区的一部分,用于存放编译期间生成的各种字面常量和符号引用。

  • 在1.7版本中方法区迎来了一些改动,永久代中的静态变量和运行时常量池中的字符串常量池转移到了堆中,
    也就是说全局变量和其他常量(非字符串常量)还遗留在永久代中

  • 在JDK1.8中,方法区转换变成了元空间,主要原因是:

    1、字符串存在永久代中,容易出现性能问题和内存溢出,类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小 容易出现永久代溢出,太大则容易导致老年代溢出
    2、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低
    3、将 HotSpot 与 JRockit 合二为一

(3)虚拟机栈,线程私有,它描述的是java方法执行的内存模型,每个方法执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至完成的过程,都对应着一个栈帧从入栈到出栈的过程。

(4)本地方法栈,线程私有,本地方法栈和虚拟机栈所发挥的作用是很相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。(Native方法主要指的是一个java调用非java代码的接口。一个Native Method是这样一个java的方法:该方法的实现由非java语言实现,比如C。)

(5)程序计数器,是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令:分支、跳转、循环、异常处理、线程恢复等基础操作都会依赖这个计数器来完成。

3、执行引擎,通过类装载器装载的,被分配到JVM的运行时数据区的字节码会被执行引擎执行。执行引擎以指令为单位读取Java字节码。它就像一个CPU一样,一条一条地执行机器指令。

内存泄漏:

内存泄漏的根本原因,在于对象存在一条引用链,致使对应的GC不能对对象进行回收,从而造成内存不能再分配的情况。常见的内存泄漏有如下的情况:

  1. 静态集合类,如HashMap、LinkedList等,如果这些容器为静态的,那么它们的生命周期与程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏。
  2. 各种连接,如数据库连接、网络连接和IO连接等。在对数据库进行操作的过程中,首先需要建立与数据库的连接,当不再使用时,需要调用close方法来释放与数据库的连接。只有连接被关闭后,垃圾回收器才会回收对应的对象。
  3. 变量不合理的作用域。一般而言,一个变量的定义的作用范围大于其使用范围,很有可能会造成内存泄漏。另一方面,如果没有及时地把对象设置为null,很有可能导致内存泄漏的发生。如下面伪代码,通过readFromNet方法把接受的消息保存在变量msg中,然后调用saveDB方法把msg的内容保存到数据库中,此时msg已经就没用了,由于msg的生命周期与对象的生命周期相同,此时msg还不能回收,因此造成了内存泄漏。
public class UsingRandom {
        private String msg;
        public void receiveMsg(){
        readFromNet();// 从网络中接受数据保存到msg中
        saveDB();// 把msg保存到数据库中
        }
}

4、内部类持有外部类,如果一个外部类的实例对象的方法返回了一个内部类的实例对象,这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄露。

5、改变哈希值,当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了,否则,对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为的参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中单独删除当前对象,造成内存泄露。

6、内存泄漏的另一个常见来源是缓存,一旦你把对象引用放入到缓存中,他就很容易遗忘,对于这个问题,可以使用WeakHashMap代表缓存,此种Map的特点是,当除了自身有对key的引用外,此key没有其他引用那么此map会自动丢弃此值

7、监听器和回调,内存泄漏第三个常见来源是监听器和其他回调,如果客户端在你实现的API中注册回调,却没有显示的取消,那么就会积聚。需要确保回调立即被当作垃圾回收的最佳方法是只保存他的若引用,例如将他们保存成为WeakHashMap中的键。

对象的生命周期:

对象的整个生命周期大致可以分为7个阶段:

1、创建阶段(Creation)

在对象创建阶段,系统要通过下面的步骤,完成对象的创建过程:

(1)为对象分配存储空间。

(2)开始构造对象。

(3)递归调用其超类的构造方法。

(4)进行对象实例初始化与变量初始化。

(5)执行构造方法体。

2、应用阶段(Using)

3、不可视阶段(Invisible)

4、不可到达阶段(Unreachable)

5、可收集阶段(Collected)

6、终结阶段(Finalized)

7、释放阶段(Free)

垃圾回收(GC)

​ 垃圾回收,我们的内存垃圾回收主要集中于 java 堆和方法区中,在程序运行期间,这部分内存的分配和使用都是动态的,因此需要对这部分内容进行合理的回收。程序运行时,内存空间是有限的,那么如何及时的把不再使用的对象清除将内存释放出来,这就是GC要做的事。总体概述图如下:

清理对象

GC对象判定:

​ 需要进行回收的对象就是已经没有存活的对象,判断一个对象是否存活常用的有两种办法:引用计数可达分析

引用计数:

​ 每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题

可达性分析

​ 从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,即不可达对象。ps:当标记为不可达的对象时,该对象是不可以再被引用的。

在Java语言中,GC Roots包括:
1、虚拟机栈(局部变量表)中引用的对象。
2、方法区中类静态属性实体引用的对象(采用static修饰的对象)。
3、方法区中常量引用的对象(final修饰的对象)。
4、本地方法栈中JNI引用的对象(使用native修饰的方法)。

GC清理常用算法:

标记清除法:

​ 为每个对象存储一个标记位,记录对象的状态(活着或是死亡)。分为两个阶段,一个是标记阶段,这个阶段内,为每个对象更新标记位,检查对象是否死亡;第二个阶段是清除阶段,该阶段对死亡的对象进行清除,执行 GC 操作。

标记整理法:

​ 与标记清除类似,该算法也将所有对象标记为存活和死亡两种状态;不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是将所有存活的对象整理一下,放到另一处空间,然后把剩下的所有对象全部清除。这样可以避免内存碎片的产生

复制算法:

​ 该算法将内存平均分成两部分,然后每次只使用其中的一部分,当这部分内存满的时候,将内存中所有存活的对象复制到另一个内存中,然后将之前的内存清空,只使用这部分内存,循环下去。优点是不产生内存碎片,缺点是只能使用一半内存空间。

分代回收算法:

​ 它根据对象的生存周期,将堆分为新生代(Young)和老年代(Tenure),默认比例大小为1:2。在新生代中,由于对象生存期短,每次回收都会有大量对象死去,那么这时就采用复制算法。老年代里的对象存活率较高,没有额外的空间进行分配担保,所以可以使用标记-整理 或者 标记-清除

GC触发的时机

GC又分为 minor GC 和 Full GC(也称为 Major GC )
Minor GC触发条件:
  当Eden区满时,触发Minor GC。

Full GC触发条件:
  a.调用System.gc时,系统建议执行Full GC,但是不必然执行
  b.老年代空间不足(关键)
  c.方法区空间不足(关键)
  d.通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  e.由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

新生代转换为老年代具体有四种情况,如下。

  1. Eden区满时,进行Minor GC,当Eden和一个Survivor区中依然存活的对象无法放入到Survivor中,则通过分配担保机制提前转移到老年代中。分配担保机制是指,将当前新生代Eden区中的对象拷贝到老年代中,从而清理出新生代的空间用于存储新的大对象。
  2. 对象体积太大,新生代无法容纳XX:PretenureSizeThreshold即对象的大小大于此值, 就会绕过新生代, 直接在老年代分配, 此参数只对Serial及ParNew两款收集器有效。
  3. 长期存活的对象将进入老年代,虚拟机对每个对象定义了一个对象年龄(Age)计数器。当年龄增加到一定的临界值时,就会晋升到老年代中,该临界值由参数:-XX:MaxTenuringThreshold来设置。如果对象在Eden出生并在第一次发生MinorGC时仍然存活,并且能够被Survivor中所容纳的话,则该对象会被移动到Survivor中,并且设Age=1;以后每经历一次Minor GC,该对象还存活的话Age=Age+1。
  4. 动态对象年龄判定,虚拟机并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升到老年代,如果在Survivor区中相同年龄(设年龄为age)的对象的所有大小之和超过Survivor空间的一半,年龄大于或等于该年龄(age)的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。

跨代引用是指新生代中存在对老年代对象的引用,或者老年代中存在对新生代的引用,

​ 更细致的,新生代还可以划分成Eden区,From Surivior区和To Survivor区三个部分,比例为8:1:1。

之所以使用两个Surivior区域,是为了避免对象从Eden区域复制到Surivior区域时,由于之前的数据还保存在Surivior中可能会导致内存碎片的出现,采用两个Surivior区,就可以始终保持其中一个Surivior区域是空的。等到一些对象经历了15次GC后,会从Surivior区域转移到老年代中。

如下是分代垃圾收集的过程,始终会保持一个Surivior中为全空状态。

img img img

等达到GC的阈值之后,就一并移动到老年代中。

img

跨代引用:跨代引用是指新生代中存在对老年代对象的引用,或者老年代中存在对新生代的引用。minor GC时,为了找到年轻代中的存活对象,不得不遍历整个老年代;反之亦然。这种方案存在极大的性能浪费。

解决方案记忆集,是用来记录跨代引用的表,通过引入记忆集避免遍历老年代。以minor GC为例说明,要回收年轻代,只需要引用年轻代对象的GC ROOT+记忆集,就可以判断出Young区对象是否存活,不必再遍历老年代。缺点是具有“滞后性”,浪费一定的空间,因为如果没有进行Full GC,一些老年代中的引用难以被清除。

垃圾收集器:

img
1、Serial 垃圾回收器
2、Serial Old垃圾回收器

​ Serial垃圾回收器采用复制算法,SerialOld垃圾回收器采用标记整理算法,两种都是采用串型单线程的方式进行垃圾回收。

img
3、ParNew垃圾收集器

​ 其是Serial收集器的多线程版本,用于新生代收集,常采用复制算法,目前只有它能与CMS收集器配合工作。

img
4、Parallel Scavenge

其属于新生代垃圾收集器,采用复制算法,关注吞吐量,吞吐量优先,吞吐量=代码运行时间/(代码运行时间+垃圾收集时间),也就是高效率利用cpu时间,Parallel Scavenge收集器使用两个参数控制吞吐量:

  • XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
  • XX:GCRatio 直接设置吞吐量的大小。

除此之外,Parallel Scavenge收集器还可以设置参数-XX:+UseAdaptiveSizePocily来动态调整停顿时间或者最大的吞吐量,这种方式称为GC自适应调节策略,这个是ParNew没有的。

5、Parallel Old

ParallelScavenge的老年版本,用于老年代收集,常采用标记整理算法

Parallel Scavenge and Parrallel Old收集器组合
6、CMS收集器

CMS收集器(Concurrent Mark Sweep)的目标就是获取最短回收停顿时间。在注重服务器的响应速度,希望停顿时间最短,则CMS收集器是比较好的选择。整个执行过程分为以下4个步骤:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清除,(与用户并发,因此产生了浮动垃圾。)

初始标记和重新标记这两个步骤仍然需要暂停Java执行线程,初始标记只是标记GC Roots能够关联到的对象,并发标记就是执行GC Roots Tracing的过程,而重新标记就是为了修正并发标记期间因用户程序执行而导致标记发生变动使得标记错误的记录。其执行过程如下:

CMS收集器

CMS的主要缺点在于:

  • CMS收集器无法处理浮动垃圾。所谓的“浮动垃圾”,就是在并发清除阶段,由于用户程序在运行,那么自然就会有新的垃圾产生,CMS无法在当次集中处理它们,只好在下一次GC的时候处理。这部分未处理的垃圾就称为“浮动垃圾”
  • 对CPU资源太敏感,这点可以这么理解,虽然在并发标记阶段用户线程没有暂停,但是由于收集器占用了一部分CPU资源,导致程序的响应速度变慢。
  • 由于CMS收集器是基于“标记-清除”算法的,前面说过这个算***导致大量的空间碎片的产生,一旦空间碎片过多,大对象就没办法给其分配内存,那么即使内存还有剩余空间容纳这个大对象,但是却没有连续的足够大的空间放下这个对象,所以虚拟机就会触发一次Full GC(这个后面还会提到)这个问题的解决是通过控制参数-XX:+UseCMSCompactAtFullCollection,用于在CMS垃圾收集器顶不住要进行FullGC的时候开启空间碎片的合并整理过程。
7、G1收集器

G1(Garbage-First)收集器是现今收集器技术的最新成果之一,之前一直处于实验阶段,直到jdk7u4之后,才正式作为商用的收集器。与前几个收集器相比,G1收集器有以下特点:

  • 并行与并发
  • 分代收集(仍然保留了分代的概念)
  • 空间整合(整体上属于“标记-整理”算法,不会导致空间碎片)
  • 可预测的停顿(比CMS更先进的地方在于能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒)

​ 此外,G1收集器将Java堆划分为多个大小相等的Region(独立区域),新生代与老年代都是一部分Region的集合,G1的收集范围则是这一个个Region(化整为零)。

G1的工作过程如下:

  1. 初始标记(Initial Marking)
  2. 并发标记(Concurrent Marking)
  3. 最终标记(Final Marking)
  4. 筛选回收(Live Data Counting and Evacuation),采用STW来避免出现浮动垃圾。

初始标记阶段仅仅只是标记一下GC Roots能够直接关联的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段的用户程序并发运行的时候,能在正确可用的Region中创建对象,这个阶段需要暂停线程。并发标记阶段从GC Roots进行可达性分析,找出存活的对象,这个阶段与用户线程并发执行的。最终标记阶段则是修正在并发标记阶段因为用户程序的并发执行而导致标记产生变动的那一部分记录,这部分记录被保存在Remembered Set Logs中,最终标记阶段再把Logs中的记录合并到Remembered Set中。最后在筛选阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间制定回收计划,同时采用STW的方式进行回收,从而避免了浮动垃圾的产生。整个执行过程如下:

G1收集器

JVM调优

JVM调优目标:使用较小的内存占用来获得较高的吞吐量或者较低的延迟。调优有几个比较重要的指标:

  • 内存占用:程序正常运行需要的内存大小。
  • 延迟:由于垃圾收集而引起的程序停顿时间。
  • 吞吐量:用户程序运行时间占用户程序和垃圾收集占用总时间的比值。

JVM调优工具:

(1)调优数据:

​ 可以依赖、参考的数据有系统运行日志、堆栈错误信息、GC日志、线程快照、堆转储快照等。

​ ①系统运行日志:系统运行日志就是在程序代码中打印出的日志,描述了代码级别的系统运行轨迹(执行的方法、入参、返回值等),一般系统出现问题,系统运行日志是首先要查看的日志。

​ ②堆栈错误信息:当系统出现异常后,可以根据堆栈信息初步定位问题所在,比如根据“java.lang.OutOfMemoryError: Java heap space”可以判断是堆内存溢出;根据“java.lang.StackOverflowError”可以判断是栈溢出;根据“java.lang.OutOfMemoryError: PermGen space”可以判断是方法区溢出等。

​ ③GC日志:程序启动时用 -XX:+PrintGCDetails 和 -Xloggc:/data/jvm/gc.log 可以在程序运行时把gc的详细过程记录下来,或者直接配置“-verbose:gc”参数把gc日志打印到控制台,通过记录的gc日志可以分析每块内存区域gc的频率、时间等,从而发现问题,进行有针对性的优化。

​ ④线程快照:顾名思义,根据线程快照可以看到线程在某一时刻的状态,当系统中可能存在请求超时、死循环、死锁等情况是,可以根据线程快照来进一步确定问题。通过执行虚拟机自带的“jstack pid”命令,可以dump出当前进程中线程的快照信息,

​ ⑤堆转储快照:程序启动时可以使用 “-XX:+HeapDumpOnOutOfMemory” 和 “-XX:HeapDumpPath=/data/jvm/dumpfile.hprof”,当程序发生内存溢出时,把当时的内存快照以文件形式进行转储(也可以直接用jmap命令转储程序运行时任意时刻的内存快照),事后对当时的内存使用情况进行分析。

(2)JVM调优工具:

​ ①用jps(JVM process Status)可以查看虚拟机启动的所有进程、执行主类的全名、JVM启动参数,

​ ②用jstat(JVM Statistics Monitoring Tool)监视虚拟机信息 jstat -gc pid 500 10 :每500毫秒打印一次Java堆状况(各个区的容量、使用容量、gc时间等信息),打印10次。

​ ③用jmap(Memory Map for Java)查看堆内存信息,执行jmap -histo pid可以打印出当前堆中所有每个类的实例数量和内存占用。

​ ④利用jconsole、jvisualvm分析内存信息(各个区如Eden、Survivor、Old等内存变化情况).

​ ⑤分析堆转储快照,前面说到配置了 “-XX:+HeapDumpOnOutOfMemory” 参数可以在程序发生内存溢出时dump出当前的内存快照,也可以用jmap命令随时dump出当时内存状态的快照信息,dump的内存快照一般是以.hprof为后缀的二进制格式文件。

(3)常用的JVM调优参数:

参数 说明 实例
-Xms 初始堆大小,默认物理内存的1/64 -Xms512M
-Xmx 最大堆大小,默认物理内存的1/4 -Xms2G
-Xmn 新生代内存大小,官方推荐为整个堆的3/8 -Xmn512M
-Xss 线程堆栈大小,jdk1.5及之后默认1M,之前默认256k -Xss512k
-XX:NewRatio=n 设置新生代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4 -XX:NewRatio=3
-XX:SurvivorRatio=n 年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:8,表示Eden:Survivor=8:1:1,一个Survivor区占整个年轻代的1/8 -XX:SurvivorRatio=8
-XX:PermSize=n 永久代初始值,默认为物理内存的1/64 -XX:PermSize=128M
-XX:MaxPermSize=n 永久代最大值,默认为物理内存的1/4 -XX:MaxPermSize=256M
-verbose:class 在控制台打印类加载信息
-verbose:gc 在控制台打印垃圾回收日志
-XX:+PrintGC 打印GC日志,内容简单
-XX:+PrintGCDetails 打印GC日志,内容详细
-XX:+PrintGCDateStamps 在GC日志中添加时间戳
-Xloggc:filename 指定gc日志路径 -Xloggc:/data/jvm/gc.log
-XX:+UseSerialGC 年轻代设置串行收集器Serial
-XX:+UseParallelGC 年轻代设置并行收集器Parallel Scavenge
-XX:ParallelGCThreads=n 设置Parallel Scavenge收集时使用的CPU数。并行收集线程数。 -XX:ParallelGCThreads=4
-XX:MaxGCPauseMillis=n 设置Parallel Scavenge回收的最大时间(毫秒) -XX:MaxGCPauseMillis=100
-XX:GCTimeRatio=n 设置Parallel Scavenge垃圾回收时间占程序运行时间的百分比。公式为1/(1+n) -XX:GCTimeRatio=19
-XX:+UseParallelOldGC 设置老年代为并行收集器ParallelOld收集器
-XX:+UseConcMarkSweepGC 设置老年代并发收集器CMS
-XX:+CMSIncrementalMode 设置CMS收集器为增量模式,适用于单CPU情况。
# 后端开发知识框架汇总

==Spring框架==

Spring/Springboot/SpringMVC

Spring

​ 其是一个引擎,众多衍生产品例如boot、security、jpa等等;但他们的基础都是Spring的ioc和 aop,ioc 提供了依赖注入的容器, aop解决了面向切面编程,然后在此两者的基础上实现了其他延伸产品的高级功能。

Springboot

​ 其是进一步实现了auto-configuration自动配置(另外三大神器actuator监控,cli命令行接口,starter依赖),降低了项目搭建的复杂度。它主要是为了解决使用Spring框架需要进行大量的配置太麻烦的问题。

SpringMVC

​ 其是spring基础之上的一个MVC框架,主要处理web开发的路径映射和视图渲染,属于spring框架中WEB层开发的一部分,其主要分为Model(模型)、VIew(视图)、Controller(控制器)三部分,具体的工作流程如下。在这里插入图片描述

Spring的作用域

1、singleton

​ 保持容器中只存在唯一的单例。Controller亦采用的是单例模式,同时采用ThreadLocal的方式来解决可能存在的线程不安全的问题。

2、propetype

​ 为每个bean请求申请一个实例。申请之后不会再进行管理。

3、request

​ 对每一个网络请求,会申请一个对应的bean容器。

4、session

​ 会保证每个session中有一个对应的实例,在session失效后bean也会失效。

5、global-session

​ 其与portlet有关,

IOC/DI(控制反转/依赖注入)

​ IOC理论提出的观点大体是这样的:借助于“第三方”实现具有依赖关系的对象之间的解耦,简单来说,就是对象的创建不再有程序本身去创建,而是交给IOC容器去实现。IoC的一个重点是在系统运行中,动态的向某个对象提供它所需要的其他对象。这一点是通过DI(Dependency Injection,依赖注入)来实现的。在Java开发中,Ioc意味着将你设计好的对象交给容器控制,而不是传统的在你的对象内部直接控制。Spring中实现依赖注入的主要方式是采用@Autowired的注释,

AOP(面向切面编程)

​ AOP即面向切面编程,其最主要的实现方式是采用代理实现的。代理又主要分为两种:静态代理动态代理

静态代理主要是令代理人和被代理人共同实现一个类,实现代码如下:

interface MyInterface{
   public void dosomething();
}
class Principal implements MyInterface{
    @Override
    public void dosomething() {
        System.out.println("嗨!我是被代理人!");
    }
}
class Agent implements MyInterface{
    Principal principal;
    public Agent(Principal principal) {
        this.principal = principal;
    }
    @Override
    public void dosomething() {
        System.out.println("我是代理人!");
        principal.dosomething();
        System.out.println("我代理完成了!");
    }
}
public class StaticProxy {
    public static void main(String[] args) {
        Principal p = new Principal();
        Agent agent = new Agent(p);
        agent.dosomething();
    }
}

JDK的动态代理CGlib的动态代理

  • JDK动态代理:JDK的动态代理主要通过代理类和被代理类实现同一个接口实现,同时采用反射的机制,调用对应类的接口实现的AOP。
//JDK动态代理
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
interface MyInterface{
   public void dosomething();
}

class Principal implements MyInterface{
    @Override
    public void dosomething() {
        System.out.println("嗨!我是被代理人!");
    }
}

class Agent implements InvocationHandler {
    Object principal;
    public Agent(Object principal) {
        this.principal = principal;
    }
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        //甚至连方法都调用对应的反射!这样连组合排列都不用了!
        System.out.println("-----------before-----------");
        method.invoke(principal, args);
        System.out.println("-----------after-----------");
        return null;
    }
}

public class StaticProxy {
    public static void main(String[] args) {
        Principal p = new Principal();
        Agent agent = new Agent(p);
        MyInterface myinterface = (MyInterface) Proxy.newProxyInstance(MyInterface.class.getClassLoader(),
                new Class[] {MyInterface.class}, agent);    //创建对应的被代理对象
        myinterface.dosomething();
    }
}
  • CGlib动态代理

底层实现与JDK的动态代理不同,其采用修改对应的ASM的字节码的方式实现。

@Transctional 注解:主要是通过反射获取bean的注解信息,利用AOP对编程式事务进行封装实现。==?==

Springboot自动装配原理

​ Springboot自动配置原理主要采用三个注解实现,其分别是:

1、EnableAutoConfiguration:该注解主要开启Springboot的自动配置功能。

​ (1) @AutoConfigurationPackage:利用register注册对应的容器,将指定的一个包下的所有组件导入进来。(默认是main包下的所有组件。)

​ (2) @Import(AutoConfigurationImportSelector.class):底层采用工厂模式从配置文件中读取对应的组件(注意并不是全部加载,会对组件采用@ConditionOnMissingBean等条件进行判断 )

2、SpringConfiguration,标示启动类是一个SpringConfiguration类,扫描并加载都容器中。

3、ComponetScan,指定对应的包扫描的路径。

img

Spring循环依赖

循环依赖-->循环引用--->即2个或以上bean 互相持有对方,最终形成闭环。eg:A依赖B,B依赖C,C又依赖A。

Spring循环依赖场景

①:构造器的循环依赖。【这个Spring解决不了】

StudentA有参构造是StudentB。StudentB的有参构造是StudentC,StudentC的有参构造是StudentA ,这样就产生了一个循环依赖的情况,我们都把这三个Bean交给Spring管理,并用有参构造实例化。

②【setter循环依赖】field属性的循环依赖【setter方式 单例,默认方式-->通过递归方法找出当前Bean所依赖的Bean,然后提前缓存【放入三层缓存中】。通过提前暴露 -->暴露一个exposedObject用于返回提前暴露的Bean。】Spring是先将Bean对象实例化【依赖无参构造函数】--->再设置对象属性的

原因:Spring先用构造器实例化Bean对象----->将实例化结束的对象放到一个Map中,并且Spring提供获取这个未设置属性的实例化对象的引用方法。结合我们的实例来看,,当Spring实例化了StudentA、StudentB、StudentC后,紧接着会去设置对象的属性,此时StudentA依赖StudentB,就会去Map中取出存在里面的单例StudentB对象,以此类推,不会出来循环的问题喽。

Spring 三级缓存

​ Spring在启动过程中,使用到了三个map,称为三级缓存。第一级缓存主要保存Bean到指定的容器中,而第二第三级的缓存主要用于解决循环依赖。

singletonFactories : 单例对象工厂的cache

earlySingletonObjects :提前暴光的单例对象的Cache 。【用于检测循环引用,与singletonFactories互斥】

singletonObjects:单例对象的cache

img

​ 假设初始化A bean时,发现A bean依赖B bean,而B还没有初始化的话,需要暂停A的注入先注入B容器。那么此时new出来的A对象放哪里,直接放在容器Map里显然不合适,半残品怎么能用,所以需要提供一个可以标记创建中bean(A)的Map(即二级缓存),可以提前暴露正在创建的bean供其他bean依赖。而如果初始化A所依赖的bean B时,发现B也需要注入一个A的依赖(即发生循环依赖),则B可以从创建中的beanMap中直接获取A对象(创建中)注入A,然后完成B的初始化,返回给正在注入属性的A,最终A也完成初始化,皆大欢喜。这块的实现主要由第二级的缓存实现。

​ 三级缓存中提到出现循环依赖才去解决,也就是说出现循环依赖时,才会执行工厂的getObject生成(获取)早期依赖,这个时候就需要给它挪个窝了,因为真正暴露的不是工厂,而是对象,所以需要使用一个二级缓存保存暴露的早期对象(earlySingletonObjects),同时移除提前暴露的工厂。

//具体解决依赖的代码
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
    Object singletonObject = this.singletonObjects.get(beanName);
    if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
        synchronized (this.singletonObjects) {
            singletonObject = this.earlySingletonObjects.get(beanName);
            if (singletonObject == null && allowEarlyReference) {
                ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
                if (singletonFactory != null) {
                    singletonObject = singletonFactory.getObject();
                    this.earlySingletonObjects.put(beanName, singletonObject);
                    this.singletonFactories.remove(beanName);
                }
            }
        }
    }
    return (singletonObject != NULL_OBJECT ? singletonObject : null);

Spring启动过程

(1)创建beanFactory,加载xml配置文件。
(2)解析配置文件转化beanDefination,获取到bean的所有属性、依赖及初始化用到的各类处理器等。
(3)刷新beanFactory容器,初始化所有单例bean。
(4)注册所有的单例bean并返回可用的容器,一般为扩展的applicationContext。

Bean生存周期

1、实例化Bean

​ 对于BeanFactory容器,当客户向容器请求一个尚未初始化的bean时,或初始化bean的时候需要注入另一个尚未初始化的依赖时,容器就会调用createBean进行实例化。 对于ApplicationContext容器,当容器启动结束后,便实例化所有的bean。 容器通过获取BeanDefinition对象中的信息进行实例化。并且这一步仅仅是简单的实例化,并未进行依赖注入。 实例化对象被包装在BeanWrapper对象中,BeanWrapper提供了设置对象属性的接口,从而避免了使用反射机制设置属性。

2、依赖注入

​ 实例化后的对象被封装在BeanWrapper对象中,并且此时对象仍然是一个原生的状态,并没有进行依赖注入。
紧接着,Spring根据BeanDefinition中的信息进行依赖注入。并且通过BeanWrapper提供的设置属性的接口完成依赖注入。

3、 注入Aware接口

​ 紧接着,Spring会检测该对象是否实现了xxxAware接口,并将相关的xxxAware实例注入给bean。

4、BeanPostProcessor

​ 当经过上述几个步骤后,bean对象已经被正确构造,但如果你想要对象被使用前再进行一些自定义的处理,就可以通过BeanPostProcessor接口实现。 该接口提供了两个函数:

  • postProcessBeforeInitialzation( Object bean, String beanName )
    当前正在初始化的bean对象会被传递进来,我们就可以对这个bean作任何处理。
    这个函数会先于InitialzationBean执行,因此称为前置处理。
    所有Aware接口的注入就是在这一步完成的。
  • postProcessAfterInitialzation( Object bean, String beanName )
    当前正在初始化的bean对象会被传递进来,我们就可以对这个bean作任何处理。
    这个函数会在InitialzationBean完成后执行,因此称为后置处理。

5. InitializingBean与init-method

​ 当BeanPostProcessor的前置处理完成后就会进入本阶段。 InitializingBean接口只有一个函数:

  • afterPropertiesSet()

这一阶段也可以在bean正式构造完成前增加我们自定义的逻辑,但它与前置处理不同,由于该函数并不会把当前bean对象传进来,因此在这一步没办法处理对象本身,只能增加一些额外的逻辑。
若要使用它,我们需要让bean实现该接口,并把要增加的逻辑写在该函数中。然后Spring会在前置处理完成后检测当前bean是否实现了该接口,并执行afterPropertiesSet函数。

当然,Spring为了降低对客户代码的侵入性,给bean的配置提供了init-method属性,该属性指定了在这一阶段需要执行的函数名。Spring便会在初始化阶段执行我们设置的函数。init-method本质上仍然使用了InitializingBean接口。

6. DisposableBean和destroy-method

​ 和init-method一样,通过给destroy-method指定函数,就可以在bean销毁前执行指定的逻辑。

img

==mysql数据库==

数据库引擎

数据库引擎主要分为InnodbMyIsam。其主要的不同有以下几点:

1、锁粒度,Innodb采用行锁,且仅仅在索引生效的时候起作用,其余时候退化为表锁。MyIsam采用表锁。

2、事务性,Innodb支持事务性,而MyIsam不支持事务。

3、外键,Innodb支持外键,而MyIsam不支持外键。

4、聚簇索引,Innodb支持聚簇索引,即索引和数据放在一起,不需要进行二次回表搜索。而MyIsam不支持。

MyIsam结构如下:

img

Innodb结构如下:

img

Mysql语句查询过程

以SELECT name,COUNT(name) AS num FROM student WHERE grade < 60 GROUP BY name HAVING num >= 2 ORDER BY num DESC,name ASC LIMIT 0,2 为例子。

1、加载内存,一条查询的sql语句先执行的是 FROM student 负责把数据库的表文件加载到内存中去,如图1.0中所示。(mysql数据库在计算机上也是一个进程,cpu会给该进程分配一块内存空间,在计算机‘服务’中可以看到,该进程的状态)

2、数据过滤,WHERE grade < 60,对数据进行过滤,取出符合条件的记录行,生成一张临时表。

3、临时表划分,GROUP BY name会把数据切分成若干临时表。

4、数据挑选,SELECT 的执行读取规则分为sql语句中有无GROUP BY两种情况。

(1)当没有GROUP BY时,SELECT 会根据后面的字段名称对内存中的一张临时表整列读取。

(2)当查询sql中有GROUP BY时,会对内存中的若干临时表分别执行SELECT,而且只取各临时表中的第一条记录,然后再形成新的临时表。这就决定了查询sql使用GROUP BY的场景下,SELECT后面跟的一般是参与分组的字段和聚合函数,否则查询出的数据要是情况而定。另外聚合函数中的字段可以是表中的任意字段,需要注意的是聚合函数会自动忽略空值。

5,二次过滤,HAVING num >= 2对数据再次过滤,与WHERE语句不同的是HAVING 用在GROUP BY之后,WHERE是对FROM student从数据库表文件加载到内存中的原生数据过滤,而HAVING 是对SELECT 语句执行之后的临时表中的数据过滤,所以说column AS otherName ,otherName这样的字段在WHERE后不能使用,但在HAVING 后可以使用。但HAVING的后使用的字段只能是SELECT 后的字段,SELECT后没有的字段HAVING之后不能使用。

6,数据排序,ORDER BY num DESC,name ASC对以上的临时表按照num,name进行排序。

7,限制获取,LIMIT 0,2取排序后的前两个。

连接语句

1、左外连接:是A和B的交集再并上A的所有数据。

2、右外连接:是A和B的交集再并上B的所有数据。

img

3、内连接,也就是返回两个表的交集(阴影)部分。

删除语句:

Delete :删除数据表中的行(可以删除某一行,也可以在不删除数据表的情况下删除所有行)。

Delete from table_name where col_name = XXX //删除某一行
Delete * from table_name

Drop:删除数据表或数据库,或删除数据表字段。

drop database db_name    //删除数据库
drop table table_name // 删除特定表
alter table db_name drop column col_name    //删除数据库表特定字段

Truncate:删除数据表中的数据(仅数据表中的数据,不删除表)。

truncate table table_name //删除表中数据

区别:删除数据的速度,一般来说: drop> truncate > delete。

  • DELETE 语句每次删除一行,并在事务日志中为所删除的每行记录一项。执行 DELETE 语句后,表仍会包含空页。
  • truncate和delete只删除表中的数据,drop会连表的结构一起删除。
  • delete是DML语句,操作会被放到 rollback segment中,事务提交后才生效,以便进行进行回滚操作(可恢复)。truncate、drop是DLL语句,操作立即生效,原数据不放到 rollback segment中,不能回滚(不可恢复)。
  • delete操作之后表或索引所占的空间不会减少,truncate操作之后表和索引所占用的空间会恢复到初始大小,drop语句将表所占用的空间全部释放。

删除表内部分数据用delete,删除表内所有数据并且保留表的结构用truncate,删除表或者库用drop

Explain SQL主要包含的信息如下(https://www.cnblogs.com/yhtboke/p/9467763.html):

这里写图片描述

id:select查询的序列号,包含一组数字,表示查询中执行select子句或操作表的顺序 .

Select_type:查询的类型,主要是用于区分普通查询、联合查询、子查询等复杂的查询

  1. SIMPLE:简单的select查询,查询中不包含子查询或者union
  2. PRIMARY:查询中包含任何复杂的子部分,最外层查询则被标记为primary
  3. SUBQUERY:在select 或 where列表中包含了子查询
  4. DERIVED:在from列表中包含的子查询被标记为derived(衍生),mysql或递归执行这些子查询,把结果放在零时表里
  5. UNION:若第二个select出现在union之后,则被标记为union;若union包含在from子句的子查询中,外层select将被标记为derived
  6. UNION RESULT:从union表获取结果的select

Table:指明查询语句是查询的哪一张表。

Type:访问类型,sql查询优化中一个很重要的指标,结果值从好到坏依次是:system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL,一般来说,好的sql查询至少达到range级别,最好能达到ref。

  1. system:表只有一行记录(等于系统表),这是const类型的特例,平时不会出现,可以忽略不计
  2. const:表示通过索引一次就找到了,const用于比较primary key 或者 unique索引。因为只需匹配一行数据,所以速度很快。如果将主键置于where列表中,mysql就能将该查询转换为一个const。
  3. eq_ref:唯一性索引扫描,对于每个索引键,表中只有一条记录与之匹配。常见于主键 或 唯一索引扫描。
  4. ref:非唯一性索引扫描,返回匹配某个单独值的所有行。本质是也是一种索引访问,它返回所有匹配某个单独值的行,然而他可能会找到多个符合条件的行,所以它应该属于查找和扫描的混合体
  5. range:只检索给定范围的行,使用一个索引来选择行。key列显示使用了那个索引。一般就是在where语句中出现了bettween、<、>、in等的查询。这种索引列上的范围扫描比全索引扫描要好。只需要开始于某个点,结束于另一个点,不用扫描全部索引。
  6. index:Full Index Scan,index与ALL区别为index类型只遍历索引树。这通常为ALL块,应为索引文件通常比数据文件小。(Index与ALL虽然都是读全表,但index是从索引中读取,而ALL是从硬盘读取)
  7. ALL:Full Table Scan,遍历全表以找到匹配的行。

possible_keys:查询涉及到的字段上存在索引,则该索引将被列出,但不一定被查询实际使用。

key:实际使用的索引,如果为NULL,则没有使用索引。 查询中如果使用了覆盖索引,则该索引仅出现在key列表中。

Key_len:表示索引中使用的字节数,查询中使用的索引的长度(最大可能长度),并非实际使用长度,理论上长度越短越好。key_len是根据表定义计算而得的,不是通过表内检索出的。

ref:显示索引的那一列被使用了,如果可能,是一个常量const。

row:根据表统计信息及索引选用情况,大致估算出找到所需的记录所需要读取的行数

Extra,不适合在其他字段中显示,但是十分重要的额外信息

1、Using filesort :
mysql对数据使用一个外部的索引排序,而不是按照表内的索引进行排序读取。也就是说mysql无法利用索引完成的排序操作成为“文件排序” 由于索引是先按email排序、再按address排序,所以查询时如果直接按address排序,索引就不能满足要求了,mysql内部必须再实现一次“文件排序”

2、Using temporary:
使用临时表保存中间结果,也就是说mysql在对查询结果排序时使用了临时表,常见于order by 和 group by

3、Using index:
表示相应的select操作中使用了覆盖索引(Covering Index),避免了访问表的数据行,效率高
如果同时出现Using where,表明索引被用来执行索引键值的查找
如果没用同时出现Using where,表明索引用来读取数据而非执行查找动作 覆盖索引(Covering Index):也叫索引覆盖。就是select列表中的字段,只用从索引中就能获取,不必根据索引再次读取数据文件,换句话说查询列要被所建的索引覆盖。
注意:
a、如需使用覆盖索引,select列表中的字段只取出需要的列,不要使用select *
b、如果将所有字段都建索引会导致索引文件过大,反而降低crud性能

4、Using where :
使用了where过滤

5、Using join buffer :
使用了链接缓存

6、Impossible WHERE:
where子句的值总是false,不能用来获取任何元祖

7、select tables optimized away:
在没有group by子句的情况下,基于索引优化MIN/MAX操作或者对于MyISAM存储引擎优化COUNT(*)操作,不必等到执行阶段在进行计算,查询执行计划生成的阶段即可完成优化

8、distinct:
优化distinct操作,在找到第一个匹配的元祖后即停止找同样值得动作

索引

​ 索引可以极大的提高查询访问速度,但是会降低插入,删除,更新表的速度,因为在执行写操作的时候还要操作索引文件。

单列索引

一个单列索引只包含一列,其又具体分为如下三个索引。

  • 普通索引:最基本的索引,其构造的基本SQL语句如下。
CREATE INDEX index_name
ON table_name (column_name)
  • 唯一索引:唯一索引,要求值不重复,可以为空。
CREATE UNIQUE INDEX index_name
ON table_name (column_name)
  • 主键索引:与唯一索引类似,但不允许有空值。

组合索引

​ 一个组合索引包含多列值,其创建的SQL语句如下:

CREATE INDEX index_name
ON table_name (column_name1, column_name2)

​ 组合索引的匹配服从“最左匹配”原则。如果你建立了组合索引(nickname,account,createdTime_Index),那么他实际包含的是3个索引 (nickname)、 (nickname,account) 、(nickname,account,created_time)。

聚簇索引/非聚簇索引:

1、聚簇索引就是按照每张表的主键构造一颗B+树,同时叶子节点中存放的是整张表的行记录数据,也将聚集索引的叶子节点称为数据页。

2、在聚簇索引之上创建的索引称之为辅助索引,辅助索引访问数据总是需要二次查找。辅助索引叶子节点存储的不再是行的物理位置,而是主键值。通过辅助索引首先找到的是主键值,再通过主键值找到数据行的数据页,再通过数据页中的Page Directory找到数据行。

索引数据结构

Mysql中采用的索引有B+树索引和Hash索引两种。

一、B+树索引,底层基于B+树实现,其是一种多叉的平衡树,之所以采用B+树,有以下几点原因:

​ 1、B+树支持范围索引,相比较B树在范围搜索的时候速度更快,同时由于信息只保存在叶子节点,因此搜索的效率比较稳定。(但需要注意B树单次的搜索平均是优于B+树的)。

​ 2、B+树树深较低大致为Logm(n),比起二叉树,红黑树深度要低,搜索速度更快。相较于B树,因为B树不管叶子节点还是非叶子节点,都会保存数据,这样导致在非叶子节点中能保存的指针数量变少,指针少的情况下要保存大量数据,只能增加树的高度,导致IO操作变多,查询性能变低;

二、哈希索引基于哈希表实现,只有精确匹配索引所有列的查询才有效。对于每一行数据,存储引擎都会对所有的索引列计算一个哈希码(hash code),哈希码是一个较小的值,并且不同键值的行计算出来的哈希码也不一样。哈希索引将所有的哈希码存储在索引中,同时在哈希表中保存指向每个数据行的指针。对于hash相同的,采用链表的方式解决冲突。在Mysql中InnoDB引擎有一个特殊的功能叫做自适应哈希索引,它会在内存中基于B-Tree索引的基础上面创建一个哈希索引,这让B-Tree索引具备了一些哈希索引的优点。

两者区别:B+树索引查询效率稳定,且支持范围查询。而哈希索引的查询一次只能查询一行数据,且不支持范围查询,但查询速度要优于B+树索引。

索引优化

最左匹配:组合索引的匹配服从“最左匹配”原则。如果你建立了组合索引(nickname,account,createdTime_Index),那么他实际包含的是3个索引 (nickname)、 (nickname,account) 、(nickname,account,created_time)。因此在实际使用中,尽可能按照最左匹配的顺序使用索引。

回表:回表的意思是首先根据普通索引数,找出对应的主键值,再根据这个主键的值去对应的聚簇索引的树上进行搜索,并找到对应的行信息。因此,非聚簇索引查询了两次树,而聚簇索引只查询了一次。

img

索引覆盖:如果索引的叶子节点包含了要查询的数据,那么就不用回表查询了,也就是说这种索引包含(亦称覆盖)所有需要查询的字段的值,我们称这种索引为覆盖索引

索引下推:当使用索引条件下推优化时,如果存在某些被索引的列的判断条件时,MySQL服务器将这一部分判断条件传递给存储引擎,然后由存储引擎通过判断索引是否符合MySQL服务器传递的条件,只有当索引符合条件时才会将数据检索出来返回给MySQL服务器。

索引失效

  1. 有or必全有索引;
  2. 复合索引未用左列字段,最左匹配原则。
  3. like以%开头,最左匹配原则。
  4. 需要类型转换;
  5. where中索引列有运算或函数;
  6. 如果mysql觉得全表扫描更快时(数据少);
  7. 如果索引存在空数据,则会失效。

事务及四大特性

事务:是作为一个单元的一组有序的数据库操作。如果组中的所有 操作都成功,则认为事务成功,即使只有一个操作失败,事务也不成功,并进行回滚撤销对数据库造成的影响。

1、原子性(Atomicity),mysql采用undo log实现,对没有完成的事务进行回滚。undo log 叫做回滚日志,用于记录数据被修改前的信息。他正好跟前面所说的重做日志所记录的相反,重做日志记录数据被修改后的信息。

2、一致性(Consistency),一致性由其余三大属性一起形成。

3、隔离性(Isolation),锁+MVCC多版本并发控制

4、持久性(Durability)。mysql提供了一个缓冲池buffer用以加快数据的检索,当从数据库读取数据时,会首先从Buffer Pool中读取,如果Buffer Pool中没有,则从磁盘读取后放入Buffer Pool;当向数据库写入数据时,会首先写入Buffer Pool,Buffer Pool中修改的数据会定期刷新到磁盘中(这一过程称为刷脏)。

Redo log

​ 于是,redo log被引入来解决这个问题:当数据修改时,除了修改Buffer Pool中的数据还会在redo log记录这次操作;当事务提交时,会调用fsync接口对redo log进行刷盘。如果MySQL宕机,重启时可以读取redo log中的数据,对数据库进行恢复。innodb通过force log at commit机制实现事务的持久性,即在事务提交的时候,必须先将该事务的所有事务日志写入到磁盘上的redo log file中进行持久化。这样可以通过读取redo log来避免在宕机(即红色线数据丢失)的情况下,损失一些已经提交的事物。(先执行第七步再执行红线部分)

img

为什么写入redo log会比将redo log buffer写入磁盘要快呢?

(1)刷脏是随机IO,因为每次修改的数据位置随机,但写redo log是追加操作,属于顺序IO。
(2)刷脏是以数据页(Page)为单位的,MySQL默认页大小是16KB,一个Page上一个小修改都要整页写入;而redo log中只包含真正需要写入的部分,无效IO大大减少。

Binlog:Mysql binlog是二进制日志文件,用于记录mysql的所有的数据更新或者潜在更新(比如DELETE语句执行删除而实际并没有符合条件的数据),在mysql主从复制中就是依靠的binlog。binlog有以下三种模式:

  • row模式:日志中会记录每一行数据被修改的形式,然后在 slave 端再对相同的数据进行修改。row 模式只记录要修改的数据,只有 value,不会有 sql 多表关联的情况。
  • statement模式:每一条修改数据的 sql 都会记录到 master 的 binlog 中,slave 在复制的时候,sql 进程会解析成和原来在 master 端执行时的相同的 sql 再执行。(优势是:只记录进行修改的SQL语句,而不是记录修改后对应的行,因此保存的数据量比row少。)
  • mixed模式:Mixed,实际上就是前两种模式的结合。在 Mixed 模式下,MySQL 会根据执行的每一条具体的 SQL 语句来区分对待记录的日志形式,也就是在 statement 和 row 之间选择一种。

Undo log:

​ undo log和redo log记录物理日志不一样,它是逻辑日志。可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录。当执行rollback时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚。有时候应用到行版本控制的时候,也是通过undo log来实现的:当读取的某一行被其他事务锁定时,它可以从undo log中分析出该行记录以前的数据是什么,从而提供该行版本信息,让用户实现非锁定一致性读取。

undo log是采用段(segment)的方式来记录的,每个undo操作在记录的时候占用一个undo log segment。innodb存储引擎对undo的管理采用段的方式。rollback segment称为回滚段,每个回滚段中有1024个undo log segment

隔离级别

未提交读

解决了更新丢失的问题,如果一个事务已经开始写数据,则另外一个事务不允许同时进行写操作,但允许其他事务读此行数据,该隔离级别可以通过“排他写锁”,但是不排斥读线程实现。但可能出现脏读

已提交读(Oracle默认)

如果是一个读事务(线程),则允许其他事务读写,如果是写事务将会禁止其他事务访问该行数据,已提交读通过对数据的版本进行校验解决了脏读问题,但是已提交读存在不可重复读

可重复读(mysql默认级别)

可重复读取是指在一个事务内,多次读同一个数据,在这个事务还没结束时,其他事务不能访问该数据(包括了读写),这样就可以在同一个事务内两次读到的数据是一样的,因此称为是可重复读隔离级别,读取数据的事务将会禁止写事务(但允许读事务),写事务则禁止任何其他事务(包括了读写),这样避免了不可重复读和脏读,但是有时可能会出现幻读。可重复读的主要实现方式是采用MVCC+Next-Key-Lock

可序列化

提供严格的事务隔离,它要求事务序列化执行,事务只能一个接着一个地执行,但不能并发执行,如果仅仅通过“行级锁”是无法实现序列化的,必须通过其他机制保证新插入的数据不会被执行查询操作的事务访问到。序列化是最高的事务隔离级别,同时代价也是最高的,性能很低。可序列化的主要实现方式是将所有的查询语句修改成对应的select in share mode,来使得事务串型执行。

在这里插入图片描述

Mysql锁相关

1、记录锁(行锁):记录锁是 封锁记录,记录锁也叫行锁,例如:

SELECT * FROM `test` WHERE `id`=1 FOR UPDATE;

它会在 id=1 的记录上加上记录锁,以阻止其他事务插入,更新,删除 id=1 这一行。for update是一种行级锁,又叫排它锁,一旦用户对某个行施加了行级加锁,则该用户可以查询也可以更新被加锁的数据行,其它用户只能查询但不能更新被加锁的数据行.如果其它用户想更新该表中的数据行,则也必须对该表施加行级锁.即使多个用户对一个表均使用了共享更新,但也不允许两个事务同时对一个表进行更新,真正对表进行更新时,是以独占方式锁表,一直到提交或复原该事务为止。行锁永远是独占方式锁。只有当出现如下之一的条件,才会释放共享更新锁:

  • 1、执行提交(COMMIT)语句
  • 2、退出数据库(LOG OFF)
  • 3、程序停止运行

2、间隙锁(Gap Locks),是封锁索引记录中的间隔,或者第一条索引记录之前的范围,又或者最后一条索引记录之后的范围。但是间隙锁不是排他锁,有可能出现死锁的情况。

3、Next Key Lock(行锁+间隙锁),每个 next-key lock 是前开后闭区间。即包含了记录锁和间隙锁,即锁定一个范围,并且锁定记录本身。

1、当前读
像select lock in share mode(共享锁), select for update ; update, insert ,delete(排他锁)这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。

2、快照读

​ 像不加锁的select操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本

Mysql死锁情况

在数据库中有两种基本的锁类型:排它锁(Exclusive Locks,即X锁)共享锁(Share Locks,即S锁)。当数据对象被加上排它锁时,其他的事务不能对它读取和修改。加了共享锁的数据对象可以被其他事务读取,但不能修改。数据库利用这两种基本的锁类型来对数据库的事务进行并发控制。

死锁的第一种情况(访问表顺序造成死锁),一个用户A访问表A(锁住了表A),然后又访问表B;另一个用户B 访问表B(锁住了表B),然后企图访问表A;这时用户A由于用户B已经锁住表B,它必须等待用户B释放表B才能继续,同样用户B要等用户A释放表A才能继续,这就死锁就产生了。

解决方法:这种死锁比较常见,是由于程序的BUG产生的,除了调整的程序的逻辑没有其它的办法。仔细分析程序的逻辑,对于数据库的多表操作时,尽量按照相同的顺序进行处理,尽量避免同时锁定两个资源,如操作A和B两张表时,总是按先A后B的顺序处理, 必须同时锁定两个资源时,要保证在任何时刻都应该按照相同的顺序来锁定资源。

死锁的第二种情况(共享锁升级成独占锁),用户A查询一条纪录,然后修改该条纪录;这时用户B修改该条纪录,这时用户A的事务里锁的性质由查询的共享锁企图上升到独占锁,而用户B里的独占锁由于A有共享锁存在所以必须等A释放掉共享锁,而A由于B的独占锁而无法上升的独占锁也就不可能释放共享锁,于是出现了死锁。这种死锁比较隐蔽,但在稍大点的项目中经常发生。如在某项目中,页面上的按钮点击后,没有使按钮立刻失效,使得用户会多次快速点击同一按钮,这样同一段代码对数据库同一条记录进行多次操作,很容易就出现这种死锁的情况。

解决方法:

  • 对于按钮等控件,<stron>,不让用户重复点击,避免对同时对同一条记录操作。</stron>
  • 使用乐观锁进行控制。乐观锁大多是基于数据版本(Version)记录机制实现。即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个“version”字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。乐观锁机制避免了长事务中的数据库加锁开销(用户A和用户B操作过程中,都没有对数据库数据加锁),大大提升了大并发量下的系统整体性能表现。Hibernate 在其数据访问引擎中内置了乐观锁实现。需要注意的是,由于乐观锁机制是在我们的系统中实现,来自外部系统的用户更新操作不受我们系统的控制,因此可能会造 成脏数据被更新到数据库中。
  • 使用悲观锁进行控制。悲观锁大多数情况下依靠数据库的锁机制实现,如Oracle的Select … for update语句,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。如一个金融系统, 当某个操作员读取用户的数据,并在读出的用户数据的基础上进行修改时(如更改用户账户余额),如果采用悲观锁机制,也就意味着整个操作过程中(从操作员读 出数据、开始修改直至提交修改结果的全过程,甚至还包括操作员中途去煮咖啡的时间),数据库记录始终处于加锁状态,可以想见,如果面对成百上千个并发,这样的情况将导致灾难性的后果。所以,采用悲观锁进行控制时一定要考虑清楚。

死锁的第三种情况(行锁上升为表锁)

如果在事务中执行了一条不满足条件的update语句,则执行全表扫描,把行级锁上升为表级锁,多个这样的事务执行后,就很容易产生死锁和阻塞。类似的情况还有当表中的数据量非常庞大而索引建的过少或不合适的时候,使得经常发生全表扫描,最终应用系统会越来越慢,最终发生阻塞死锁

解决方法:SQL语句中不要使用太复杂的关联多表的查询;使用“执行计划”对SQL语句进行分析,对于有全表扫描的SQL语句,建立相应的索引进行优化。

MVCC(多版本并发控制)

MVCC(多版本并发控制),是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。 它的实现原理主要是依赖记录中的 隐式字段,undo日志 ,Read View 来实现的。

一、隐式字段:

每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段

  • TRX_ID,6byte,最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID
  • ROLL_PTR,7byte,回滚指针,指向这条记录的上一个版本(存储于rollback segment里)
  • ROW_ID,6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引
  • 实际还有一个删除flag隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除flag变了
在这里插入图片描述

查询一条记录的流程大致如下:

  1. 如果被访问版本的trx_id=creator_id,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
  2. 如果被访问版本的trx_id<min_trx_id,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。
  3. 被访问版本的trx_id>=max_trx_id,表明生成该版本的事务在当前事务生成ReadView后才开启,该版本不可以被当前事务访问。如果数据不可以被访问,则会按照roll_pointer查找上一个版本的记录。
  4. 被访问版本的trx_id是否在m_ids列表中。
    4.1 是,创建ReadView时,该版本还是活跃的,该版本不可以被访问。顺着版本链找下一个版本的数据,继续执行上面的步骤判断可见性,如果最后一个版本还不可见,意味着记录对当前事务完全不可见
    4.2 否,创建ReadView时,生成该版本的事务已经被提交,该版本可以被访问

二、undo日志

undo log主要分为两种:

  • insert undo log
    代表事务在insert新记录时产生的undo log,只在事务回滚时需要,并且在事务提交后可以被立即丢弃
  • update undo log
    事务在进行update或delete时产生的undo log; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被purge线程统一清除

三、Read View(读视图)

​ Read View就是事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID。主要是用来做可见性判断的,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。

数据库三大范式

第一范式(1NF)必须不包含重复组的关系,即每一列都是不可拆分的原子项

缺点:存在数据冗余、更新异常、插入异常、删除异常。因此需要进一步采用更高级的范式。

img

第二范式(2NF):关系模式必须满足第一范式,并且所有非主属性都完全依赖于主码,但是可以有传递依赖。注意,符合第二范式的关系模型可能还存在数据冗余、更新异常等问题。举例如关系模型(职工号,姓名,职称,项目号,项目名称)中,职工号->姓名,职工号->职称,而项目号->项目名称。显然依赖关系不满足第二范式,常用的解决办法是差分表格,比如拆分为职工信息表和项目信息表。

第三范式(3NF)关系模型满足第二范式,所有非主属性对任何候选关键字都不存在传递依赖。即每个属性都跟主键有直接关系而不是间接关系,像:a-->b-->c。

​ 比如Student表(学号,姓名,年龄,性别,所在院校,院校地址,院校电话)这样一个表结构,就存在上述关系。 学号--> 所在院校 --> (院校地址,院校电话)。我们应该拆开来,如下:(学号,姓名,年龄,性别,所在院校)--(所在院校,院校地址,院校电话)

BC范式(BCNF)是指在满足第三范式的前提下,每一个决定因素都包含码。 根据定义我们可以得到结论,一个满足BC范式的关系模式有:

  1. 所有非主属性对每一个码都是完全函数依赖;
  2. 所有主属性对每一个不包含它的码也是完全函数依赖;
  3. 没有任何属性完全函数依赖于非码的任何一组属性。

第四范式(4NF)

定义: 限制关系模式的属性之间不允许有非平凡且非函数依赖的多值依赖。

理解: 显然一个关系模式是4NF,则必为BCNF。也就是说,当一个表中的非主属性互相独立时(3NF),这些非主属性不应该有多值,若有多值就违反了4NF。

第五范式(5NF)投影联合范式(PJ / NF) 。 它旨在通过以多种格式分隔语义连接的关系来存储多值事实,以最大程度地减少关系数据库中的冗余。

==设计模式==

单例模式(懒汉型线程安全)

//懒汉单例模式,线程安全,但是性能很低。
public class SingletonModel {
    private static SingletonModel single ;
    private SingletonModel(){ } //虚化从而使得
    public synchronized static SingletonModel GetSingletonModel() {
        if (single == null){
            single = new SingletonModel();
        }
        return single;
    }
}

//懒汉单例模式,双重校验模式DCL。 保持高并发且是Lazy Loading。
public class SingletonModel {
    private static SingletonModel single ;
    private SingletonModel(){ } //虚化从而使得
    public  static SingletonModel GetSingletonModel() {
        if (single == null){
            synchronized (SingletonModel.class){
                if (single == null){
                    single = new SingletonModel();
                }
            }
        }
        return single;
    }
}

单例模式(饿汉型线程安全)

//饿汉直接是线程安全的。
public class SingletonModel {
    private static SingletonModel single = new SingletonModel();
    private SingletonModel(){ } //虚化从而使得
    public static SingletonModel GetSingletonModel() {
        return single;
    }
}

工厂模式:

//工厂模式代码,主要的思想是采用接口来接受对应的对象。
interface Shape{
    public void draw();
}

class Rectangle implements Shape{
    @Override
    public void draw() {
        //实现对应的画形状接口
        System.out.println("画矩形");
    }
}

class Cycle implements Shape{
    @Override
    public void draw() {
        //画圆形
        System.out.println("画圆形");
    }
}

class Square implements Shape{
    @Override
    public void draw() {
        //画正方形
        System.out.println("画正方形");
    }
}

class ShapeFactory{
    public Shape getShape(String str){
        switch (str) {
            case "Cycle":
                return new Cycle();
            case "Square":
                return new Square();
            case "Rectangle":
                return new Rectangle();
        }
        return null;
    }
}
public class Factory{
    public static void main(String[] args) {
        ShapeFactory sf = new ShapeFactory();
        Shape s = sf.getShape("Cycle");
        Shape s1 = sf.getShape("Rectangle");
        Shape s2 = sf.getShape("Square");
        s.draw();
        s1.draw();
        s2.draw();
    }
}

抽象工厂模式:

观察者模式:==?==

//观察者模式代码

策略模式:

里氏替换原则

定义:子类对象(object ofsubtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

​ 子类在设计的时候,要遵守父类的行为约定。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。

==多线程实例==

消费者生产者模型

具体代码如下:(需要学会秒写)

//生产者消费者模型,编写思路1、首先需要写对应的资源互斥区storage;2、紧接着需要写资源互斥区中get和set方法。
//3、紧接着再写Producer线程和Customer线程即可.
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedList;
import java.util.List;

class storage{
    //资源互斥区域
    Deque<Integer> items= new LinkedList<>();
    int size= 10;
    int i=0;
    public synchronized int get(){
            while(items.size()<=0){
                try {
                    wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            notifyAll();
            return items.pollFirst();
    }

    public synchronized int set(){
            while (items.size()>=size){
                try {
                    wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            items.addLast(i++);
            notifyAll();
            return i-1;
    }
}

class producer extends Thread{
    storage s;
    producer(storage s) {
        this.s = s;
    }

    @Override
    public void run() {
        for (int i=0;i<10;i++){
            System.out.println(Thread.currentThread().getName()+" puts "+ s.set());
        }
    }
}

class customer extends Thread{
    storage s;
    customer(storage s){
        this.s = s;
    }

    @Override
    public void run() {
        for (int i=0;i<10;i++){
            System.out.println(Thread.currentThread().getName()+" gets "+s.get());
            //睡一段时间防止多次读取
        }
    }
}

public class ProducerAndCustomer {
    public static void main(String[] args) {
        storage s = new storage();
        customer c= new customer(s);
        producer p = new producer(s);
        customer c1 = new customer(s);
        producer p1 = new producer(s);
        c.start();
        c1.start();
        p.start();
        p1.start();
    }
}

线程交替打印

import java.util.concurrent.*;
public class ThreadABC {
    public static class ThreadPrinter implements Runnable {
        private String name;
        private Object prev;
        private Object self;

        ThreadPrinter(String name,Object prev,Object self){
            this.name = name;
            this.prev = prev;
            this.self = self;
        }
        @Override
        public void run() {
            int count=10;
            while(count>0){
                synchronized (prev){
                    synchronized (self){
                        System.out.print(name);
                        count--;
                        self.notifyAll();
                    }
                    if(count==0){
                        prev.notifyAll();
                    }else{
                        try {
                            if (count == 0) {// 如果count==0,表示这是最后一次打印操作,通过notifyAll操作释放对象锁。
                                prev.notifyAll();
                            } else {
                                prev.wait(); // 立即释放 prev锁,当前线程休眠,等待唤醒
                            }
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        Object a = new Object();
        Object b = new Object();
        Object c = new Object();
        Thread pa = new Thread(new ThreadPrinter("A", c, a));
        Thread pb = new Thread(new ThreadPrinter("B", a, b));
        Thread pc = new Thread(new ThreadPrinter("C", b, c));
        pa.start();
        Thread.sleep(10);
        pb.start();
        Thread.sleep(10);
        pc.start();
        Thread.sleep(10);
    }
}

线程交替打印变形(一)

线程一打印1,2,3,4.......

线程二打印A,B,C,D.......

要求最后的输出结果为:1A2B3C4D......

import java.util.Scanner;
class storage{
    boolean produce = true;
    public synchronized void producenums(int i){
        while(!produce){
            try {
                wait();
            }
            catch(Exception ignored){
            }
        }
        System.out.println(i);
        produce = false;
        notifyAll();
    }

    public synchronized void produceletters(char c){
        while(produce){
            try {
                wait();
            }
            catch(Exception ignored){
            }
        }
        System.out.println(c);
        produce = true;
        notifyAll();
    }
}

class Threadnums extends Thread{
    storage s;
    Threadnums(storage s){
        this.s = s;
    }

    @Override
    public void run(){
        for(int i=0;i<100;i++){
            s.producenums(i+1);
        }
    }
}

class Threadletters extends Thread{
    storage s;
    char a = 'A';
    Threadletters(storage s){
        this.s = s;
    }

    @Override
    public void run(){
        for(int i=0;i<100;i++){
            s.produceletters((char) (a+i%26));
        }
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        storage s = new storage();
        Threadnums n = new Threadnums(s);
        Threadletters l = new Threadletters(s);
        n.start();
        Thread.sleep(3);
        l.start();
    }
}

==Linux==

常见命令:

-wc :统计指定文件中的字节数、字数、行数,并将统计结果显示输出。

-ps:用于报告当前系统的进程状态。使用该命令可以确定有哪些进程正在运行和运行的状态、进程是否结束、进程有没有僵死、哪些进程占用了过多的资源等等,总之大部分信息都是可以通过执行该命令得到的。命令参数的选项如下:

  • a:显示现行终端机下的所有程序,包括其他用户的程序。
  • u:以用户为主的格式来显示系统状况。
  • x:显示所有程序,包括历史进程。
  • -e:显示所有进程(同a)
  • -f:显示UID、PPIP、C与STIME栏
  • -l:显示进程详细信息

-lsof:用于查看你进程开打的文件,打开文件的进程,进程打开的端口(TCP、UDP)。找回/恢复删除的文件。是十分方便的系统监视工具,因为 lsof 需要访问核心内存和各种文件,所以需要root用户执行。

==git==

常见命令:

-git pull:命令用于从远程获取代码并合并本地的版本。

-git fetch :命令用于从远程获取代码库。但是不与当前的代码进行合并。

-git push:命令用于从将本地的分支版本上传到远程并合并。

-git add . :他会监控工作区的状态树,使用它会把工作时的所有变化提交到暂存区,包括文件内容修改(modified)以及新文件(new),但不包括被删除的文件。

Git 与 SVN 区别

  • 1、Git 是分布式的,SVN 不是:这是 Git 和其它非分布式的版本控制系统,例如 SVN,CVS 等,最核心的区别。
  • 2、Git 把内容按元数据方式存储,而 SVN 是按文件:所有的资源控制系统都是把文件的元信息隐藏在一个类似 .svn、.cvs 等的文件夹里。
  • 3、Git 分支和 SVN 的分支不同:分支在 SVN 中一点都不特别,其实它就是版本库中的另外一个目录。
  • 4、Git 没有一个全局的版本号,而 SVN 有:目前为止这是跟 SVN 相比 Git 缺少的最大的一个特征。
  • 5、Git 的内容完整性要优于 SVN:Git 的内容存储使用的是 SHA-1 哈希算法。这能确保代码内容的完整性,确保在遇到磁盘故障和网络问题时降低对版本库的破坏。

==Mybatis框架==

Mybatis原理

​ 1、mybatis应用程序通过SqlSessionFactoryBuilder从mybatis-config.xml配置文件(也可以用Java文件配置的方式,需要添加@Configuration)来构建SqlSessionFactory(SqlSessionFactory是线程安全的);

​ 2、然后,SqlSessionFactory的实例直接开启一个SqlSession,再通过SqlSession实例获得Mapper对象并运行Mapper映射的SQL语句,完成对数据库的CRUD和事务提交,之后关闭SqlSession。

详细流程如下:

1、加载mybatis全局配置文件(mybatis-config.xml),解析配置文件,MyBatis基于XML配置文件生成Configuration,和一个个MappedStatement(包括了参数映射配置、动态SQL语句、结果映射配置),其对应着<select | update | delete | insert>标签项。

2、SqlSessionFactoryBuilder通过Configuration对象生成SqlSessionFactory,用来开启SqlSession。

3、SqlSession对象完成和数据库的交互:
a、用户程序调用mybatis接口层api(即Mapper接口中的方法)

​ b、SqlSession通过调用api的Statement ID找到对应的MappedStatement对象

​ c、通过Executor(负责动态SQL的生成和查询缓存的维护)将MappedStatement对象进行解析,sql参数转化、动态sql拼接,生成jdbc Statement对象

​ d、JDBC执行sql。

​ e、借助MappedStatement中的结果映射关系,将返回结果转化成HashMap、JavaBean等存储结构ResultSet并返回。

img

mybatis-config.xml

其中主要包含driver(数据库驱动)、url(数据库地址)、username(连接用户名)、password(用户密码)

查询分页

​ 例如在数据库的某个表里有1000条数据,我们每次只显示100条数据,在第1页显示第0到第99条,在第2页显示第100到199条,依次类推,这就是分页。mybatis的分页主要依靠PageInterceptor插件实现,插件的原理主要基于拦截器实现,实现方法有三种,如下:

1、采用mysql自带的limit语句(物理分页),可以对数据进行分页,但是比较麻烦。

<select id="queryStudentsBySql" parameterType="map" resultMap="studentmapper"> 
           select * from student limit #{currIndex} , #{pageSize}
</select>

2、采用自定义插件拦截语句并修改(物理分页),即分页拦截器(paginationInterceptor)的方式,分页插件的基本原理是使用Mybatis提供的插件接口,实现自定义插件,在插件的拦截方法内拦截待执行的sql,然后重写sql,根据dialect方言,添加对应的物理分页语句和物理分页参数。举例:select * from student,拦截sql后重写为:select t.* from (select * from student)t limit 0,10

3、RowBounds分页(逻辑分页),Mybatis使用RowBounds对象进行分页,它是针对ResultSet结果集执行的内存分页,而非物理分页,可以在sql内直接书写带有物理分页的参数来完成物理分页功能,也可以使用分页插件来完成物理分页。

==Redis框架==

Redis底层原理:==?==

Redis集群数据一致性:==?==

缓存数据一致性

https://developer.aliyun.com/article/712285

首先明确只有在执行写命令的时候会出现缓存和数据库不一致的情况,那么首先第一个问题是:

更新cache还是淘汰cache?

  1. 淘汰cache:
    优点:操作简单,无论更新操作是否复杂,直接将缓存中的旧值淘汰
    缺点:淘汰cache后,下一次查询无法在cache中查到,会有一次cache miss,这时需要重新读取数据库

  2. 更新cache:
    更新chache的意思就是将更新操作也放到缓冲中执行,并不是数据库中的值更新后再将最新值传到缓存
    优点:命中率高,直接更新缓存,不会有cache miss的情况
    缺点:更新cache消耗较大当更新操作简单,如只是将这个值直接修改为某个值时,更新cache与淘汰cache的消耗差不多但当更新操作的逻辑较复杂时,需要涉及到其它数据,如用户购买商品付款时,需要考虑打折等因素,这样需要缓存与数据库进行多次交互,将打折等信息传入缓存,再与缓存中的其它值进行计算才能得到最终结果,此时更新cache的消耗要大于直接淘汰cache。

    ​ 且更新cache也可能带来数据不安全的情况,因此更推荐使用淘汰缓存。如果之后需要再次读取这个数据,最多会有一次缓存失败

根据先更新数据库还是淘汰缓存可以分为两种方案:

1、先淘汰缓存,再更新数据库

​ 如果第一步淘汰缓存成功,第二步更新数据库失败,此时再次查询缓存,最多会有一次cache miss。如果更新数据库失败其实也是一个问题,为了确保数据库中的数据被正常更新,需要“重试机制”,即当数据库中的数据更新失败后,也需要人工或业务代码再次重试,直到更新成功。进一步的,在并发情况下可能导致数据的不一致。

  • 采用同步更新缓存的策略,该方案可能会导致cache与数据库的数据一直或很长时间不一致。因此需要采用串行化或者延时双删+设置缓存的超时时间的方式。延时双删:A线程进行写操作,先成功淘汰缓存,但由于网络或其它原因,还未更新数据库或正在更新,B线程进行读操作,从数据库中读入旧数据,共耗时N秒。在B线程将旧数据读入缓存后,A线程将数据更新完成,此时数据不一致。A线程将数据库更新完成后,休眠M秒(M比N稍大即可),然后再次淘汰缓存,此时缓存中即使有旧数据也会被淘汰,此时可以保证数据的一致性。其它线程进行读操作时,缓存中无数据,从数据库中读取的是更新后的新数据
  • 采用异步更新缓存的策略,线程只是从数据库中读取想要的数据,并不将这个数据放入缓存中,所以并不会导致缓存与数据库的不一致。在线程更新数据库后,通过订阅binlog来异步更新缓存,数据库与缓存的内容将一直一致。保证了数据的一致性,适用于对一致性要求高的业务

2、先更新数据库,再淘汰缓存
如果第一步更新数据库成功,第二部淘汰缓存失败,则会出现数据库中是新数据,缓存中是旧数据,即数据不一致。解决办法:为确保缓存删除成功,需要用到“重试机制”,即当删除缓存失效后,返回一个错误,由业务代码再次重试,直到缓存被删除。该方案即使在高并发下,也不存在长时间的数据缓存不一致的情况。但以防万一也会采用延时双删重试机制保证了数据读取的效率,如果业务对一致性要求不是很高,这种方案最合适

Redis事务

redis一个事务从开始到结束通常会经历以下三个阶段:

1.事务开始:MULTI,MULTI 命令的执行标志着事务的开始:

2.命令入队:QUEUED状态,当一个客户端处于非事务状态时,这个客户端发送的命令会立即被服务器执行,但是当一个客户端切换到事务状态之后,服务器会根据这个客户端发来的不同命令执行不同的操作:(如图 1-2-1)

如果客户端发送的命令是 EXEC、DISCARD、WATCH、MULTI 四个命令的其中一个,那么服务器立即执这个命令;
如果客户端发送的命令是 EXEC、DISCARD、WATCH、MULTI 四个命令以外的其他命令,那么服务器并不立即执行这个命令,而是将这个命令放入一个事务队列里,然后向客户端返回 QUEUED 回复。

img

3.事务执行:EXEC

redis的事务如果执行失败是不需要回滚的,原因有以下两点:

  • 只有当发生语法错误(这个问题在命令队列时无法检测到)了,Redis命令才会执行失败, 或对keys赋予了一个类型错误的数据:这意味着这些都是程序性错误,这类错误在开发的过程中就能够发现并解决掉,几乎不会出现在生产环境。
  • 由于不需要回滚,这使得Redis内部更加简单,而且运行速度更快。

Redis如何实现分布式锁:

1、加锁

加锁实际上就是在redis中,给Key键设置一个值,为避免死锁,并给定一个过期时间。

SET lock_key random_value NX PX 5000

值得注意的是:
random_value 是客户端生成的唯一的字符串。
NX 代表只在键不存在时,才对键进行设置操作。
PX 5000 设置键的过期时间为5000毫秒。

这样,如果上面的命令执行成功,则证明客户端获取到了锁。

2、解锁

解锁的过程就是将Key键删除。但也不能乱删,不能说客户端1的请求将客户端2的锁给删除掉。这时候random_value的作用就体现出来。

为了保证解锁操作的原子性,我们用LUA脚本完成这一操作。先判断当前锁的字符串是否与传入的值相等,是的话就删除Key,解锁成功。

    public boolean unlock(String id){
        Jedis jedis = jedisPool.getResource();
        String script =
                "if redis.call('get',KEYS[1]) == ARGV[1] then" +
                        "   return redis.call('del',KEYS[1]) " +
                        "else" +
                        "   return 0 " +
                        "end";
        try {
            Object result = jedis.eval(script, Collections.singletonList(lock_key), 
                                    Collections.singletonList(id));
            if("1".equals(result.toString())){
                return true;
            }
            return false;
        }finally {
            jedis.close();
        }
    }

上述的方法比较简单,但是问题也比较大,最重要的一点是,锁不具有可重入性。

redisson

​ redisson相比较上述的算法,功能更强大,其可以提供可重入锁的操作。主要的操作逻辑如下:

image-20210415164249553

解锁的主要流程如下:

img

Redis数据结构:

我们平时主要是通过操作对象的api来操作redis,而不是通过它的调用它底层数据结构来完成(外观模式)。但我们还需要了解其底层,只有这样才能写最优化高效的代码。五种基本数据类型如下:

1、List

  • list类型的value对象内部以linkedlist或ziplist承载。当list的元素个数和单个元素的长度较小时,redis会采用ziplist实现以减少内存占用,否则采用linkedlist结构
  • linkedlist内部实现是双向链表。在list中定义了头尾元素指针和列表的长度,是的pop/push操作、llen操作的复杂度为O(1)。由于是链表,lindex类的操作复杂度仍然是O(N)
  • ziplist的内部结构
    • 所有内容被放置在连续的内存中。其中zlbytes表示ziplist的总长度,zltail指向最末元素,zllen表示元素个数,entry表示元素自身内容,zlend作为ziplist定界符
    • rpush、rpop、llen,复杂度为O(1);lpush/pop操作由于涉及全列表元素的移动,复杂度为O(N)

2、String

最大可以存放512MB大小的字符串。字符串编码有三个:int、raw、embstr。

  • 能表达3中类型:字符串、整数和浮点数。根据场景相互间自动转型,并且根据需要选取底层的承载方式
  • value内部以int、sds作为结构存储。int存放整型数据,sds存放字节/字符串和浮点型数据
  • sds内部结构:
    • buf数组存储字符串的内容,但数组的长度会大于所存储内容的长度。会有一格专门存放”\0”(C标准库)作为结尾,还有预留多几个空的(即free区域),当append字符串的长度小于free区域,则sds不会重新申请内存,直接使用free区域
    • 扩容:当对字符串的操作完成后预期的串长度小于1M时,扩容后的buf数组大小=预期长度*2+1;若大于1M,则buf总是会预留出1M的free空间
    • value对象通常具有两个内存部分:redisObject部分和redisObject的ptr指向的sds部分。创建value对象时,通常需要为redisObject和sds申请两次内存。单对于短小的字符串,可以把两者连续存放,所以可以一次性把两者的内存一起申请了。
//字符串的底层源码
struct sdshdr {
    // 用于记录buf数组中使用的字节的数目和SDS存储的字符串的长度相等
    int len;
    // 用于记录buf数组中没有使用的字节的数目
    int free;
    // 字节数组,用于储存字符串
    char buf[];
};

//优点1、常数复杂度获取字符串长度
//优点2、杜绝缓冲区溢出
//优点3、减少了内存重新分配的次数

3、Hash

hash结构不能为每个key单独设置过期时间,主要编码方式是ziplisthashtable

  • Hash内部的key和value不能再嵌套hash了,只能是string类型:整形、浮点型和字符串
  • Hash主要由hashtable和ziplist两种承载方式实现,对于数据量较小的hash,采用ziplist实现。
  • hashtable内部结构
    • 主要分为三层,自底向上分别是dictEntry、dictht、dict
    • dictEntry:管理一个key-value对,同时保留同一个桶中相邻元素的指针,一次维护哈希桶的内部连
    • dictht:维护哈希表的所有桶链
    • dict:当dictht需要扩容/缩容时,用于管理dictht的迁移
    • 哈希表的核心结构是dictht,它的table字段维护着hash桶,它是一个数组,每个元素指向桶的第一个元素(dictEntry)
    • set值的流程:先通过MurmurHash算法求出key的hash值,再对桶的个数取模,得到key对应的桶,再进入桶中,遍历全部entry,判定是否已有相同的key,如果没有,则将新key对应的键值对插入到桶头,并且更新dictht的used数量,used表示hash表中已经存了多少元素。由于每次插入都要遍历hash桶中的全部entry,所以当桶中entry很多时,性能会线性下降
    • 扩容:通过负载因子判定是否需要增加桶数。有两个阈值,小于1一定不扩容;大于5一定扩容。扩容时新的桶数目是现有桶的2n倍
    • 缩容:负载因子的阈值是0.1
    • 扩/缩容通过新建哈希表的方式实现。即扩容时,会并存两个哈希表,一个是源表,一个是目标表。通过将源表的桶逐步迁移到目标表,以数据迁移的方式实现扩容,迁移完成后目标表覆盖源表。迁移过程中,首先访问源表,如果发现key对应的源表桶已完成迁移,则重新访问目标表,否则在源表中操作。
    • redis是单线程处理请求,迁移和访问的请求在相同线程内进行,所以不会存在并发性问题
    • ziplist内部结构
      • 和list的ziplist实现类似。不同的是,map对应的ziplist的entry个数总是2的整数倍,奇数存放key,偶数存放value
      • ziplist实现下,由哈希遍历变成了链表的顺序遍历,复杂度变成O(N)。

4、Set

编码的方式主要有intsethashtable

  • hashtable中的value永远为null,当set中只包含整数型的元素时,则采用intset
  • intset的内部结构
    • 核心元素是一个字节数组,从小到大有序存放着set的元素
    • 由于元素有序排列,所以set的获取操作采用二分查找方式实现,复杂度O(log(N))。进行插入时,首先通过二分查找得到本次插入的位置,再对元素进行扩容,再将预计插入位置之后的所有元素向右移动一个位置,最后插入元素,插入复杂度为O(N)。删除类似。

5、Zset

底层采用ziplist(跳表)和skiplist,分层提取多个节点保存,从而形成一颗类似树的结构,使得检索的速度达到O(logN)。

  1. 类似map是一个key-value对,但是有序的。value是一个浮点数,称为score,内部是按照score从小到大排序
  2. 内部结构以ziplist或skiplist+hashtable来实现。

跳表:跳表全称为跳跃列表,它允许快速查询,插入和删除一个有序连续元素的数据链表。跳跃列表的平均查找和插入时间复杂度都是O(logn)。大致的结构如下:

img

跳表的插入:

img

具体到代码层面的话,每个node节点会保存一个node类型的forwards数组,大小为MAX_LEVEL,用于保存在某一层中的后继节点。

  public class Node {
    private int data = -1;
    private Node forwards[] = new Node[MAX_LEVEL];
    private int maxLevel = 0;
    @Override
    public String toString() {
      StringBuilder builder = new StringBuilder();
      builder.append("{ data: ");
      builder.append(data);
      builder.append("; levels: ");
      builder.append(maxLevel);
      builder.append(" }");
      return builder.toString();
    }
  }

Redis为什么这么快:

1、完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。

2、数据结构简单,对数据操作也简单,Redis中的数据结构是专门采用C语言进行设计的;

3、采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗,(依据是因为Redis是基于内存的操作,CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存的大小或者网络带宽,因此可以采用单线程的方式。);

4、使用多路I/O复用模型,非阻塞IO;

5、使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis直接自己构建了VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求;

数据失效

1、缓存击穿:

​ 指的是某一时刻一个热点数据失效,对应的查询数据涌到了数据库中,造成的数据库压力过大

解决方案

(1)分布式互斥锁,只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。set(key,value,timeout) ,缺点是如果在查询数据库 + 和 重建缓存(key失效后进行了大量的计算)时间过长,也可能会存在死锁和线程池阻塞的风险。

(2)永不过期,这种方案由于没有设置真正的过期时间,实际上已经不存在热点key产生的一系列危害,但是会存在数据不一致的情况。

2、缓存雪崩

指的是大多数的热点数据在同一时间段都失效,造成大量的查询涌到了数据库中。

解决方案:

(1)缓存的过期时间用随机值,尽量让不同的key的过期时间不同;

(2)采用多级缓存,采用缓存进行兜底。本地进程作为一级缓存,redis作为二级缓存,不同级别的缓存设置的超时时间不同,即使某级缓存过期了,也有其他级别缓存兜底

3、缓存穿透

​ 指查询一个缓存和数据库中都没有的数据,缓存穿透将导致不存在的数据每次请求都要到持久层去查询,失去了缓存保护后端持久的意义

解决方案

(1)布隆过滤器,在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截,当收到一个对key请求时先用布隆过滤器验证是key否存在,如果存在再进入缓存层、存储层。布隆过滤器的原理,对插入的对象进行hash操作,将hash值对应的数组元素下标置为1,下次判断的时候如果布隆过滤器有对应的这多个值为1,则证明可能存在该元素,否则一定不存在这个元素。

(2)缓存空对象:是指在持久层没有命中的情况下,对key进行set (key,null)

​ 缓存空对象会有两个问题:第一,value为null 不代表不占用内存空间,空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间,比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。第二,缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。

数据持久化:

Redis的持久化时间默认为

持久化的方法主要分为两种:RDB持久化AOF持久化保存。

一、RDB持久化

​ 其是指在指定的时间间隔内将内存中的数据集快照写入磁盘。也是默认的持久化方式,这种方式是就是将内存中数据以快照的方式写入到二进制文件中,默认的文件名为dump.rdb。对于RDB来说,提供了三种保存数据的机制:save、bgsave、自动化

1、save触发方式,该命令会阻塞当前Redis服务器,执行save命令期间,Redis不能处理其他命令,直到RDB过程完成为止。具体流程如下:

img

​ 执行完成时候如果存在老的RDB文件,就把新的替代掉旧的。这种方式在大数据量下显然不可取。

2、bgsave触发方式,执行该命令时,Redis会在后台异步进行快照操作,快照同时还可以响应客户端请求。具体流程如下:

img

​ 具体操作是Redis进程执行fork操作创建子进程,RDB持久化过程由子进程负责,完成后自动结束。阻塞只发生在fork阶段,一般时间很短。基本上 Redis 内部所有的RDB操作都是采用 bgsave 命令。

3、自动触发,自动触发是由我们的配置文件来完成的。

二、AOF持久化

​ 全量备份总是耗时的,有时候我们提供一种更加高效的方式AOF,工作机制很简单,redis会将每一个收到的写命令都通过write函数追加到文件中。通俗的理解就是日志记录。AOF的方式也同时带来了另一个问题,持久化文件会变的越来越大。为了压缩aof的持久化文件。redis提供了bgrewriteaof命令。将内存中的数据以命令的方式保存到临时文件中,同时会fork出一条新进程来将文件重写。

AOF有三种触发机制

(1)每修改同步(always):同步持久化 每次发生数据变更会被立即记录到磁盘 性能较差但数据完整性比较好

(2)每秒同步(everysec):异步操作,每秒记录 如果一秒内宕机,有数据丢失

(3)不同(no):从不同步

img

过期数据删除:

其实有三种不同的删除策略:
(1):立即删除。在设置键的过期时间时,创建一个回调事件,当过期时间达到时,由时间处理器自动执行键的删除操作。
(2):惰性删除。键过期了就过期了,不管。每次从dict字典中按key取值时,先检查此key是否已经过期,如果过期了就删除它,并返回nil,如果没过期,就返回键值。
(3):定时删除。每隔一段时间,对expires字典进行检查,删除里面的过期键。
可以看到,第二种为被动删除,第一种和第三种为主动删除,且第一种实时性更高。下面对这三种删除策略进行具体分析。

立即删除

立即删除能保证内存中数据的最大新鲜度,因为它保证过期键值会在过期后马上被删除,其所占用的内存也会随之释放。但是立即删除对cpu是最不友好的。因为删除操作会占用cpu的时间,如果刚好碰上了cpu很忙的时候,比如正在做交集或排序等计算的时候,就会给cpu造成额外的压力。

而且目前redis事件处理器对时间事件的处理方式--无序链表,查找一个key的时间复杂度为O(n),所以并不适合用来处理大量的时间事件。

惰性删除

​ 惰性删除是指,某个键值过期后,此键值不会马上被删除,而是等到下次被使用的时候,才会被检查到过期,此时才能得到删除。所以惰性删除的缺点很明显:浪费内存。dict字典和expires字典都要保存这个键值的信息。

举个例子,对于一些按时间点来更新的数据,比如log日志,过期后在很长的一段时间内可能都得不到访问,这样在这段时间内就要拜拜浪费这么多内存来存log。这对于性能非常依赖于内存大小的redis来说,是比较致命的

定时删除

​ 从上面分析来看,立即删除会短时间内占用大量cpu,惰性删除会在一段时间内浪费内存,所以定时删除是一个折中的办法。
定时删除是:每隔一段时间执行一次删除操作,并通过限制删除操作执行的时长和频率,来减少删除操作对cpu的影响。另一方面定时删除也有效的减少了因惰性删除带来的内存浪费。

==操作系统==

进程/线程/协程/程序/纤程/管程

程序:

​ 计算机程序是一组计算机能识别和执行的指令,运行于计算机上,满足人们某种需求的信息化工具。

进程:

​ 进程是一段程序的执行过程,是计算机资源分配的基本单位,进程是程序的基本执行实体。同时进程拥有自己的进程控制块,系统可以利用这个进程的控制块(PCB)来控制对应的进程。

线程:

​ 是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

线程和进程的比较:

1、调度,线程是独立调度的基本单位,进程是资源分配的基本单位。

2、拥有资源,进程是拥有资源的基本单位,而线程则不拥有系统的资源,但是线程可以访问其所属进程的资源。

3、系统开销,系统创建和回收进程是需要对进程进行资源回收的。因此创建和回收进程的损耗要大于创建和回收线程的损耗。

4、地址空间和其他资源,进程间各自的地址空间是独立的,线程间的地址空间是共享的。

5、通信方面,进程间通信主要通过进程同步和互斥手段,而线程间可以直接读写进程数据段(如全局变量)进行通信。

协程

​ 是用户级别的轻量级线程,协程不是进程或线程,其执行过程更类似于子例程,或者说不带返回值的函数调用。一个程序可以包含多个协程,可以对比与一个进程包含多个线程,因而下面我们来比较协程和线程。协程相对独立,有自己的上下文,但是其切换由程序员控制,由当前协程切换到其他协程由当前协程来控制。

纤程:

​ 是由操作系统实现的一种轻量化线程上的一个执行结构. 通常是多个fiber共享一个固定的线程, 然后他们通过互相主动切换到其他fiber来交出线程的执行权. 各个子任务之间的关系非常强。

管程:

​ 管程在功能上和信号量及PV操作类似,属于一种进程同步互斥工具,但是具有与信号量及PV操作不同的属性。

进程通信/同步方式:

1、管道/命名管道;2、消息队列;3、信号量;4、内存共享。

线程通信/同步方式:

1、wait()/notify()/notifyAll();2、Synchronized关键字;3、通过Condition的awiat和signal;4、 ReentrantLock 结合 Condition;5、Volatile关键字修饰的全局变量;

内存管理

​ 内存管理主要分为以下部分:内存的空间分配和回收、地址转换、内存空间的扩充、存储保护

1、内存的分配主要有两种:连续分配非连续分配

连续分配主要指为一个用户程序分配一个连续的内存空间,连续分配的方式包括单一连续分配、固定分区分配、动态分区分配

​ (1)单一连续分配,只用于单用户单进程的操作系统,

​ (2)固定分区分配,其将内存划分为多个固定大小的分区,当有空闲分区的时候,再从后续的作业队列中选择合适大小的作业装入。

​ (3)可变分区分配,os根据程序需要,分给每个程序所需要的内存大小,但会出现内存碎片的问题。通常需要采用紧凑技术进行解决。可变分区的分配策略有以下几种:

  • 首次适应算法,空闲分区以地址递增次序连接,找到大小能满足要求的第一个分区。
  • 最佳适应算法,空闲分区按容量递增次序连接,找到第一个能满足要求的空闲分区。
  • 最坏适应算法,空闲分区以容量递减次序连接,挑选第一个最大的分区。
  • 邻近适应算法,与首次适应类似,只不过每次都从上一次的位置开始查找。

非连续分配算法主要有三种实现方式:分页式存储、分段式存储、段页式存储

(1)分页式存储:主要将内存划分成大小固定的页面,在程序申请内存的时候,分配适合大小的页面。这种方式可以有效减少内存的碎片,同时不需要数据连续存放。同时内存中会维护一张页表,用于对内存地址进行转换。作业的逻辑地址:页号+页内偏移

(2)分段式存储:主要是从用户的角度出发,按照程序的自然段划分为逻辑空间。同样内存中会维护一张段表,每个段表项对应内存中的一段。内存地址借助于段表寄存器进行转换,作业的逻辑地址为:段号+段内偏移

(3)段页式存储:结合了上述两种方法的优点,首先会对作业进行分段,分段之后再保存到对应的每个页中。内存管理仍然和分页式存储一致。作业的逻辑地址分为三部分:段号+页号+逻辑地址

虚拟内存

​ 虚拟内存指的是将程序划分成多个页块,将程序运行所需要的页块调入到内存中,其余的页块保存在硬盘中。如果程序访问到一些不在内存中存在的页块(产生了缺页中断),就采用页面的置换算法进行置换。(虚拟地址的格式一般为:虚拟块号+页内偏移。而物理地址一般为:物理块号+页内偏移)流程为:查询页表,将虚拟页号按照页表转换成对应的物理块号,同时将物理块号换入到内存中,并在访问一次,即完成虚拟地址到物理地址的转换。

img

页面置换算法

1、OPT算法,最佳置换算法。所选择的被换出的页面将是以后永不使用的,或者是在最长时间内不再被访问,可以保证获得最低的缺页率。但是由于人们无法预知一个页面多长时间不再被访问,因此该算法是一种理论上的算法。

​ 开始运行时,先将 7, 0, 1 三个页面装入内存。当进程要访问页面 2 时,产生缺页中断,根据最佳置换算法,页面 7 在第18次访问才需要调入,再次被访问的时间最长,因此会将页面 7 换出,以此类推,具体过程如下图;

img

2、FIFO算法,先进先出置换算法,选择换出的页面是最先进入的页面。该算法实现简单,但会将那些经常被访问的页面也被换出,从而使缺页率升高。

img

FIFO算法还会产生当分配的物理块数增大而页故障数不减反增的异常现象,称为Belady异常。FIFO算法可能出现Belady异常,而LRU和OPT算法永远不会。

3、LRU算法,最近最久未使用置换算法。LRU 将最近最久未使用的页面换出,它认为过去一段时间内未使用的页面,在最近的将来也不会被访问。实现方式一是在内存中维护一个所有页面的链表。当一个页面被访问时,将这个页面移到链表表头。这样就能保证链表表尾的页面时最近最久未访问的。实现方式二是为每个页面设置一个访问字段,来记录页面自上次被访问以来所经历的时间,淘汰页面时选择现有页面中值最大的予以淘汰。

面试题 16.25. LRU 缓存

img

LRU性能较好,但需要寄存器和栈的硬件支持,LRU是堆栈类的算法,理论上可以证明,堆栈类算法不可能出现Belady异常,FIFO算法基于队列实现,不是堆栈类算法。

4、第二次机会算法,FIFO 算法可能会把经常使用的页面置换出去,为了避免这一问题,对该算法做一个简单的修改:当页面被访问 (读或写) 时设置该页面的 R 位为 1。需要替换的时候,检查最老页面的 R 位。如果 R 位是 0,那么这个页面既老又没有被使用,可以立刻置换掉;如果是 1,就将 R 位清 0,并把该页面放到链表的尾端,修改它的装入时间使它就像刚装入的一样,然后继续从链表的头部开始搜索。

3、Clock算法,时钟置换算法。最简单的时钟策略需要给每一页框关联一个附加位,称为使用位。当某一页首次装入内存中时,则将该页页框的使用位设置为1;当该页随后被访问到时(在访问产生缺页中断之后),它的使用位也会被设置为1。当一页被置换时,该指针针被设置成指向缓冲区中的下一页框。当需要置换一页时,操作系统扫描缓冲区,以查找使用位被置为0的一页框。每当遇到一个使用位为1的页框时,操作系统就将该位重新置为0;如果在这个过程开始时,缓冲区中所有页框的使用位均为0时,则选择遇到的第一个页框置换;如果所有页框的使用位均为1时,则指针针在缓冲区中完整地循环一周,把所有使用位都置为0,并且停留在最初的位置上,置换该页框中的页当需要使用的页已经存在时,则指针不会受到影响,不会发生转动。

常驻内存

常驻内存,这个术语来自MSDOS的时代。MSDOS是单任务的运行环境,系统一般不允许两个以上程序同时运行。也就是说,如果你正在运行一个任务,而又想运行另外一个任务,你必须退出当前的任务。有一种辅助工具程序,能假装退出,而仍驻留于内存当中,让你运行其它的应用。而当你需要的时候,可以用热键随时把该驻留程序激活。这样就看起来像多任务,并用这种方式为用户提供方便。一般这样的程序都是很小的应用程序。占用内存极少。或者占用高端内存。在现代的多任务操作系统中,常驻内存程序,只不过是个名词而已,其内涵早就失去了实际意义。这不是说没有可以常驻内存的程序,而是把程序区分为常驻内存和非常驻内存,无论从技术或者使用的角度来说,都毫无意义。

写时复制

​ 写时复制的技术,它通过允许父进程和子进程最初共享相同的页面来工作。这些共享页面标记为写时复制,这意味着如果任何一个进程写入共享页面,那么就创建共享页面的副本。

进程1修改页面C前后

五种IO模型

BIO

​ 其是Blocking IO的意思。在类似于网络中进行read, write, connect一类的系统调用时会被卡住。举个例子,当用read去读取网络的数据时,是无法预知对方是否已经发送数据的。因此在收到数据之前,能做的只有等待,直到对方把数据发过来,或者等到网络超时。

NIO

​ 是指将IO模式设为“Non-Blocking”模式。在NIO模式下,调用read,如果发现没数据已经到达,就会立刻返回-1, 并且errno被设为EAGAIN。具体来说NIO就是不断的尝试有没有数据到达,有了就处理,没有就等一小会再试。NIO的底层原理主要是依靠缓冲区来实现的,输入通过管道输入到缓冲区,之后OS系统再通知对应的线程从缓冲区中提取数据,从而实现无需等待的过程。

IO多路复用

​ IO多路复用(IO Multiplexing) 是这么一种机制:程序注册一组socket文件描述符给操作系统,表示“我要监视这些fd是否有IO事件发生,有了就告诉程序处理”,这样就可以只需要一个或几个线程就可以完成数据状态询问的操作,当有数据准备就绪之后再分配对应的线程去读取数据,这么做就可以节省出大量的线程资源出来,这个就是IO复用模型的思路。

img

IO复用模型的思路就是系统提供了一种函数可以同时监控多个fd的操作,这个函数就是我们常说到的select、poll、epoll函数,有了这个函数后,应用线程通过调用select函数就可以同时监控多个fd,select函数监控的fd中只要有任何一个数据状态准备就绪了,select函数就会返回可读状态,这时询问线程再去通知处理数据的线程,对应线程此时再发起recvfrom请求去读取数据。

信号驱动IO模型

复用IO模型解决了一个线程可以监控多个fd的问题,但是select是采用轮询的方式来监控多个fd的,通过不断的轮询fd的可读状态来知道是否就可读的数据,而无脑的轮询就显得有点暴力,因为大部分情况下的轮询都是无效的,所以有人就想,能不能不要我总是去问你是否数据准备就绪,能不能我发出请求后等你数据准备好了就通知我,所以就衍生了信号驱动IO模型

于是信号驱动IO不是用循环请求询问的方式去监控数据就绪状态,而是在调用sigaction时候建立一个SIGIO的信号联系当内核数据准备好之后再通过SIGIO信号通知线程数据准备好后的可读状态,当线程收到可读状态的信号后,此时再向内核发起recvfrom读取数据的请求,因为信号驱动IO的模型下应用线程在发出信号监控后即可返回,不会阻塞,所以这样的方式下,一个应用线程也可以同时监控多个fd。

img

异步IO模型

其实经过了上面两个模型的优化,我们的效率有了很大的提升,但是我们当然不会就这样满足了,有没有更好的办法,通过观察我们发现,不管是IO复用还是信号驱动,我们要读取一个数据总是要发起两阶段的请求,第一次发送select请求,询问数据状态是否准备好,第二次发送recevform请求读取数据。应用只需要向内核发送一个read 请求,告诉内核它要读取数据后即刻返回;内核收到请求后会建立一个信号联系,当数据准备就绪,内核会主动把数据从内核复制到用户空间,等所有操作都完成之后,内核会发起一个通知告诉应用,我们称这种一劳永逸的模式为异步IO模型

这种模型与信号驱动模型的主要区别在于,信号驱动IO只是由内核通知我们合适可以开始下一个IO操作,而异步IO模型是由内核通知我们操作什么时候完成。

img

系统调用:Linux内核中设置了一组用于实现各种系统功能的子程序,称为系统调用。用户可以通过系统调用命令在自己的应用程序中调用它们。从某种角度来看,系统调用和普通的函数调用非常相似。区别仅仅在于,系统调用由操作系统核心提供,运行于核心态;而普通的函数调用由函数库或用户自己提供,运行于用户态。

系统调用大致可分为六大类:
进程控制(process control)、文件管理(file manipulation)、设备管理(device manipulation)、信息维护(information maintenance)、通信(communication)、保护(protection)

IO管理

程序访问磁盘时,CPU的控制方式主要有以下几种:

1、程序直接控制方式,CPU会对外设状态进行循环检查,这种方式需要CPU一直参与IO。

2、中断驱动方式,允许IO设备主动打断CPU的运行并执行对应的服务,从而可以解放CPU。

3、DMA方式,又称为直接存储器存放,不经过cpu,直接从IO设备读入到内存中。基本传送单位是块。

4、通道控制方式,IO通道是指专门负责输入/输出的处理机。其进一步减少CPU的干预,数据的传送不再需要经过CPU而是直接采用通道进行处理。

死锁

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,四大必要条件

1、互斥访问;

2、不可剥夺;

3、请求并保持;

4、循环等待。死锁解除的方法对应就是破坏四大必要条件。

死锁避免:银行家算法

中断

中断处理的基本过程包括中断请求、中断判优、中断响应、中断服务 和中断返回等五个阶段。

中断请求阶段

1)发生在CPU内部的中断(内部中断),不需要中断请求,CPU内部的中断控制逻辑直接接收处理。

2)外部中断请求由中断源提出。外部中断源利用CPU的中断输入引脚 输入中断请求信号。一般CPU设有两个中断请求输入引脚:可屏蔽中断请求输入引脚和不可屏蔽中断请求输入引脚。

中断判优阶段

CPU一次只能接受一个中断源的请求,当多个中断源同时向CPU提出中断请求时,CPU必须找出中断优先级最高的中断源,这一过程称为中断判优。中断判优可以采用硬件方法,也可采用软件方法。

中断响应阶段

经过中断判优,中断处理就进入中断响应阶段。中断响应时,CPU向中断源发出中断响应信号,同时:
① 保护硬件现场;
② 关中断;
③ 保护断点;
④ 获得中断服务程序的入口地址。

中断服务阶段

中断服务程序的一般结构为:
1)保护现场。 在中断服务程序的起始部分安排若干条入栈指令,将各寄存器的内容压入堆栈保存。
2)开中断。 在中断服务程序执行期间允许级别更高的中断请求中断现 行的中断服务程序,实现中断嵌套。
3)中断服务。 完成中断源的具体要求。
4)恢复现场。 中断服务程序结束前,必须恢复主程序的中断现场。通常是将保存在堆栈中的现场信息弹出到原来的寄存器中。
5)中断返回。 返回到原程序的断点处,继续执行原程序。

中断返回阶段

返回到原程序的断点处,恢复硬件现场,继续执行原程序。
中断返回操作是中断响应操作的逆过程。

==计算机网络==

OSI七层模型

物理层

​ 可以通过网线也可以通过无线连接两台设备。当数据传送到物理层时会转化为比特数据,比如010101,而比特数据会转化为光信号或电信号传输到另一台设备上。而这一过程就需要物理层完成。

主要协议:RJ45、CLOCK、IEEE802.3 (中继器,集线器,网关)

数据链路层

涉及MAC地址网卡地址。当一台设备连接到路由器,路由器连接到光猫上,而光猫则连通电信网络。当你从这台设备发出信息时,首先数据到达路由器,在通过路由器数据到达光猫,然后到达电信公网。在数据从设备到达路由器这一过程中是通过识别MAC地址完成的。也就是从一个节点到达另一个节点。而IP则是直接标识源和目的地。(即你的设备地址,和你所发送的设备地址)。

主要协议:PPP、FR、HDLC、VLAN、MAC (网桥,交换机)

网络层

这一层开始实现设备到设备之间的通信

(1)IP即互联协议,Ip为网络设备提供逻辑地址。

(2)并且把刚刚从传输层送来的数据送到“目的地”,那么网络层则为这些数据提供最佳的路线。

主要协议:IP、ICMP、ARP、RARP、OSPF、IPX、RIP、IGRP、 (路由器)

传输层

(1)传输层为应用程序提供端口供应用层使用。

(2)当传输较大的文件时会将文件分段,那么为了保证这些数据正常的顺序,还要对数据进行重组,包括流量控

制;差错控制,这些都由传输层完成控制。

主要协议:TCP、UDP、SPX

会话层

会将不同的应用程序的数据隔离起来。比如QQ与微信的隔离,使两个应用程序之间的数据进行隔离,防止串流。当然,如果想要共享两个软件之间的数据,可以通过应用层对应的接口进行共享。

主要协议:NFS、SQL、NETBIOS、RPC

表示层

对应用数据转换。比如说你在QQ聊天时,音频通过对应软件的接口,传递给声卡,声卡把这些数据进行一个编码,变成比特数据传送到另一个人的设备上,然后在这台设备进行解码转化成语音。

主要协议:JPEG、MPEG、ASII

应用层

最接近用户的一层。提供接口,使应用程序能够使用一些网络协议。

主要协议:FTP、DNS、Telnet、SMTP、HTTP、WWW、NFS

TCP/IP五层模型

TCP/IP五层模型:1、物理层:2、数据链路层:3、网络层:4、传输层:5、应用层;

TCP/IP四层模型:

1、数据链路层(物理层直接叫做物理传输介质。);2、网络层;3、传输层;4、应用层;

  • 应用层:应用层是在用户空间实现的,负责处理众多业务逻辑,如文件传输、网络管理。
  • 传输层:为应用程序隐藏了数据包跳转的细节,负责数据包的收发、链路超时重连等。
  • 网络层:能够使得不同应用类型的数据在Internet上通畅地传输。
  • 数据链路层:实现网卡接口的网络驱动,以处理数据在以太网等物理媒介上的传输。
img

ICMP协议

ICMP,因特网控制报文协议。它是IPv4协议族中的一个子协议,用于IP主机、路由器之间传递控制消息。控制消息是在网络通不通、主机是否可达、路由是否可用等网络本身的消息。这些控制消息虽然不传输用户数据,但是对于用户数据的传递起着重要的作用。

ICMP报文信息

​ ICMP报文包含在IP数据报中,IP报头在ICMP报文的最前面。一个ICMP报文包括IP报头(至少20字节)、ICMP报头(至少八字节)和ICMP报文(属于ICMP报文的数据部分)。当IP报头中的协议字段值为1时,就说明这是一个ICMP报文。ICMP报头如下图所示。

ICMP报头格式

各字段说明

  • 类型:占一字节,标识ICMP报文的类型,目前已定义了14种,从类型值来看ICMP报文可以分为两大类。第一类是取值为1~127的差错报文,第2类是取值128以上的信息报文
  • 代码:占一字节,标识对应ICMP报文的代码。它与类型字段一起共同标识了ICMP报文的详细类型。
  • 校验和:这是对包括ICMP报文数据部分在内的整个ICMP数据报的校验和,以检验报文在传输过程中是否出现了差错。其计算方法与在我们介绍IP报头中的校验和计算方法是一样的。
  • 标识:占两字节,用于标识本ICMP进程,但仅适用于回显请求和应答ICMP报文,对于目标不可达ICMP报文和超时ICMP报文等,该字段的值为0。

TCP

TCP(控制传输协议),是一种面向连接的、可靠的、基于字节流的传输层通信协议。是为了在不可靠的互联网络上提供可靠的端到端字节流而专门设计的一个传输协议。

TCP报头信息

img

TCP报文是TCP层传输的数据单元,也叫报文段。

1、端口号:用来标识同一台计算机的不同的应用进程。

1)源端口:源端口和IP地址的作用是标识报文的返回地址。

2)目的端口:端口指明接收方计算机上的应用程序接口。

TCP报头中的源端口号和目的端口号同IP数据报中的源IP与目的IP唯一确定一条TCP连接。

2、序号和确认号:是TCP可靠传输的关键部分。序号是本报文段发送的数据组的第一个字节的序号。在TCP传送的流中,每一个字节一个序号。e.g.一个报文段的序号为300,此报文段数据部分共有100字节,则下一个报文段的序号为400。所以序号确保了TCP传输的有序性。确认号,即ACK,指明下一个期待收到的字节序号如N,则表明序号N之前的所有数据已经正确无误的收到。确认号只有当ACK标志为1时才有效。比如建立连接时,SYN报文的ACK标志位为0。

3、数据偏移/首部长度:4bits。由于首部可能含有可选项内容,因此TCP报头的长度是不确定的,报头不包含任何任选字段则长度为20字节,4位首部长度字段所能表示的最大值为1111,转化为10进制为15,15*32/8 = 60,故报头最大长度为60字节。首部长度也叫数据偏移,是因为首部长度实际上指示了数据区在报文段中的起始偏移值。

4、保留:为将来定义新的用途保留,现在一般置0。

5、控制位:URG ACK PSH RST SYN FIN,共6个,每一个标志位表示一个控制功能。

1)URG:紧急指针标志,为1时表示紧急指针有效,为0则忽略紧急指针。

2)ACK:确认序号标志,为1时表示确认号有效,为0表示报文中不含确认信息,忽略确认号字段。

3)PSH:push标志,为1表示是带有push标志的数据,指示接收方在接收到该报文段以后,应尽快将这个报文段交给应用程序,而不是在缓冲区排队。

4)RST:重置连接标志,用于重置由于主机崩溃或其他原因而出现错误的连接。或者用于拒绝非法的报文段和拒绝连接请求。

5)SYN:同步序号,用于建立连接过程,在连接请求中,SYN=1和ACK=0表示该数据段没有使用捎带的确认域,而连接应答捎带一个确认,即SYN=1和ACK=1。

6)FIN:finish标志,用于释放连接,为1时表示发送方已经没有数据发送了,即关闭本方数据流。

6、窗口:滑动窗口大小,用来告知发送端接受端的缓存大小,以此控制发送端发送数据的速率,从而达到流量控制。窗口大小时一个16bit字段,因而窗口大小最大为65535。

7、校验和:奇偶校验,此校验和是对整个的 TCP 报文段,包括 TCP 头部和 TCP 数据,以 16 位字进行计算所得。由发送端计算和存储,并由接收端进行验证。

8、紧急指针:只有当 URG 标志置 1 时紧急指针才有效。紧急指针是一个正的偏移量,和顺序号字段中的值相加表示紧急数据最后一个字节的序号。 TCP 的紧急方式是发送端向另一端发送紧急数据的一种方式。

9、选项和填充:最常见的可选字段是最长报文大小,又称为MSS(Maximum Segment Size),每个连接方通常都在通信的第一个报文段(为建立连接而设置SYN标志为1的那个段)中指明这个选项,它表示本端所能接受的最大报文段的长度。选项长度不一定是32位的整数倍,所以要加填充位,即在这个字段中加入额外的零,以保证TCP头是32的整数倍。

10、数据部分TCP 报文段中的数据部分是可选的。在一个连接建立和一个连接终止时,双方交换的报文段仅有 TCP 首部。如果一方没有数据要发送,也使用没有任何数据的首部来确认收到的数据。在处理超时的许多情况中,也会发送不带任何数据的报文段。

TCP元组

这些元组主要是用于端口复用的,对于一个连接来说,只要其中一个存在不同则可以区别这是一个不同的连接。

四元组

源IP地址、目的IP地址、源端口、目的端口

五元组:

源IP地址、目的IP地址、协议号、源端口、目的端口

七元组:

源IP地址、目的IP地址、协议号、源端口、目的端口,服务类型以及接口索引

三次握手

​ (1)第一次握手:客户端给服务端发一个 SYN 报文,并指明客户端的初始化序列号 ISN。此时客户端处于 SYN_SEND 状态。首部的同步位SYN=1,初始序号seq=x,SYN=1的报文段不能携带数据,但要消耗掉一个序号。

​ (2)第二次握手:服务器收到客户端的 SYN 报文之后,会以自己的 SYN 报文作为应答,并且也是指定了自己的初始化序列号 ISN(s)。同时会把客户端的 ISN + 1 作为ACK 的值,表示自己已经收到了客户端的 SYN,此时服务器处于 SYN_REVD 的状态。在确认报文段中SYN=1,ACK=1,确认号ack=x+1,初始序号seq=y。

​ (3)第三次握手:客户端收到 SYN 报文之后,会发送一个 ACK 报文,当然,也是一样把服务器的 ISN + 1 作为 ACK 的值,表示已经收到了服务端的 SYN 报文,此时客户端处于 ESTABLISHED 状态。服务器收到 ACK 报文之后,也处于 ESTABLISHED 状态,此时,双方已建立起了连接。确认报文段ACK=1,确认号ack=y+1,序号seq=x+1(初始为seq=x,第二个报文段所以要+1),ACK报文段可以携带数据,不携带数据则不消耗序号。发送第一个SYN的一端将执行主动打开(active open),接收这个SYN并发回下一个SYN的另一端执行被动打开(passive open)。在socket编程中,客户端执行connect()时,将触发三次握手。

(PS:第三次握手的情况下,ACK数据包如果丢失,有两种情况,1、客户端接着发送数据包,服务器端收到数据包,这个时候数据包带有和ACK一样的信息,因此不需要进行重传。2、如果客户端没有发送数据包,那么过了2ms,服务器端会认为自己的ACK数据包丢失了,进行重传,这个时候客户端才重传对应的数据包。)

img

三次握手的目的

第一次握手:客户端发送网络包,服务端收到了。这样服务端就能得出结论:客户端的发送能力、服务端的接收能力是正常的。

第二次握手:服务端发包,客户端收到了。这样客户端就能得出结论:服务端的接收、发送能力,客户端的接收、发送能力是正常的。不过此时服务器并不能确认客户端的接收能力是否正常。

第三次握手:客户端发包,服务端收到了。这样服务端就能得出结论:客户端的接收、发送能力正常,服务器自己的发送、接收能力也正常。因此,需要三次握手才能确认双方的接收与发送能力是否正常。试想如果是用两次握手,则会出现下面这种情况:

如客户端发出连接请求,但因连接请求报文丢失而未收到确认,于是客户端再重传一次连接请求。后来收到了确认,建立了连接。数据传输完毕后,就释放了连接,客户端共发出了两个连接请求报文段,其中第一个丢失,第二个到达了服务端,但是第一个丢失的报文段只是在某些网络结点长时间滞留了,延误到连接释放以后的某个时间才到达服务端,此时服务端误认为客户端又发出一次新的连接请求,于是就向客户端发出确认报文段,同意建立连接,不采用三次握手,只要服务端发出确认,就建立新的连接了,此时客户端忽略服务端发来的确认,也不发送数据,则服务端一致等待客户端发送数据,浪费资源。

初始序列号,ISN(Initial Sequence Number):

当一端为建立连接而发送它的SYN时,它为连接选择一个初始序号。ISN随时间而变化,因此每个连接都将具有不同的ISN。ISN可以看作是一个32比特的计数器,每4ms加1 。这样选择序号的目的在于防止在网络中被延迟的分组在以后又被传送,而导致某个连接的一方对它做错误的解释。三次握手的其中一个重要功能是客户端和服务端交换 ISN,以便让对方知道接下来接收数据的时候如何按序列号组装数据。如果 ISN 是固定的,攻击者很容易猜出后续的确认号,因此 ISN 是动态生成的。

泛洪攻击:

​ 该攻击借助于TCP的三次握手实现的。攻击者发送TCP SYN,SYN是TCP三次握手中的第一个数据包,而当服务器返回ACK后,该攻击者就不对其进行再确认,那这个TCP连接就处于挂起状态,也就是所谓的半连接状态,服务器收不到再确认的话,还会重复发送ACK给攻击者。这样更加会浪费服务器的资源。攻击者就对服务器发送非常大量的这种TCP连接,由于每一个都没法完成三次握手,所以在服务器上,这些TCP连接会因为挂起状态而消耗CPU和内存,最后服务器可能死机,就无法为正常用户提供服务了。

解决措施:

​ 对于SYN泛洪攻击的防范,优化主机系统设置是常用的手段。如降低SYN timeout时间,使得主机尽快释放半连接的占用;又比如采用SYN cookie设置,如果短时间内连续收到某个IP的重复SYN请求,则认为受到了该IP的攻击,丢弃来自该IP的后续请求报文。此外合理地采用***等外部网络安全设施也可缓解SYN泛洪攻击。

四次挥手

​ (1)第一次挥手:客户端发送一个 FIN 报文,报文中会指定一个序列号。此时客户端处于 FIN_WAIT1 状态。即发出连接释放报文段(FIN=1,序号seq=u),并停止再发送数据,主动关闭TCP连接,进入FIN_WAIT1(终止等待1)状态,等待服务端的确认。

​ (2)第二次挥手:服务端收到 FIN 之后,会发送 ACK 报文,且把客户端的序列号值 +1 作为 ACK 报文的序列号值,表明已经收到客户端的报文了,此时服务端处于 CLOSE_WAIT 状态。即服务端收到连接释放报文段后即发出确认报文段(ACK=1,确认号ack=u+1,序号seq=v),服务端进入CLOSE_WAIT(关闭等待)状态,此时的TCP处于半关闭状态,客户端到服务端的连接释放。客户端收到服务端的确认后,进入FIN_WAIT2(终止等待2)状态,等待服务端发出的连接释放报文段。

​ (3)第三次挥手:如果服务端也想断开连接了,和客户端的第一次挥手一样,发给 FIN 报文,且指定一个序列号。此时服务端处于 LAST_ACK 的状态。即服务端没有要向客户端发出的数据,服务端发出连接释放报文段(FIN=1,ACK=1,序号seq=w,确认号ack=u+1),服务端进入LAST_ACK(最后确认)状态,等待客户端的确认。(这里是多出的一次挥手,主要原因在于可能双方连接断开的时候,数据的传输还没有完成,因此需要多一次等待数据传输完成。)

​ (4)第四次挥手:客户端收到 FIN 之后,一样发送一个 ACK 报文作为应答,且把服务端的序列号值 +1 作为自己 ACK 报文的序列号值,此时客户端处于 TIME_WAIT 状态。需要过一阵子以确保服务端收到自己的 ACK 报文之后才会进入 CLOSED 状态,服务端收到 ACK 报文之后,就处于关闭连接了,处于 CLOSED 状态。

img

TIME_WAIT状态:客户端发送最后一次ACK之后,可能由于网络比较阻塞,该数据帧在传送过程中丢失了。服务器可能会再次进行确认,但是此时如果客户端已经进入CLOSE状态,不会理会其他的请求。因此,采用TIME_WAIT来保障如果网络比较阻塞,可以保证正常关闭TCP连接。TIME_WAIT其实并不能说是服务器还是客户端的状态。因为他其实是一个主动断开链接发起者的状态,在发送最后一次ACK后进入TIME_WAIT状态。

CLOSE_WAIT状态:在被动关闭连接情况下,在已经接收到FIN,但是还没有发送自己的FIN的时刻,连接处于CLOSE_WAIT状态。

2、流量控制,通常采用滑动窗口实现。(滑动窗口的大小取决于接收方的窗口大小和信道大小),滑动窗口下的方式有

(1)停止等待协议,当发送窗口和接收窗口都等于1时,就是停止等待协议。发送端给接收端发送数据,等待接收端确认回复ACk,并停止发送新的数据包,开启计时器。数据包在计时器超时之前得到确认,那么计时器就会关闭,并发送下一个数据包。如果计时器超时,发送端就认为数据包丢失或被破坏,需要重新发送之前的数据包,说明数据包在得到确认之前,发送端需要存储数据包的副本。停止等待协议是发出一个帧后得到确认才发下一个,降低了信道的利用率。

(2)后退N帧协议,在发送完一个帧后,不用停下来等待确认,而是可以连续发送多个数据帧。收到确认帧时,任可发送数据,这样就减少了等待时间,整个通信的通吞吐量提高。如果前一个帧在超时时间内未得到确认,就认为丢失或被破坏,需要重发出错帧及其后面的所有数据帧。这样有可能有把正确的数据帧重传一遍,降低了传送效率。线路很差时,使用退后N帧的协议会浪费大量的带宽重传帧。

(3)选择重传协议,NAK:非确认帧,当在一定时间内没有收到某个数据帧的ACK时,回复一个NACK。在发送过程中,如果一个数据帧计时器超时,就认为该帧丢失或者被破坏,接收端只把出错的的帧丢弃,其后面的数据帧保存在缓存中,并向发送端回复NAK。发送端接收到NAK时,只发送出错的帧。如果落在窗口的帧从未接受过,那么存储起来,等比它序列号小的所有帧都按次序交给网络层,那么此帧才提交给网络层。接收端收到的数据包的顺序可能和发送的数据包顺序不一样。因此在数据包里必须含有顺序字符来帮助接受端来排序。选择重传协议可以避免重复传送那些正确到达接收端的数据帧。但是接收端要设置具有相当容量的缓存空间,这在许多情况下是不够经济的。

3、拥塞控制:拥塞,即对资源的需求超过了可用的资源。若网络中许多资源同时供应不足,网络的性能就要明显变坏,整个网络的吞吐量随之负荷的增大而下降。拥塞避免的方式主要包括慢启动,拥塞避免,快重传,快恢复。

1、慢启动

​ 当主机开始发送数据时,如果立即把大量数据字节注入到网络,那么就有可能引起网络拥塞,因为现在并不清楚网络的负荷情况。因此,较好的方法是先探测一下,即由小到大逐渐增大发送窗口,也就是说,由小到大逐渐增大拥塞窗口数值。通常在刚刚开始发送报文段时,先把拥塞窗口 cwnd 设置为一个最大报文段MSS的数值。而在每收到一个对新的报文段的确认后,把拥塞窗口增加至多一个MSS的数值。用这样的方法逐步增大发送方的拥塞窗口 cwnd ,可以使分组注入到网络的速率更加合理。

2、拥塞避免

​ 让拥塞窗口cwnd缓慢地增大,即每经过一个往返时间RTT就把发送方的拥塞窗口cwnd加1,而不是加倍。这样拥塞窗口cwnd按线性规律缓慢增长,比慢开始算法的拥塞窗口增长速率缓慢得多。无论在慢开始阶段还是在拥塞避免阶段,只要发送方判断网络出现拥塞(其根据就是没有收到确认),就要把慢开始门限ssthresh设置为出现拥塞时的发送方窗口值的一半(但不能小于2)。然后把拥塞窗口cwnd重新设置为1,执行慢开始算法。这样做的目的就是要迅速减少主机发送到网络中的分组数,使得发生拥塞的路由器有足够时间把队列中积压的分组处理完毕。

3、快重传

​ 那就是收到3个相同的ACK。TCP在收到乱序到达包时就会立即发送ACK,TCP利用3个相同的ACK来判定数据包的丢失,此时进行快速重传,快速重传做的事情有:

  1. 把ssthresh设置为cwnd的一半

  2. 把cwnd再设置为ssthresh的值(具体实现有些为ssthresh+3)

  3. 重新进入拥塞避免阶段。

    img

4、快恢复

  1. 当收到3个重复ACK时,把ssthresh设置为cwnd的一半,把cwnd设置为ssthresh的值加3,然后重传丢失的报文段,加3的原因是因为收到3
  2. 再收到重复的ACK时,拥塞窗口增加1。
  3. 收到新的数据包的ACK时,把cwnd设置为第一步中的ssthresh的值。原因是因为该ACK确认了新的数据,说明从重复ACK时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态。
img

PS: 发送方的窗口的上限值应当取为接收方窗口rwnd和拥塞窗口cwnd这两个变量中较小的一个。即发送方窗口的上限值 = Min [rwnd ,cwnd]。

安全性

校验和

​ TCP检验和的计算与UDP一样,在计算时要加上12byte的伪首部,检验范围包括TCP首部及数据部分,但是UDP的检验和字段为可选的,而TCP中是必须有的。计算方法为:在发送方将整个报文段分为多个16位的段,然后将所有段进行反码相加,将结果存放在检验和字段中,接收方用相同的方法进行计算,如最终结果为检验字段所有位是全1则正确(UDP中也是全为1则正确),否则存在错误。

UDP

​ Internet 协议集支持一个无连接的传输协议,该协议称为用户数据报协议(UDP,User Datagram Protocol)。UDP 为应用程序提供了一种无需建立连接就可以发送封装的 IP 数据包的方法。

UDP传输的可靠性由应用层负责。常用的UDP端口号有:53(DNS)、69(TFTP)、161(SNMP),使用UDP协议包括:TFTP、SNMP、NFS、DNS、BOOTP。

UDP报头由4个域组成,其中每个域各占用2个字节,具体包括源端口号、目标端口号、数据包长度、校验值

TCP与UDP的区别

1、连接方面区别

TCP面向连接(如打电话要先拨号建立连接)。

UDP是无连接的,即发送数据之前不需要建立连接。

2、安全方面的区别

TCP提供可靠的服务,通过TCP连接传送的数据,无差错,不丢失,不重复,且按序到达。

UDP尽最大努力交付,即不保证可靠交付。

3、传输效率的区别

TCP传输效率相对较低。

UDP传输效率高,适用于对高速传输和实时性有较高的通信或广播通信。

4、连接对象数量的区别

TCP连接只能是点到点、一对一的。

UDP支持一对一,一对多,多对一和多对多的交互通信。

image.png

HTTP

无状态:

1、协议对于事务处理没有记忆能力。

2、对同一个url请求没有上下文关系。

3、每次的请求都是独立的,它的执行情况和结果与前面的请求和之后的请求是无直接关系的,它不会受前面的请求应答情况直接影响,也不会直接影响后面的请求应答情况。

4、服务器中没有保存客户端的状态,客户端必须每次带上自己的状态去请求服务器。

Cookies/Session

​ 这两个主要用于解决Http无状态连接的问题,会给每个会话创建对应的SessionID,会根据sessionID判断当前登陆的用户。

Cookie ,通俗地说就是当一个用户通过 HTTP 协议访问一个服务器的时候,这个服务器会将一些 Key/Value 键值对返回给客户端浏览器,并给这些数据加上一些限制条件,在条件符合时这个用户下次访问这个服务器的时候,数据又被完整地带回给服务器。Cookie 是 HTTP 协议头中的一个字段,虽然 HTTP 协议本身对这个字段并没有多少限制,但是 Cookie 最终还是存储在浏览器里。

Session,实际上是一个对象,存储在服务器端的内存中,在session对象中也可以存储多条数据,每一条数据都有一个sessionid作为唯一标识。

利用cookie和session登录的原理:
客户输入账号密码进行登录,服务器端进行验证,验证成功则生成唯一的sessionId,并且在session对象中存储当前用户信息。服务器端将sessionId写入客户端cookie中,当客户端下次访问服务器端时cookie会被自动发送给服务器端,服务器端在cookie中拿到sessionId然后在服务器端的session对象中查找sessionId进行验证,验证成功说明用户是登陆状态,则可以为其响应只有在登陆状态才能响应的数据。

长连接

短连接

Web请求完整过程

  1. 输入网址;

  2. DNS根据网址递归查找对应的IP地址。

    浏览器缓存 – 浏览器会缓存DNS记录一段时间。 有趣的是,操作系统没有告诉浏览器储存DNS记录的时间,这样不同浏览器会储存个自固定的一个时间(2分钟到30分钟不等)。

    系统缓存 – 如果在浏览器缓存里没有找到需要的记录,浏览器会做一个系统调用(windows里是gethostbyname)。这样便可获得系统缓存中的记录。

    路由器缓存 – 接着,前面的查询请求发向路由器,它一般会有自己的DNS缓存。
    ISP DNS 缓存 – 接下来要check的就是ISP缓存DNS的服务器。在这一般都能找到相应的缓存记录。

    递归搜索 – 你的ISP的DNS服务器从根域名服务器开始进行递归搜索,从.com顶级域名服务器Facebook的域名服务器。一般DNS服务器的缓存中会有.com域名服务器中的域名,所以到顶级服务器的匹配过程不是那么必要了。

    img
  3. 建立对应的TCP连接,三次握手四次挥手。

  4. 浏览器给web服务器发送一个HTTP请求。

  5. 服务的永久重定向响应。(待选)

  6. 浏览器跟踪对应的重定向地址。(待选)

  7. 服务器处理请求。

  8. 服务器发回HTML响应。

  9. 浏览器解析对应的HTML响应

    1. 浏览器开始显示HTML
    2. 浏览器发送获取嵌入在HTML中的对象
    3. 浏览器发送异步(AJAX)请求
  10. 关闭TCP连接。

HTTP状态码

1、消息(1XX):

100 Continue,客户端应当继续发送请求。这个临时响应是用来通知客户端它的部分请求已经被服务器接收,且仍未被拒绝。客户端应当继续发送请求的剩余部分,或者如果请求已经完成,忽略这个响应。

2、成功(2XX):

200 OK,请求已成功;

201 Created,请求已经被实现,而且有一个新的资源已经依据请求的需要而建立,且其 URI 已经随Location 头信息返回。

3、重定向(3XX)

300 Multiple Choices,被请求的资源有一系列可供选择的回馈信息,每个都有自己特定的地址和浏览器驱动的商议信息。用户或浏览器能够自行选择一个首选的地址进行重定向。

301: 永久重定向,表示本网页永久性转移到另一个地址。

302:重定向,当响应码为302时,表示服务器要求浏览器重新再发一个请求,服务器会发送一个响应头Location,它指定了新请求的URL地址;

304: 服务器端会获取If-Modified-Since值,与index.html 的当前最后修改时间比对,如果相同,服务器会发响应码304,表示index.html与浏览器上次缓存的相 同,无需再次发送,浏览器可以显示自己的缓存页面,如果比对不同,那么说明index.html已经做了修 改,服务器会响应200。

4、请求错误(4XX):

400 Bad Request

​ 1、语义有误,当前请求无法被服务器理解。除非进行修改,否则客户端不应该重复提交这个请求。

​ 2、请求参数有误。

403 Forbidden,服务器已经理解请求,但拒绝执行它。

404 Not Found,请求失败,请求所希望得到的资源未被在服务器上发现。

5、服务器错误(5XX):

500 Internal Server Error,服务器遇到了一个未曾预料的状况,导致了它无法完成对请求的处理。一般来说,这个问题都会在服务器端的源代码出现错误时出现。

503 Service Unavailable,由于临时的服务器维护或者过载,服务器当前无法处理请求。

504 Gateway Timeout,作为网关或者代理工作的服务器尝试执行请求时,未能及时从上游服务器(URI标识出的服务器,例如HTTP、FTP、LDAP)或者辅助服务器(例如DNS)收到响应。

505 HTTP Version Not Supported,服务器不支持,或者拒绝支持在请求中使用的 HTTP 版本。这暗示着服务器不能或不愿使用与客户端相同的版本。响应中应当包含一个描述了为何版本不被支持以及服务器支持哪些协议的实体。

HTTP方法

  • GET,GET 用于从指定资源请求数据。
  • POST,POST 用于将数据发送到服务器来创建/更新资源。

post请求几种常见content-type类型:

  1. application/x-www-form-urlencoded:原生form表单
  2. multipart/form-data:许多文件类型,比如文件
  3. application/json:告诉服务端消息主体是JSON
  4. text/xml:传输和存储数据
  5. binary:二进制文件类型
  • PUT,PUT 用于将数据发送到服务器来创建/更新资源。POST 和 PUT之间的区别在于 PUT 请求是幂等的(idempotent)。也就是说,多次调用相同的 PUT 请求将始终产生相同的结果。相反,重复调用POST请求具有多次创建相同资源的副作用。
  • HEAD,HEAD 与 GET 几乎相同,但没有响应主体。换句话说,如果 GET /users 返回用户列表,那么 HEAD /users 将发出相同的请求,但不会返回用户列表。HEAD 请求对于在实际发出 GET 请求之前(例如在下载大文件或响应正文之前)检查 GET 请求将返回的内容很有用。
  • DELETE,删除指定的资源。
  • PATCH
  • OPTIONS,方法描述目标资源的通信选项。

粘包问题

​ 粘包指的是由于连续的数据传送,导致多个数据包混合在一起,没有办法有效的区分开。TCP是基于字节流的,只维护发送出去多少,确认了多少,并没有维护消息与消息之间的边界,因而极有可能导致粘包问题。

1、在发送端,TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一包数据。若连续几次发送的数据都很少,通常TCP会根据优化算法把这些数据合成一包后一次发送出去,这样接收方就收到了粘包数据。

2、接收方引起的粘包是由于接收方用户进程不及时接收数据,从而导致粘包现象。这是因为接收方先把收到的数据放在系统接收缓冲区,用户进程从该缓冲区取数据,若下一包数据到达时前一包数据尚未被用户进程取走,则下一包数据放到系统接收缓冲区时就接到前一包数据之后,而用户进程根据预先设定的缓冲区大小从系统接收缓冲区取数据,这样就一次取到了多包数据。

PS:UDP方案是不会出现粘包的,因为其有对应的数据分割。

一是对于发送方引起的粘包现象,用户可通过编程设置来避免,TCP提供了强制数据立即传送的操作指令push,TCP软件收到该操作指令后,就立即将本段数据发送出去,而不必等待发送缓冲区满;

二是对于接收方引起的粘包,则可通过优化程序设计、精简接收进程工作量、提高接收进程优先级等措施,使其及时接收数据,从而尽量避免出现粘包现象;

三是由接收方控制,将一包数据按结构字段,人为控制分多次接收,然后合并,通过这种手段来避免粘包。

HTTPS

SSL加密算法

目的

  1. 客户端与服务器需要就一组用于保护数据的算法达成一致;
  2. 它们需要确立一组由那些算法所使用的加密密钥;
  3. 握手还可以选择对客户端进行认证

加密过程

  • 客户端将它所支持的算法列表一个用作产生密钥的随机数发送给服务器;
  • 服务器从算法列表中选择一种加密算法,并将它和一份包含服务器公用密钥的证书发送给客户端;该证书还包含了用于认证目的的服务器标识,服务器同时还提供了一个用作产生密钥的随机数
  • 客户端对服务器的证书进行验证(有关验证证书,可以参考数字签名),并抽取服务器的公用密钥;然后,再产生一个称作pre_master_secret的随机密码串,并使用服务器的公用密钥对其进行加密(参考非对称加/解密),并将加密后的信息发送给服务器;
  • 客户端与服务器端根据pre_master_secret以及客户端与服务器的随机数值独立计算出加密和MAC密钥(参考DH密钥交换算法)。
  • 客户端将所有握手消息的MAC值发送给服务器;
  • 服务器将所有握手消息的MAC值发送给客户端。

img

如何确保证书的可信度?

服务器证书需要验证域名所有权,OV/EV类型的证书还要验证企业信息,有效区别于假冒钓鱼网站,当然能让网站安全可信。

网络套接字
创建套接字,返回值就是套接字的文件描述符

视频理解

DH密钥交换算法:

现在假设Alice和Bob需要共享一个对称密码的密钥,然而双方之间的通信线路已经被窃听者窃听。这时,Alice和Bob可以通过下面的方法进行DH密钥交换,从而生成共享密钥。

img

1 Alice向Bob发送两个质数P和G

P必须是一个非常大的质数,而G则是一个和P相关的数,称为生成元。G可以是一个较小的数字。P和G不需要保密,被窃听者获取了也没关系。此外,P和G可以由Alice和Bob中的任意一方生成。

2 Alice生成一个随机数A

A是一个1~P-2之间的整数。这个数是一个只有Alice知道的秘密数字,没有必要告诉Bob,也不能让窃听者知道。

3 Bob生成一个随机数B

B是一个1~P-2之间的整数。这个数是一个只有Bob知道的秘密数字,没有必要高数Alice,也不能让窃听者知道。

4、Alice将 G的A次方 mod P 这个数发送给Bob

5、Bob 将 G的B次方 mod P 这个数发送给Alice

6、Alice用Bob发过来的数计算A次方并求 mod P

img

上面将mod P的“G的B次方的A次方” 改写成了“G的A*B次方”

7、Bob用Alice发过来的数计算B次方并求 mod P

img

上面将mod P的“G的A次方的B次方”改写成了“G的A*B次方”

于是Alice和Bob就计算出相等的共享密钥了。

HTTP/HTTPS的区别

1、HTTPS 协议需要到 CA 申请证书,一般免费证书较少,因而需要一定费用。

2、HTTP 是超文本传输协议,信息是明文传输,HTTPS 则是具有安全性的 SSL 加密传输协议

3、HTTP 和 HTTPS 使用的是完全不同的连接方式,用的端口也不一样,前者是80,后者是443

4、HTTP 的连接很简单,是无状态的。HTTPS 协议是由 SSL+HTTP 协议构建的可进行加密传输、身份认证的网络协议,比 HTTP 协议安全。(无状态的意思是其数据包的发送、传输和接收都是相互独立的。无连接的意思是指通信双方都不长久的维持对方的任何信息。)

全部评论

(4) 回帖
加载中...
话题 回帖

推荐话题

相关热帖

近期精华帖

热门推荐