J2EE

JAVA基础

基础

基本类型

访问修饰符

面向对象三大特性

面向对象的特征主要有以下几个方面:

抽象:抽象是将一类对象的共同特征总结出来构造类的过程,包括数据抽象和行为抽象两方面。抽象只关注对象有哪些属性和行为,并不关注这些行为的细节是什么。

其中Java 面向对象编程三大特性:封装 继承 多态

封装:封装是把一个对象的属性私有化,隐藏内部的实现细节,同时提供一些可以被外界访问属性的方法。通过封装可以使程序便于使用,提高复用性和安全性

继承:继承是使用已存在的类的定义作为基础,建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。通过继承可以提高代码复用性。继承是多态的前提。

关于继承如下 3 点请记住

子类拥有父类非 private 的属性和方法。 子类可以拥有自己的属性和方法,即子类可以对父类进行扩展。 子类可以用自己的方式实现父类的方法。

多态性:父类或接口定义的引用变量可以指向子类或具体实现类的实例对象。多态提高了程序的扩展性。一个引用变量到底会指向哪个类的实例对象,该引用变量发出的方法调用到底是哪个类中实现的方法,必须在程序运行期间才能决定。

Java实现多态有三个必要条件:继承、重写、向上转型。

继承:在多态中必须存在有继承关系的子类和父类。 重写:子类对父类中某些方法进行重新定义,在调用这些方法时就会调用子类的方法。 向上转型:在多态中需要将子类的引用赋给父类对象,只有这样该引用才能够具备调用父类和子类的方法的技能。

抽象类和接口的对比

抽象类是用来捕捉子类的通用特性的,实现代码重用。接口是抽象方法的集合,利用接口可以达到 API 定义和实现分离的目的,提供程序的扩展性和可维护性。

从设计层面来说,抽象类是对类的抽象,是一种模板设计,接口是行为的抽象,是一种行为的规范。

相同点

接口和抽象类都不能实例化 都位于继承的顶端,用于被其他类实现或继承 都包含抽象方法,其子类都必须重写这些抽象方法

不同点

参数抽象类接口声明抽象类使用abstract关键字声明接口使用interface关键字声明实现子类使用extends关键字来继承抽象类。如果一个类继承了抽象类,那么该子类必须实现抽象类的所有抽象方法。子类使用implements关键字来实现接口。如果一个类实现了接口,那么该子类必须实现父接口的所有方法。构造器抽象类可以有构造器接口不能有构造器访问修饰符抽象类中的方法可以是任意访问修饰符接口方法默认修饰符是public。并且不允许定义为 private 或者 protected字段声明抽象类的字段声明可以是任意的接口的字段默认都是 static 和 final 的多继承一个类最多只能继承一个抽象类一个类可以实现多个接口

备注:Java8中接口中引入默认方法和静态方法,以此来减少抽象类和接口之间的差异。现在,我们可以为接口提供默认实现的方法了,并且不用强制子类来实现它。

接口和抽象类各有优缺点,在接口和抽象类的选择上,必须遵守下面的几个原则:

抽象类用来定义某个领域的固有属性,即抽象类表示它是什么,接口用来定义某个领域的扩展功能,即接口表示它能做什么。 当需要为子类提供公共的实现代码时,应优先考虑抽象类。因为抽象类中的非抽象方法可以被子类继承,使实现功能的代码更简洁。 当注重代码的扩展性和可维护性时,应当优先采用接口。①接口与实现类之间可以不存在任何层次关系,接口可以实现毫不相关类的行为,比抽象类的使用更加方便灵活;②接口只关心对象之间的交互方法,而不关心对象所对应的具体类。接口是程序之间的一个协议,比抽象类的使用更安全、清晰。一般使用接口的情况更多。

集合

List,Set,Map三者的区别

Java 集合容器分为 Collection 和 Map 两大类,Collection集合的子接口有Set、List、Queue,Map接口不是collection的子接口。

Collection集合主要有List、Set和Queue接口

List:一个有序(元素存入集合的顺序和取出的顺序一致)容器,元素可以重复,可以插入多个null元素,元素都有索引。常用的实现类有 ArrayList、LinkedList 和 Vector。 Set:一个无序(存入和取出顺序有可能不一致)容器,不可以存储重复元素,只允许存入一个null元素,必须保证元素唯一性。常用实现类有 HashSet、LinkedHashSet 以及 TreeSet。 Queue/Deque,则是 Java 提供的标准队列结构的实现,除了集合的基本功能,它还支持类似先入先出(FIFO, First-in-First-Out)或者后入先出(LIFO,Last-In-First-Out)等特定行为。常用实现类有ArrayDeque、ArrayBlockingQueue、LinkedBlockingDeque

Map是一个键值对集合,存储键和值之间的映射。Key无序,唯一;value 不要求有序,允许重复。Map没有继承Collection接口,从Map集合中检索元素时,只要给出键对象,就能返回对应的值对象。

常用实现类有HashMap、LinkedHashMap、ConcurrentHashMap、TreeMap、HashTable

集合框架底层数据结构

Collection

List

Arraylist:Object数组 LinkedList:双向循环链表 Vector:Object数组

Set

HashSet(无序,唯一):基于 HashMap 实现,底层采用 HashMap 的key来保存元素 LinkedHashSet:LinkedHashSet 继承于 HashSet,并且其内部是通过 LinkedHashMap 来实现的。 TreeSet(有序,唯一):红黑树(自平衡的排序二叉树)

Map

HashMap:JDK1.8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8),但是数组长度小于64时会首先进行扩容,否则会将链表转化为红黑树,以减少搜索时间 LinkedHashMap:LinkedHashMap 继承自 HashMap,它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得LinkedHashMap可以保持键值对的插入顺序。 HashTable:数组+链表组成的,数组是 HashTable 的主体,链表则是主要为了解决哈希冲突而存在的 TreeMap:红黑树(自平衡的排序二叉树)

Collection接口

List接口

ArrayList、LinkedList、Vector 有何区别?

这三者都是实现集合框架中的 List 接口,也就是所谓的有序集合,因此具体功能也比较近似,比如都提供搜索、添加或者删除的操作,都提供迭代器以遍历其内容等功能。

数据结构实现:ArrayList 和 Vector 是动态数组的数据结构实现,而 LinkedList 是双向循环链表的数据结构实现。 随机访问效率:ArrayList 和 Vector 比 LinkedList 在根据索引随机访问的时候效率要高,因为 LinkedList 是链表数据结构,需要移动指针从前往后依次查找。 增加和删除效率:在非尾部的增加和删除操作,LinkedList 要比 ArrayList 和 Vector 效率要高,因为 ArrayList 和 Vector 增删操作要影响数组内的其他数据的下标,需要进行数据搬移。因为 ArrayList 非线程安全,在增删元素时性能比 Vector 好。 内存空间占用:一般情况下LinkedList 比 ArrayList 和 Vector 更占内存,因为 LinkedList 的节点除了存储数据,还存储了两个引用,分别是前驱节点和后继节点 线程安全:ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;Vector 使用了 synchronized 来实现线程同步,是线程安全的。 扩容:ArrayList 和 Vector 都会根据实际的需要动态的调整容量,只不过在 Vector 扩容每次会增加 1 倍容量,而 ArrayList 只会增加 50%容量。 使用场景:在需要频繁地随机访问集合中的元素时,推荐使用 ArrayList,希望线程安全的对元素进行增删改操作时,推荐使用Vector,而需要频繁插入和删除操作时,推荐使用 LinkedList。

Java集合的快速失败机制 “fail-fast”?

快速失败机制是java集合的一种错误检测机制,当多个线程对集合进行结构上的改变时,有可能会产生 fail-fast 机制。

例如:假设存在两个线程(线程1、线程2),线程1通过Iterator在遍历集合A中的元素,在某个时候线程2修改了集合A的结构(是结构上面的修改,而不是简单的修改集合元素的内容),那么这个时候程序就会抛出 ConcurrentModificationException 异常,从而产生fail-fast机制。

原因分析:迭代器在遍历时,ArrayList的父类AbstarctList中有一个modCount变量,每次对集合进行修改时都会modCount++,而foreach的实现原理其实就是Iterator,ArrayList的Iterator中有一个expectedModCount变量,该变量会初始化和modCount相等,每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount的值和expectedmodCount的值是否相等,如果集合进行增删操作,modCount变量就会改变,就会造成expectedModCount!=modCount,此时就会抛出ConcurrentModificationException异常

解决办法:

在遍历过程中,所有涉及到改变modCount值的地方全部加上synchronized。 使用CopyOnWriteArrayList来替换ArrayList

迭代器Iterator是什么?如何一边遍历一边删除Collection中的元素

Iterator 接口提供遍历任何 Collection 的接口。我们可以从一个 Collection 中使用迭代器方法来获取迭代器实例。迭代器取代了 Java 集合框架中的 Enumeration,同时迭代器允许调用者在迭代过程中增删元素。

边遍历边修改 Collection 的唯一正确方式是使用 Iterator.remove() 方法,如下:

Iterator it = list.iterator();

while(it.hasNext()){

// do something

it.remove();

}

一种最常见的错误代码如下:

for(Integer i : list){

list.remove(i)

}

运行以上错误代码会报 ConcurrentModificationException 异常。

遍历一个 List 有哪些不同的方式?原理是什么?

遍历方式有以下几种:

for 循环遍历,基于计数器。在集合外部维护一个计数器,然后依次读取每一个位置的元素,当读取到最后一个元素后停止。 迭代器遍历,Iterator。Iterator 是面向对象的一个设计模式,目的是屏蔽不同数据集合的差异,提供统一遍历集合的接口。Java 在 Collections 集合都支持了 Iterator 遍历。 foreach 循环遍历。foreach 内部也是采用了 Iterator 的方式实现,使用时不需要显式声明 Iterator 或计数器。优点是代码简洁,不易出错;缺点是只能做简单的遍历,不能在遍历过程中不能对集合进行增删操作。

最佳实践:Java Collections 框架中提供了一个 RandomAccess 接口,用来标记 List 实现是否支持 Random Access。

如果一个数据集合实现了该接口,就意味着它支持 Random Access,按索引读取元素的平均时间复杂度为 O(1),如ArrayList。 如果没有实现该接口,表示不支持 Random Access,如LinkedList。

Java 中 List 遍历的最佳实践是什么?

推荐的做法就是,支持 Random Access 的列表可用 for 循环遍历,否则建议用 Iterator 或 foreach 遍历。

说一下 ArrayList 的优缺点

ArrayList的优点如下:

ArrayList 底层以数组实现,ArrayList 实现了 RandomAccess 接口,根据索引进行随机访问的时候速度非常快。 ArrayList 在尾部添加一个元素的时候非常方便。

ArrayList 的缺点如下:

在非尾部的增加和删除操作,影响数组内的其他数据的下标,需要进行数据搬移,比较消耗性能

ArrayList 比较适合顺序添加、随机访问的场景。

为什么 ArrayList 的 elementData 加上 transient 修饰?

ArrayList 中的数组定义如下:

private transient Object[] elementData;

再看一下 ArrayList 的定义:

public class ArrayList extends AbstractList

  implements List, RandomAccess, Cloneable, java.io.Serializable

可以看到 ArrayList 实现了 Serializable 接口,这意味着 ArrayList 支持序列化。transient 的作用是不希望 elementData 数组被序列化,重写了 writeObject 实现:

private void writeObject(java.io.ObjectOutputStream s)

  throws java.io.IOException{

  // Write out element count, and any hidden stuff

  int expectedModCount = modCount;

  s.defaultWriteObject();

  // Write out size as capacity for behavioural compatibility with clone()

  s.writeInt(size);

  // Write out all elements in the proper order.

  for (int i=0; i

      s.writeObject(elementData[i]);

}

  if (modCount != expectedModCount) {

      throw new ConcurrentModificationException();

}

}

writeObject 的功能:每次序列化时,先调用 defaultWriteObject() 方法序列化 ArrayList 中的非 transient 元素,然后遍历 elementData,只序列化已存入的元素,这样既加快了序列化的速度,又减小了序列化之后的文件大小。

源码分析add()方法,remove()方法

add()方法(有四个)

增和删是ArrayList最重要的部分,这部分代码需要我们细细研究

//添加一个特定的元素到list的末尾

public boolean add(E e) {

  //先确保elementData数组的长度足够,size是数组中数据的个数,因为要添加一个元素,所以size+1,先判断size+1的这个个数数组能否放得下,在这个方法中去判断数组长度是否够用

  ensureCapacityInternal(size + 1); // Increments modCount!!

  //在数据中正确的位置上放上元素e,并且size++

  elementData[size++] = e;

  return true;

}

//在指定位置添加一个元素

public void add(int index, E element) {

  rangeCheckForAdd(index);

  //先确保elementData数组的长度足够

  ensureCapacityInternal(size + 1); // Increments modCount!!

  //将数据整体向后移动一位,空出位置之后再插入,效率不太好

  System.arraycopy(elementData, index, elementData, index + 1,

                      size - index);

  elementData[index] = element;

  size++;

}

// 校验插入位置是否合理

private void rangeCheckForAdd(int index) {

  //插入的位置肯定不能大于size 和小于0

  if (index > size || index < 0)  

      //如果是,就报越界异常

      throw new IndexOutOfBoundsException(outOfBoundsMsg(index));

}

//添加一个集合

public boolean addAll(Collection c) {

  //把该集合转为对象数组

  Object[] a = c.toArray();

  int numNew = a.length;

  //增加容量

  ensureCapacityInternal(size + numNew); // Increments modCount

  //挨个向后迁移

  System.arraycopy(a, 0, elementData, size, numNew);

  size += numNew;

  //新数组有元素,就返回 true

  return numNew != 0;

}

//在指定位置,添加一个集合

public boolean addAll(int index, Collection c) {

  rangeCheckForAdd(index);

  Object[] a = c.toArray();

  int numNew = a.length;

  ensureCapacityInternal(size + numNew); // Increments modCount

  int numMoved = size - index;

  //原来的数组挨个向后迁移

  if (numMoved > 0)

      System.arraycopy(elementData, index, elementData, index + numNew,

                      numMoved);

  //把新的集合数组 添加到指定位置

  System.arraycopy(a, 0, elementData, index, numNew);

  size += numNew;

  return numNew != 0;

}

对数组的容量进行调整

以上两种添加数据的方式都调用到了ensureCapacityInternal这个方法,我们看看它是如何完成工作的

//确保内部容量够用

private void ensureCapacityInternal(int minCapacity) {

  ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));

}

//计算容量。判断初始化的elementData是不是空的数组,如果是空的话,返回默认容量10与minCapacity=size+1的较大值者。

private static int calculateCapacity(Object[] elementData, int minCapacity) {

  if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {

      return Math.max(DEFAULT_CAPACITY, minCapacity);

}

  return minCapacity;

}

//确认实际的容量,这个方法就是真正的判断elementData是否够用

private void ensureExplicitCapacity(int minCapacity) {

  modCount++;

  //minCapacity如果大于了实际elementData的长度,那么就说明elementData数组的长度不够用,不够用那么就要增加elementData的length。这里有的小伙伴就会模糊minCapacity到底是什么呢,这里解释一下

/**

  * 当我们要 add 进第1个元素到 ArrayList 时,elementData.length 为0 (因为还是一个空的 list),因为执行了 `ensureCapacityInternal()` 方法 ,所以 minCapacity 此时为10。此时,`minCapacity - elementData.length > 0 `成立,所以会进入 `grow(minCapacity)` 方法。

  * 当add第2个元素时,minCapacity 为2,此时elementData.length(容量)在添加第一个元素后扩容成 10 了。此时,`minCapacity - elementData.length > 0 ` 不成立,所以不会进入 (执行)`grow(minCapacity)` 方法。

  * 添加第3、4···到第10个元素时,依然不会执行grow方法,数组容量都为10。

  * 直到添加第11个元素,minCapacity(为11)比elementData.length(为10)要大。进入grow方法进行扩容。

  */

  // overflow-conscious code

  if (minCapacity - elementData.length > 0)

      //ArrayList能自动扩展大小的关键方法就在这里了

      grow(minCapacity);

}

//扩容核心方法

private void grow(int minCapacity) {

  //将扩充前的elementData大小给oldCapacity

  // overflow-conscious code

  int oldCapacity = elementData.length;

  //新容量newCapacity是1.5倍的旧容量oldCapacity

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

  //这句话就是适应于elementData就空数组的时候,length=0,那么oldCapacity=0,newCapacity=0,所以这个判断成立,在这里就是真正的初始化elementData的大小了,就是为10。

  if (newCapacity - minCapacity < 0)

      newCapacity = minCapacity;

  //如果newCapacity超过了最大的容量限制,就调用hugeCapacity,也就是将能给的最大值给newCapacity

  if (newCapacity - MAX_ARRAY_SIZE > 0)

      newCapacity = hugeCapacity(minCapacity);

  //新的容量大小已经确定好了,就copy数组,改变容量大小。

  // minCapacity is usually close to size, so this is a win:

  elementData = Arrays.copyOf(elementData, newCapacity);

}

//这个就是上面用到的方法,很简单,就是用来赋最大值。

private static int hugeCapacity(int minCapacity) {

  if (minCapacity < 0) // overflow

      throw new OutOfMemoryError();

  //如果minCapacity都大于MAX_ARRAY_SIZE,那么就Integer.MAX_VALUE返回,反之将MAX_ARRAY_SIZE返回。因为maxCapacity是三倍的minCapacity,可能扩充的太大了,就用minCapacity来判断了。

//Integer.MAX_VALUE:2147483647   MAX_ARRAY_SIZE:2147483639 也就是说最大也就能给到第一个数值。还是超过了这个限制,就要溢出了。相当于arraylist给了两层防护。

  return (minCapacity > MAX_ARRAY_SIZE) ?

      Integer.MAX_VALUE :

  MAX_ARRAY_SIZE;

}

至此,我们彻底明白了ArrayList的扩容机制了。首先创建一个空数组elementData,第一次插入数据时直接扩充至10,然后如果elementData的长度不足,就扩充至1.5倍,如果扩充完还不够,就使用需要的长度作为elementData的长度。

remove()方法

其实这几个删除方法都是类似的。

//根据索引删除指定位置的元素

public E remove(int index) {

  //检查index的合理性

  rangeCheck(index);

//这个作用很多,比如用来检测快速失败的一种标志。

  modCount++;

  //通过索引直接找到该元素

  E oldValue = elementData(index);

  //计算要移动的位数。

  int numMoved = size - index - 1;

  if (numMoved > 0)

      //移动元素,挨个往前移一位。

      System.arraycopy(elementData, index+1, elementData, index,

                      numMoved);

  //将--size上的位置赋值为null,让gc(垃圾回收机制)更快的回收它。

  elementData[--size] = null; // clear to let GC do its work

//返回删除的元素。

  return oldValue;

}

//从此列表中删除指定元素的第一个匹配项,如果存在,则删除。通过元素来删除该元素,就依次遍历,如果有这个元素,就将该元素的索引传给fastRemove(index),使用这个方法来删除该元素,fastRemove(index)方法的内部跟remove(index)的实现几乎一样,这里最主要是知道arrayList可以存储null值

public boolean remove(Object o) {

  if (o == null) {

      //挨个遍历找到目标

      for (int index = 0; index < size; index++)

          if (elementData[index] == null) {

              //快速删除

              fastRemove(index);

              return true;

        }

} else {

      for (int index = 0; index < size; index++)

          if (o.equals(elementData[index])) {

              fastRemove(index);

              return true;

        }

}

  return false;

}

//内部方法,“快速删除”,就是把重复的代码移到一个方法里

private void fastRemove(int index) {

  modCount++;

  int numMoved = size - index - 1;

  if (numMoved > 0)

      System.arraycopy(elementData, index+1, elementData, index,

                      numMoved);

  elementData[--size] = null; // clear to let GC do its work

}

//删除或者保留指定集合中的元素

//用于两个方法,一个removeAll():它只清除指定集合中的元素,retainAll()用来测试两个集合是否有交集。 

private boolean batchRemove(Collection c, boolean complement) {

  //将原集合,记名为A

  final Object[] elementData = this.elementData;

  //r用来控制循环,w是记录有多少个交集

  int r = 0, w = 0;

  boolean modified = false;

  try {

      //遍历 ArrayList 集合

      for (; r < size; r++)

          //参数中的集合c一次检测集合A中的元素是否有

          if (c.contains(elementData[r]) == complement)

              //有的话,就给集合A

              elementData[w++] = elementData[r];

} finally {

      //发生了异常,直接把 r 后面的复制到 w 后面

      if (r != size) {

          //将剩下的元素都赋值给集合A

          System.arraycopy(elementData, r,

                          elementData, w,

                          size - r);

          w += size - r;

    }

      if (w != size) {

          //这里有两个用途,在removeAll()时,w一直为0,就直接跟clear一样,全是为null。

          //retainAll():没有一个交集返回true,有交集但不全交也返回true,而两个集合相等的时候,返回false,所以不能根据返回值来确认两个集合是否有交集,而是通过原集合的大小是否发生改变来判断,如果原集合中还有元素,则代表有交集,而元集合没有元素了,说明两个集合没有交集。

          // 清除多余的元素,clear to let GC do its work

          for (int i = w; i < size; i++)

              elementData[i] = null;

          modCount += size - w;

          size = w;

          modified = true;

    }

}

  return modified;

}

//保留公共的

public boolean retainAll(Collection c) {

  Objects.requireNonNull(c);

  return batchRemove(c, true);

}

//将elementData中每个元素都赋值为null,等待垃圾回收将这个给回收掉

public void clear() {

  modCount++;

  //并没有直接使数组指向 null,而是逐个把元素置为空,下次使用时就不用重新 new 了

  for (int i = 0; i < size; i++)

      elementData[i] = null;

  size = 0;

}

总结:根据索引删除指定位置的元素,此时会把指定下标到数组末尾的元素挨个向前移动一个单位,并且会把数组最后一个元素设置为null,这样是为了方便之后将整个数组不被使用时,会被GC,可以作为小的技巧使用。

Set接口

HashSet如何检查重复?HashSet是如何保证数据不可重复的?

向HashSet 中add ()元素时,判断元素是否存在的依据,不仅要比较hash值,还要结合equles方法比较。HashSet 中的add()方法会使用HashMap 的put()方法。

HashMap 的 key 是唯一的,由源码可以看出 HashSet 添加进去的值就是作为HashMap 的key,并且在HashMap中如果K/V相同时,会用新的V覆盖掉旧的V,然后返回旧的V,所以不会重复( HashMap 比较key是否相等是先比较hashcode 再比较equals )。

以下是HashSet 部分源码:

private static final Object PRESENT = new Object();

private transient HashMap map;

public HashSet() {

  map = new HashMap<>();

}

public boolean add(E e) {

  // 调用HashMap的put方法,PRESENT是一个至始至终都相同的虚值

return map.put(e, PRESENT)==null;

}

HashSet与HashMap的区别

HashMapHashSet父接口实现了Map接口实现Set接口存储数据存储键值对仅存储对象添加元素调用put()向map中添加元素调用add()方法向Set中添加元素计算哈希值HashMap使用键(Key)计算hashcodeHashSet使用对象来计算hashcode值,对于两个对象来说hashcode可能相同,需要用equals()方法用来判断对象的相等性,如果两个对象不同的话,那么返回false获取元素的速度HashMap相对于HashSet较快,因为它是使用唯一的键获取对象HashSet较HashMap来说比较慢

Map接口

HashMap 的实现原理?

在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链法的方式可以解决哈希冲突。

JDK1.8之前

JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

JDK1.8之后

相比于之前的版本,jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8),但是数组长度小于64时会首先进行扩容,否则会将链表转化为红黑树,以减少搜索时间。

JDK1.7 VS JDK1.8 比较

JDK1.8主要解决或优化了一下问题:

resize 扩容优化 引入了红黑树,目的是避免单条链表过长而影响查询效率 解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题。

不同JDK 1.7JDK 1.8存储结构数组 + 链表数组 + 链表 + 红黑树初始化方式单独函数:inflateTable()直接集成到了扩容函数resize()中hash值计算方式扰动处理 = 9次扰动 = 4次位运算 + 5次异或运算扰动处理 = 2次扰动 = 1次位运算 + 1次异或运算存放数据的规则无冲突时,存放数组;冲突时,存放链表无冲突时,存放数组;冲突 & 链表长度 < 8:存放单链表;冲突 & 链表长度 > 8 & 数组长度 < 64,扩容;冲突 & 数组长度 > 64:链表树化并存放红黑树插入数据方式头插法(先将原位置的数据都向后移动1位,再插入数据到头位置)尾插法(直接插入到链表尾部/红黑树)扩容后存储位置的计算方式全部按照原来方法进行计算(即hashCode ->> 扰动函数 ->> (h&length-1))按照扩容后的规律计算(即扩容后的位置=原位置 or 原位置 + 旧容量)

HashMap的put方法的具体流程?

当我们put的时候,首先计算 key的hash值,这里调用了 hash方法,hash方法实际是让key.hashCode()与key.hashCode()>>>16进行异或操作,高16bit补0,一个数和0异或不变,所以 hash 函数大概的作用就是:高16bit不变,低16bit和高16bit做了一个异或,目的是减少碰撞。按照函数注释,因为bucket数组大小是2的幂,计算下标index = (table.length - 1) & hash,如果不做 hash 处理,相当于散列生效的只有几个低 bit 位,为了减少散列的碰撞,设计者综合考虑了速度、作用、质量之后,使用高16bit和低16bit异或来简单处理减少碰撞,而且JDK8中用了复杂度 O(logN) 的树结构来提升碰撞下的性能。

putVal方法执行流程图

public V put(K key, V value) {

  return putVal(hash(key), key, value, false, true);

}

static final int hash(Object key) {

  int h;

  return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

}

//实现Map.put和相关方法

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,

                  boolean evict) {

  Node[] tab; Node p; int n, i;

  // 步骤①:tab为空则创建

  // table未初始化或者长度为0,进行扩容

  if ((tab = table) == null || (n = tab.length) == 0)

      n = (tab = resize()).length;

  // 步骤②:计算index,并对null做处理  

  // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)

  if ((p = tab[i = (n - 1) & hash]) == null)

      tab[i] = newNode(hash, key, value, null);

  // 桶中已经存在元素

  else {

      Node e; K k;

      // 步骤③:节点key存在,直接覆盖value

      // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等

      if (p.hash == hash &&

          ((k = p.key) == key || (key != null && key.equals(k))))

              // 将第一个元素赋值给e,用e来记录

              e = p;

      // 步骤④:判断该链为红黑树

      // hash值不相等,即key不相等;为红黑树结点

      // 如果当前元素类型为TreeNode,表示为红黑树,putTreeVal返回待存放的node, e可能为null

      else if (p instanceof TreeNode)

          // 放入树中

          e = ((TreeNode)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);

                  //判断链表的长度是否达到转化红黑树的临界值,临界值为8

                  if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st

                      //链表结构转树形结构

                      treeifyBin(tab, hash);

                  // 跳出循环

                  break;

              }

              // 判断链表中结点的key值与插入的元素的key值是否相等

              if (e.hash == hash &&

                  ((k = e.key) == key || (key != null && key.equals(k))))

                  // 相等,跳出循环

                  break;

              // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表

              p = e;

          }

      }

      //判断当前的key已经存在的情况下,再来一个相同的hash值、key值时,返回新来的value这个值

      if (e != null) {

          // 记录e的value

          V oldValue = e.value;

          // onlyIfAbsent为false或者旧值为null

          if (!onlyIfAbsent || oldValue == null)

              //用新值替换旧值

              e.value = value;

          // 访问后回调

          afterNodeAccess(e);

          // 返回旧值

          return oldValue;

      }

  }

  // 结构性修改

  ++modCount;

  // 步骤⑥:超过最大容量就扩容

  // 实际大小大于阈值则扩容

  if (++size > threshold)

      resize();

  // 插入后回调

  afterNodeInsertion(evict);

  return null;

}

①.判断键值对数组table[i]是否为空或为null,是的话执行resize()进行扩容;

②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;

③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;

④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;

⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;

⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。

HashMap的扩容操作是怎么实现的?

①.在jdk1.8中,resize方法是在hashmap中的键值对大于阀值时或者初始化时,就调用resize方法进行扩容;

②.每次扩展的时候,新容量为旧容量的2倍;

③.扩展后元素的位置要么在原位置,要么移动到原位置 + 旧容量的位置。

在putVal()中使用到了2次resize()方法,resize()方法在进行第一次初始化时会对其进行扩容,或者当该数组的实际大小大于其临界值(第一次为12),这个时候在扩容的同时也会伴随的桶上面的元素进行重新分发,这也是JDK1.8版本的一个优化的地方,在1.7中,扩容之后需要重新去计算其Hash值,根据Hash值对其进行分发,但在1.8版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)是否为0,重新进行hash分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+旧容量的位置

final Node[] resize() {

  Node[] oldTab = table;//oldTab指向hash桶数组

  int oldCap = (oldTab == null) ? 0 : oldTab.length;

  int oldThr = threshold;

  int newCap, newThr = 0;

  if (oldCap > 0) {//如果oldCap不为空的话,就是hash桶数组不为空

      if (oldCap >= MAXIMUM_CAPACITY) {//如果大于最大容量了,就赋值为整数最大的阀值

          threshold = Integer.MAX_VALUE;

          return oldTab;//返回

      }//如果当前hash桶数组的长度在扩容后仍然小于最大容量 并且oldCap大于默认值16

      else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&

                oldCap >= DEFAULT_INITIAL_CAPACITY)

          newThr = oldThr << 1; // double threshold 双倍扩容阀值threshold

  }

  // 旧的容量为0,但threshold大于零,代表有参构造有cap传入,threshold已经被初始化成最小2的n次幂

  // 直接将该值赋给新的容量

  else if (oldThr > 0) // initial capacity was placed in threshold

      newCap = oldThr;

  // 无参构造创建的map,给出默认容量和threshold 16, 16*0.75

  else {               // zero initial threshold signifies using defaults

      newCap = DEFAULT_INITIAL_CAPACITY;

      newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

  }

  // 新的threshold = 新的cap * 0.75

  if (newThr == 0) {

      float ft = (float)newCap * loadFactor;

      newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?

                (int)ft : Integer.MAX_VALUE);

  }

  threshold = newThr;

  // 计算出新的数组长度后赋给当前成员变量table

  @SuppressWarnings({"rawtypes","unchecked"})

      Node[] newTab = (Node[])new Node[newCap];//新建hash桶数组

  table = newTab;//将新数组的值复制给旧的hash桶数组

  // 如果原先的数组没有初始化,那么resize的初始化工作到此结束,否则进入扩容元素重排逻辑,使其均匀的分散

  if (oldTab != null) {

      // 遍历新数组的所有桶下标

      for (int j = 0; j < oldCap; ++j) {

          Node e;

          if ((e = oldTab[j]) != null) {

              // 旧数组的桶下标赋给临时变量e,并且解除旧数组中的引用,否则就数组无法被GC回收

              oldTab[j] = null;

              // 如果e.next==null,代表桶中就一个元素,不存在链表或者红黑树

              if (e.next == null)

                  // 用同样的hash映射算法把该元素加入新的数组

                  newTab[e.hash & (newCap - 1)] = e;

              // 如果e是TreeNode并且e.next!=null,那么处理树中元素的重排

              else if (e instanceof TreeNode)

                  ((TreeNode)e).split(this, newTab, j, oldCap);

              // e是链表的头并且e.next!=null,那么处理链表中元素重排

              else { // preserve order

                  // loHead,loTail 代表扩容后不用变换下标,见注1

                  Node loHead = null, loTail = null;

                  // hiHead,hiTail 代表扩容后变换下标,见注1

                  Node hiHead = null, hiTail = null;

                  Node next;

                  // 遍历链表

                  do {            

                      next = e.next;

                      if ((e.hash & oldCap) == 0) {

                          if (loTail == null)

                              // 初始化head指向链表当前元素e,e不一定是链表的第一个元素,初始化后loHead

                              // 代表下标保持不变的链表的头元素

                              loHead = e;

                          else                                

                              // loTail.next指向当前e

                              loTail.next = e;

                          // loTail指向当前的元素e

                          // 初始化后,loTail和loHead指向相同的内存,所以当loTail.next指向下一个元素时,

                          // 底层数组中的元素的next引用也相应发生变化,造成lowHead.next.next.....

                          // 跟随loTail同步,使得lowHead可以链接到所有属于该链表的元素。

                          loTail = e;                          

                      }

                      else {

                          if (hiTail == null)

                              // 初始化head指向链表当前元素e, 初始化后hiHead代表下标更改的链表头元素

                              hiHead = e;

                          else

                              hiTail.next = e;

                          hiTail = e;

                      }

                  } while ((e = next) != null);

                  // 遍历结束, 将tail指向null,并把链表头放入新数组的相应下标,形成新的映射。

                  if (loTail != null) {

                      loTail.next = null;

                      newTab[j] = loHead;

                  }

                  if (hiTail != null) {

                      hiTail.next = null;

                      newTab[j + oldCap] = hiHead;

                  }

              }

          }

      }

  }

  return newTab;

}

HashMap是怎么解决哈希冲突的?

在解决这个问题之前,我们首先需要知道什么是哈希冲突,而在了解哈希冲突之前我们还要知道什么是哈希才行;

什么是哈希?

Hash,一般翻译为“散列”,也有直接音译为“哈希”的,这就是把任意长度的输入通过散列算法,变换成固定长度的输出,该输出就是散列值(哈希值);这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。

所有散列函数都有如下一个基本特性:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。

什么是哈希冲突?

当两个不同的输入值,根据同一散列函数计算出相同的散列值的现象,我们就把它叫做碰撞(哈希碰撞)。

HashMap的数据结构

在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链法的方式可以解决哈希冲突。

这样我们就可以将拥有相同哈希值的对象组织成一个链表放在hash值所对应的bucket下,但相比于hashCode返回的int类型,我们HashMap初始的容量大小DEFAULT_INITIAL_CAPACITY = 1 << 4(即2的四次方16)要远小于int类型的范围,所以我们如果只是单纯的用hashCode取余来获取对应的bucket这将会大大增加哈希碰撞的概率,并且最坏情况下还会将HashMap变成一个单链表,所以我们还需要对hashCode作一定的优化

hash()函数

上面提到的问题,主要是因为如果使用hashCode取余,那么相当于参与运算的只有hashCode的低位,高位是没有起到任何作用的,所以我们的思路就是让hashCode取值出的高位也参与运算,进一步降低hash碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动,在JDK 1.8中的hash()函数如下:

static final int hash(Object key) {

  int h;

  return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 与自己右移16位进行异或运算(高低位异或)

}

这比在JDK 1.7中,更为简洁,相比在1.7中的4次位运算,5次异或运算(9次扰动),在1.8中,只进行了1次位运算和1次异或运算(2次扰动);

JDK1.8新增红黑树

通过上面的链地址法(使用散列表)和扰动函数我们成功让我们的数据分布更平均,哈希碰撞减少,但是当我们的HashMap中存在大量数据时,加入我们某个bucket下对应的链表有n个元素,那么遍历时间复杂度就为O(n),为了针对这个问题,JDK1.8在HashMap中新增了红黑树的数据结构,进一步使得遍历复杂度降低至O(logn);

总结

简单总结一下HashMap是使用了哪些方法来有效解决哈希冲突的:

1. 使用拉链法(使用散列表)来链接拥有相同hash值的数据

2. 使用2次扰动函数(hash函数)来降低哈希冲突的概率,使得数据分布更平均

3. 引入红黑树进一步降低遍历的时间复杂度,使得遍历更快

为什么HashMap中String、Integer这样的包装类适合作为key?

String、Integer等包装类的特性能够保证Hash值的不可更改性和计算准确性,能够有效的减少Hash碰撞的几率

都是final类型,即不可变性,保证key的不可更改性,不会存在同一对象获取hash值不同的情况 内部已重写了equals()、hashCode()等方法,遵守了HashMap内部的规范,不容易出现Hash值计算错误的情况;

如果使用Object作为HashMap的Key,应该怎么办呢?

重写hashCode()和equals()方法

重写hashCode()是因为需要计算数据的存储位置,需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这样虽然能更快,但可能会导致更多的Hash碰撞; 重写equals()方法,需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用值x,x.equals(null)必须返回false的这几个特性,目的是为了保证key在哈希表中的唯一性;

HashMap 的长度为什么是2的幂次方

为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀,每个链表/红黑树长度大致相同。这个实现就是把数据存到哪个链表/红黑树中的算法。

这个算法应该如何设计呢?

我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。” 并且 采用二进制与操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。

那为什么是两次扰动呢?

这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性&均匀性,最终减少Hash冲突,两次就够了,已经达到了高位低位同时参与运算的目的;

ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?

JDK1.7

首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。

在JDK1.7中,ConcurrentHashMap采用Segment + HashEntry的数据结构,结构如下:

一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一种数组和链表结构,segment继承了ReentrantLock,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。

该类包含两个静态内部类 HashEntry 和 Segment ;前者用来封装映射表的键值对,后者用来充当锁的角色; Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。

JDK1.8

在JDK1.8中,放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

结构如下:

对比 Hashtable、HashMap、TreeMap 有什么不同?

Hashtable、HashMap、TreeMap 都是最常见的 Map 实现,是以键值对的形式存储和操作数据的容器。

Hashtable 是早期 Java 类库提供的一个哈希表实现,本身是同步的即线程安全,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用。

HashMap 是应用更加广泛的哈希表实现,行为上大致上与 HashTable 一致,主要区别在于 HashMap 不是同步的,支持 null 键和值等。通常情况下,HashMap 进行 put 或者 get 操作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选。

TreeMap 则是基于红黑树的一种提供顺序访问的 Map,和 HashMap 不同,它的 get、put、remove 之类操作都是 O(logn) 的时间复杂度,具体顺序可以由指定的 Comparator 来决定,或者根据键的自然顺序来判断。

HashMap 和 ConcurrentHashMap 的区别

ConcurrentHashMap对整个桶数组进行了分割分段(Segment),然后在每一个分段上都用lock锁进行保护,相对于HashTable的synchronized锁的粒度更精细了一些,并发性能更好,而HashMap没有锁机制,不是线程安全的。(JDK1.8之后ConcurrentHashMap启用了一种全新的方式实现,利用CAS算法。) HashMap的键值对允许有null,但是ConCurrentHashMap都不允许。

ConcurrentHashMap 和 Hashtable 的区别?

jdk1.8采用的数据结构跟hashmap1.8的结构一样,数组+链表/红黑树,hashtable和jdk1.8之前的hashmap的底层数据机构类似都是采用数组+链表的形式,数组是hashmap的主体,链表则是主要为了解决哈希冲突而存在的

ConcurrentHashMap 和 Hashtable 的区别主要体现在底层数据结构和实现线程安全的方式上不同。

底层数据结构:JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的; 实现线程安全的方式(重要): ① 在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配16个Segment,比Hashtable效率提高16倍。) 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本; ② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈,效率越低。

两者的对比图:

HashTable:

JDK1.7的ConcurrentHashMap:

JDK1.8的ConcurrentHashMap(TreeBin: 红黑树节点 Node: 链表节点):

ConcurrentHashMap 结合了 HashMap 和 HashTable 二者的优势。HashMap 没有考虑同步,HashTable 考虑了同步的问题。但是 HashTable 在每次同步执行时都要锁住整个结构。ConcurrentHashMap 锁的方式是稍微细粒度的。

什么是红黑树

什么是二叉树

二叉树简单来说就是 每一个节上可以关联两个子节点

                        a

                    /     \

                  b         c

                / \         / \

              d   e       f   g

            / \ / \     / \   / \

            h   i j k   l   m n   o

红黑树定义和性质

红黑树是一种含有红黑结点并能自平衡的二叉查找树。它必须满足下面性质:

性质1:每个节点要么是黑色,要么是红色。 性质2:根节点是黑色。 性质3:每个叶子节点(NIL)是黑色。[注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!] 性质4:每个红结点的两个子结点一定都是黑色。 性质5:任意一结点到每个叶子结点的路径都包含相同数量的黑结点。

从性质5又可以推出:

性质5.1:如果一个结点存在黑子结点,那么该结点肯定有两个子结点

红黑树能自平衡,它靠的是什么?三种操作:左旋、右旋和变色。简单点说,旋转和变色的目的是让树保持红黑树的特性。

左旋:以某个结点作为支点(旋转结点),其右子结点变为旋转结点的父结点,右子结点的左子结点变为旋转结点的右子结点,左子结点保持不变。 右旋:以某个结点作为支点(旋转结点),其左子结点变为旋转结点的父结点,左子结点的右子结点变为旋转结点的左子结点,右子结点保持不变。 变色:结点的颜色由红变黑或由黑变红。

左旋

右旋

辅助工具类

comparable 和 comparator的区别?

Comparable 接口在 java.lang 包下,它有一个 compareTo(Object obj)方法用来排序;而 Comparator 接口在 java.util 包下,它有一个compare(Object obj1, Object obj2)方法用来排序 一个类实现了 Comparable 接口,意味着该类的对象可以直接进行比较(排序),但比较(排序)的方式只有一种,很单一;一个类如果想要保持原样,又需要进行不同方式的比较(排序),就可以定制比较器(实现 Comparator 接口)。 Comparable 更多的像一个内部比较器,而 Comparator 更多的像一个外部比较器(体现了一种策略模式,耦合性较低),如果对象的排序需要基于自然顺序,请选择 Comparable,如果需要按照对象的不同属性进行排序,请选择 Comparator。

Collection 和 Collections 有什么区别

java.util.Collection 是一个集合接口(集合类的一个顶级接口)。它提供了对集合对象进行基本操作的通用接口方法。Collection接口在Java 类库中有很多具体的实现。Collection接口的意义是为各种具体的集合提供了最大化的统一操作方式,其直接继承接口有List、Set,Queue。 java.util.Collections 则是集合类的一个工具类/帮助类,其中提供了一系列静态方法,用于对集合中元素进行排序、搜索以及线程安全等各种操作。

在使用 HashMap 的时候,用 String 做 key 有什么好处?

HashMap 内部实现是通过 key 的 hashcode 来确定 value 的存储位置,因为字符串是不可变的,所以当创建字符串时,它的 hashcode 被缓存下来,不需要再次计算,所以相比于其他对象更快。

throw 和 throws 的区别是什么?

throw 关键字用在方法内部,只能用于抛出一种异常,用来抛出方法或代码块中的异常。 throws 关键字用在方法声明上,可以抛出多个异常,用来标识该方法可能抛出的异常列表。调用该方法的方法必须包含可处理异常的代码,否则也要在方法声明中用 throws 关键字声明相应的异常。

网络编程

TCP/IP的五层体系结构分别是什么?

应用层

应用层( application-layer )的任务是通过应用进程间的交互来完成特定网络应用。应用层协议定义的是应用进程间的通信和交互的规则。

对于不同的网络应用需要不同的应用层协议。在互联网中应用层协议很多,如域名系统 DNS,支持万维网应用的 HTTP 协议,支持电子邮件的 SMTP 协议等等。

运输层

运输层(transport layer)的主要任务就是负责向两台主机进程之间的通信提供通用的数据传输服务。应用进程利用该服务传送应用层报文。

运输层主要使用一下两种协议

传输控制协议-TCP:提供面向连接的,可靠的数据传输服务。 用户数据协议-UDP:提供无连接的,尽最大努力的数据传输服务(不保证数据传输的可靠性)。

UDPTCP是否连接无连接面向连接是否可靠不可靠传输,不使用流量控制和拥塞控制可靠传输,使用流量控制和拥塞控制连接对象个数支持一对一,一对多,多对一和多对多交互通信只能是一对一通信传输方式面向报文面向字节流首部开销首部开销小,仅8字节首部最小20字节,最大60字节场景适用于实时应用(IP电话、视频会议、直播等)适用于要求可靠传输的应用,例如文件传输

每一个应用层(TCP/IP参考模型的最高层)协议一般都会使用到两个传输层协议之一:

运行在TCP协议上的协议:

HTTP(Hypertext Transfer Protocol,超文本传输协议),主要用于普通浏览。 HTTPS(HTTP over SSL,安全超文本传输协议),HTTP协议的安全版本。 FTP(File Transfer Protocol,文件传输协议),用于文件传输。 POP3(Post Office Protocol, version 3,邮局协议),收邮件用。 SMTP(Simple Mail Transfer Protocol,简单邮件传输协议),用来发送电子邮件。 TELNET(Teletype over the Network,网络电传),通过一个终端(terminal)登陆到网络。 SSH(Secure Shell,用于替代安全性差的TELNET),用于加密安全登陆用。

运行在UDP协议上的协议:

BOOTP(Boot Protocol,启动协议),应用于无盘设备。 NTP(Network Time Protocol,网络时间协议),用于网络同步。 DHCP(Dynamic Host Configuration Protocol,动态主机配置协议),动态配置IP地址。

运行在TCP和UDP协议上:

DNS(Domain Name Service,域名服务),用于完成地址查找,邮件转发等工作。

网络层

网络层的任务就是选择合适的网间路由和交换结点,确保计算机通信的数据及时传送。在发送数据时,网络层把运输层产生的报文段或用户数据报封装成分组和包进行传送。在 TCP/IP 体系结构中,由于网络层使用 IP 协议,因此分组也叫 IP 数据报 ,简称数据报。

互联网是由大量的异构(heterogeneous)网络通过路由器(router)相互连接起来的。互联网使用的网络层协议是无连接的网际协议(Intert Prococol)和许多路由选择协议,因此互联网的网络层也叫做网际层或 IP 层。

数据链路层

数据链路层(data link layer)通常简称为链路层。两台主机之间的数据传输,总是在一段一段的链路上传送的,这就需要使用专门的链路层的协议。

在两个相邻节点之间传送数据时,数据链路层将网络层交下来的 IP 数据报组装成帧,在两个相邻节点间的链路上传送帧。每一帧包括数据和必要的控制信息(如同步信息,地址信息,差错控制等)。

在接收数据时,控制信息使接收端能够知道一个帧从哪个比特开始和到哪个比特结束。

一般的web应用的通信传输流是这样的:

发送端在层与层之间传输数据时,每经过一层时会被打上一个该层所属的首部信息。反之,接收端在层与层之间传输数据时,每经过一层时会把对应的首部信息去除。

物理层

在物理层上所传送的数据单位是比特。物理层(physical layer)的作用是实现相邻计算机节点之间比特流的透明传送,尽可能屏蔽掉具体传输介质和物理设备的差异。使其上面的数据链路层不必考虑网络的具体传输介质是什么。“透明传送比特流”表示经实际电路传送后的比特流没有发生变化,对传送的比特流来说,这个电路好像是看不见的。

四层协议,五层协议,七层协议的区别

为了使不同体系结构的计算机网络都能互联,国际标准化组织 ISO 于1977年提出了一个试图使各种计算机在世界范围内互联成网的标准框架,即著名的开放系统互联基本参考模型 OSI/RM,简称为OSI。

OSI 的七层协议体系结构的概念清楚,理论也较完整,但它既复杂又不实用,TCP/IP 体系结构则不同,但它现在却得到了非常广泛的应用。TCP/IP 是一个四层体系结构,它包含应用层,运输层,网际层和网络接口层(用网际层这个名字是强调这一层是为了解决不同网络的互连问题),不过从实质上讲,TCP/IP 只有最上面的三层,因为最下面的网络接口层并没有什么具体内容,因此在学习计算机网络的原理时往往采用折中的办法,即综合 OSI 和 TCP/IP 的优点,采用一种只有五层协议的体系结构,这样既简洁又能将概念阐述清楚,有时为了方便,也可把最底下两层称为网络接口层。

四层协议,五层协议和七层协议的关系如下:

TCP/IP是一个四层的体系结构,主要包括:应用层、运输层、网际层和网络接口层。 五层协议的体系结构主要包括:应用层、运输层、网络层,数据链路层和物理层。 OSI七层协议模型主要包括是:应用层(Application)、表示层(Presentation)、会话层(Session)、运输层(Transport)、网络层(Network)、数据链路层(Data Link)、物理层(Physical)。

注:五层协议的体系结构只是为了介绍网络原理而设计的,实际应用还是 TCP/IP 四层体系结构。

TCP的三次握手四次挥手的过程

TCP是一种面向连接的、可靠的、基于字节流的传输层通信协议,它会处理IP层或以下的层的丢包、重复以及错误问题。在发送数据前,通信双方必须在彼此间建立一条连接。所谓的“连接”,其实是客户端和服务端保存的一份关于对方的信息,如ip地址、端口号等,这些信息放在TCP头部

一个TCP连接通常分为三个阶段:连接、数据传输、退出(关闭)。通过三次握手建立一个连接,通过四次挥手来关闭一个连接。当一个连接被建立或被终止时,交换的报文段只包含TCP头部,而没有数据。

TCP报文的头部结构

在了解TCP连接之前先来了解一下TCP报文的头部结构。

上图中有几个字段需要重点介绍下:

(1)序号:seq序号,占32位,用来标识从TCP源端向目的端发送的字节流,发起方发送数据时对此进行标记。

(2)确认序号:ack序号,占32位,只有ACK标志位为1时,确认序号字段才有效,ack=seq+1。

(3)标志位:共6个,即URG、ACK、PSH、RST、SYN、FIN等,具体含义如下:

ACK:确认序号有效。 FIN:释放一个连接。 PSH:接收方应该尽快将这个报文交给应用层。 RST:重置连接。 SYN:发起一个新连接。 URG:紧急指针(urgent pointer)有效。

需要注意的是:

不要将确认序号ack与标志位中的ACK搞混了。 确认方ack=发起方seq+1,两端配对。

三次握手

三次握手的本质是确认通信双方收发数据的能力

首先,我让信使运输一份信件给对方,对方收到了,那么他就知道了我的发件能力和他的收件能力是可以的。

于是他给我回信,我若收到了,我便知我的发件能力和他的收件能力是可以的,并且他的发件能力和我的收件能力是可以。

然而此时他还不知道他的发件能力和我的收件能力到底可不可以,于是我最后反馈一次,他若收到了,他便清楚了他的发件能力和我的收件能力是可以的。

这就是三次握手,这样说,你理解了吗?

第一次握手:客户端要向服务端发起连接请求,首先客户端随机生成一个起始序列号ISN(比如是100),那客户端向服务端发送的报文段包含SYN标志位(也就是SYN=1),序列号seq=100。 第二次握手:服务端收到客户端发过来的报文后,发现SYN=1,知道这是一个连接请求,于是将客户端的起始序列号100存起来,并且随机生成一个服务端的起始序列号(比如是300)。然后给客户端回复一段报文,回复报文包含SYN和ACK标志(也就是SYN=1,ACK=1)、序列号seq=300、确认号ack=101(客户端发过来的序列号+1)。 第三次握手:客户端收到服务端的回复后发现ACK=1并且ack=101,于是知道服务端已经收到了序列号为100的那段报文;同时发现SYN=1,知道了服务端同意了这次连接,于是就将服务端的序列号300给存下来。然后客户端再回复一段报文给服务端,报文包含ACK标志位(ACK=1)、ack=301(服务端序列号+1)、seq=101(第一次握手时发送报文是占据一个序列号的,所以这次seq就从101开始,需要注意的是不携带数据的ACK报文是不占据序列号的,所以后面第一次正式发送数据时seq还是101)。当服务端收到报文后发现ACK=1并且ack=301,就知道客户端收到序列号为300的报文了,就这样客户端和服务端通过TCP建立了连接。

四次挥手

四次挥手的目的是关闭一个连接

比如客户端初始化的序列号ISA=100,服务端初始化的序列号ISA=300。TCP连接成功后客户端总共发送了1000个字节的数据,服务端在客户端发FIN报文前总共回复了2000个字节的数据。

第一次挥手:当客户端的数据都传输完成后,客户端向服务端发出连接释放报文(当然数据没发完时也可以发送连接释放报文并停止发送数据),释放连接报文包含FIN标志位(FIN=1)、序列号seq=1101(100+1+1000,其中的1是建立连接时占的一个序列号)。需要注意的是客户端发出FIN报文段后只是不能发数据了,但是还可以正常收数据;另外FIN报文段即使不携带数据也要占据一个序列号。 第二次挥手:服务端收到客户端发的FIN报文后给客户端回复确认报文,确认报文包含ACK标志位(ACK=1)、确认号ack=1102(客户端FIN报文序列号1101+1)、序列号seq=2300(300+2000)。此时服务端处于关闭等待状态,而不是立马给客户端发FIN报文,这个状态还要持续一段时间,因为服务端可能还有数据没发完。 第三次挥手:服务端将最后数据(比如50个字节)发送完毕后就向客户端发出连接释放报文,报文包含FIN和ACK标志位(FIN=1,ACK=1)、确认号和第二次挥手一样ack=1102、序列号seq=2350(2300+50)。 第四次挥手:客户端收到服务端发的FIN报文后,向服务端发出确认报文,确认报文包含ACK标志位(ACK=1)、确认号ack=2351、序列号seq=1102。注意客户端发出确认报文后不是立马释放TCP连接,而是要经过2MSL(最长报文段寿命的2倍时长)后才释放TCP连接。而服务端一旦收到客户端发出的确认报文就会立马释放TCP连接,所以服务端结束TCP连接的时间要比客户端早一些。

为什么TCP连接的时候是3次?2次不可以吗?

因为需要考虑连接时丢包的问题,如果只握手2次,第二次握手时如果服务端发给客户端的确认报文段丢失,此时服务端已经准备好了收发数(可以理解服务端已经连接成功)据,而客户端一直没收到服务端的确认报文,所以客户端就不知道服务端是否已经准备好了(可以理解为客户端未连接成功),这种情况下客户端不会给服务端发数据,也会忽略服务端发过来的数据。

如果是三次握手,即便发生丢包也不会有问题,比如如果第三次握手客户端发的确认ack报文丢失,服务端在一段时间内没有收到确认ack报文的话就会重新进行第二次握手,也就是服务端会重发SYN报文段,客户端收到重发的报文段后会再次给服务端发送确认ack报文。

为什么TCP连接的时候是3次,关闭的时候却是4次?

因为只有在客户端和服务端都没有数据要发送的时候才能断开TCP。而客户端发出FIN报文时只能保证客户端没有数据发了,服务端还有没有数据发客户端是不知道的。而服务端收到客户端的FIN报文后只能先回复客户端一个确认报文来告诉客户端我服务端已经收到你的FIN报文了,但我服务端还有一些数据没发完,等这些数据发完了,服务端才能给客户端发FIN报文(所以不能一次性将确认报文和FIN报文发给客户端,就是这里多出来了一次)。

为什么客户端发出第四次挥手的确认报文后要等2MSL的时间才能释放TCP连接?

要考虑丢包的问题,如果第四次挥手的报文丢失,服务端没收到确认ack报文就会重发第三次挥手的报文,这样报文一去一回最长时间就是2MSL,所以需要等这么长时间来确认服务端确实已经收到了。

滑动窗口协议

为了最大效率的利用网络资源,增加网络的吞吐量,便产生了“滑动窗口”这种协议。滑动窗口机制是TCP协议中用来控制发送数据包速率的,发送方每次只能发送滑动窗口内部的数据包,同时解决了丢包,出错,乱序等一些情况。

问题一:如何保证次序?

提出问题:在我们滑动窗口协议之前,我们如何来保证发送方与接收方之间,每个包都能被收到。并且是按次序的呢?

发送方发送一个包1,这时候接收方确认包1。发送包2,确认包2。就这样一直下去,直到把数据完全发送完毕,这样就结束了。那么就解决了丢包,出错,乱序等一些情况!同时也存在一些问题。问题:吞吐量非常的低。我们发完包1,一定要等确认包1。我们才能发送第二个包。

问题二:如何提高吞吐量?

提出问题:那么我们就不能先连发几个包等他一起确认吗?这样的话,我们的速度会不会更快,吞吐量更高些呢?

如图,这个就是我们把两个包一起发送,然后一起确认。可以看出我们改进的方案比之前的好很多,所花的时间只是一个来回的时间。接下来,我们还有一个问题:改善了吞吐量的问题

问题三:如何实现最优解?

问题:我们每次需要发多少个包过去呢?发送多少包是最优解呢?

我们能不能把第一个和第二个包发过去后,收到第一个确认包就把第三个包发过去呢?而不是去等到第二个包的确认包才去发第三个包。这样就很自然的产生了我们"滑动窗口"的实现。

每收到一个新的确认(ack),滑动窗口的位置就向右移动一格。

在图中,我们可看出灰色1号2号3号包已经发送完毕,并且已经收到Ack。这些包就已经是过去式。4、5、6、7号包是黄色的,表示已经发送了。但是并没有收到对方的Ack,所以也不知道接收方有没有收到。8、9、10号包是绿色的。是我们还没有发送的。这些绿色也就是我们接下来马上要发送的包。后面的11-16还没有被读进内存。

正常情况

可以看到4号包对方已经被接收到,所以被涂成了灰色。“窗口”就往右移一格,这里只要保证“窗口”是7格的。我们就把11号包读进了我们的缓存。进入了“待发送”的状态。8、9号包已经变成了黄色,表示已经发送出去了。接下来的操作就是一样的了,确认包后,窗口往后移,继续将未发送的包读进缓存,把“待发送“状态的包变为”已发送“。

丢包情况

有可能我们包发过去,对方的Ack丢了,也有可能我们的包并没有发送过去。从发送方角度看就是我们没有收到Ack。

发生的情况:一直在等Ack。如果一直等不到的话,我们也会把读进缓存的待发送的包也一起发过去。但是,这个时候我们的窗口已经发满了。所以并不能把12号包读进来,而是始终在等待5号包的Ack。

如果我们这个Ack始终不来怎么办呢?

超时重发

这时候我们有个解决方法:超时重发 这里有一点要说明:这个Ack是要按顺序的。必须要等到5的Ack收到,才会把6-11的Ack发送过去。这样就保证了滑动窗口的一个顺序。

这时候可以看出5号包已经接受到Ack,后面的6、7、8号包也已经发送过去已Ack。窗口便继续向后移动。

DNS解析过程

DNS(Domain Name System):因特网使用的域名系统,本质是一个分布式数据库,用于解决域名和IP地址的映射关系。

DNS解析:互联网都是通过URL来请求资源的,而URL中的域名需要解析成IP地址才能与远程主机建立连接,将域名解析成IP地址就属于DNS解析的工作范畴。

域名结构

从技术角度来看,域名是在Internet上用于解决IP地址的一种方法。一个完整的域名由2个或2个以上的部分组成,各部分之间用英文的句号“.”来分隔,最后一个“.”的右边部分称为顶级域名(TLD,也称为一级域名),最后一个“.”的左边部分称为二级域名(SLD),二级域名的左边部分称为三级域名,以此类推,每一级的域名控制它下一级域名的分配。

如域名mail.cctv.com,其中:com为顶级域名( top-level-domain,TLD), cctv为二级域名,mail为三级域名

DNS解析过程

浏览器会检查缓存中有没有这域名对应的解析过的IP地址,如果缓存中有,这个解析过程就将结束。 如果过程1中浏览器缓存中没有域名对应的ip,则从操作系统本身去做域名解析,我们在windows中的host文件可以设置特定域名映射到特定ip。C:\Windows\System32\drivers\etc\hosts

然后在终端中ping www.baidu.com,如下图所示:

向本地域名解析服务器(LDNS)发起域名解析请求 上述步骤的1、2都是在本机中完成的域名解析,如果经过1、2步骤都没有完成域名的解析,则需要向LDNS发起域名解析,对于LDNS,Window中可以通过ipconfig/all来查询,如下所示:

LDNS 一般都缓存了大部分的域名解析结果,当然缓存时间也受域名失效的时间控制,大部分的解析工作到这里就差不多结束了,LDNS负责了大部分的解析工作。

向根域名解析服务器(RDNS)发起域名解析的请求 当步骤3中没有完成域名的解析,则需要向RDNS发起域名解析的请求 根域名服务器返回通用顶级域名解析服务器(gTDL)地址 LDNS向根域名服务器发起请求,根域名服务器返回的是所查询的通用顶级域名(Generic top-level-domain, gTLD)地址,常见的通用顶级域名有.com、.org、.edu。 本地域名服务器向gTLD发起解析域名请求 gTLD服务器接收请求并返回注册的域名服务器(Name Server服务器,即名称服务器) 当gTLD服务器接收到本地域名服务器发起的请求后,并根据需要解析的域名,找到该域名对应的Name Server服务器,通常情况下,这个Name Server服务器就是你注册的域名服务器,那么你注册的域名的服务上的服务器将承担起域名解析的任务。 本地域名服务器向Name Server服务器发起域名解析请求 Name Server服务器会查询存储的域名和IP的映射关系表,然后返回该域名对应的ip和TTL给本地域名服务器,本地域名服务器进行缓存这个域名和ip的对应关系,缓存时间由TTL决定。 本地域名服务器返回查询域名对应的ip给用户(浏览器),浏览器进行缓存,缓存时间由TTL决定。

经过以上的10个步骤,就可以拿到真正的ip了,然后通过ip去对应的服务器上请求资源。

CDN原理,CDN加速过程

CDN内容分发网络,全称为Content DeliveryNetwork,通过将网络内容发布到最靠近用户的『边缘节点』,使不同地区的用户在访问相同页面、图片或视频时就可以就近获取。

CDN本质是一种分布式缓存系统,无需考虑数据持久化,如果缓存服务器出现问题,在缓存集群中标记为删除即可。

CDN中实现原理:给源站域名添加CNAME别名,别名为加速节点的域名。当用户向源站发起请求时,dns服务器解析源站域名时会发现有CNAME记录,这时dns服务器会向CNAME域名发起请求,请求会被调度至加速节点的域名。

CDN的优点

根据用户与业务服务器的距离,自动选择就近的cache服务器 镜像服务,消除运营商之间互联的瓶颈影响,保证不同网络的用户都能得到良好的访问质量 带宽优化,分担网络流量,减轻压力

域名与ip的对应关系,被称为记录(record),可分为各种类型

A记录: Address,域名指向的IP地址,A记录允许将多个域名解析到一个IP地址,但不允许将一个域名解析到多个IP上。 NS记录:Name Server,指定了特定的DNS服务器去解析。 MX记录:Mail eXchange,接受电子邮件的服务器地址 CNAME记录:Canonical Name,别名解析,可以将指定的域名解析到其他域名上

CDN的过程

使用CDN的方法很简单,只需要修改自己的DNS解析,设置一个CNAME指向CDN服务商即可。

用户访问未使用CDN缓存资源的过程

浏览器通过DNS解析,以得到此域名对应的IP地址; 浏览器使用所得到的IP地址,向域名的服务主机发出数据访问请求; 服务器向浏览器返回响应数据

使用CDN后

假设您的加速域名为www.a.com,接入CDN网络,开始使用加速服务后,当终端用户(北京)发起HTTP请求时,处理流程如下图所示。

当终端用户(北京)向www.a.com下的指定资源发起请求时,首先向LDNS(本地DNS)发起域名解析请求。 LDNS检查缓存中是否有www.a.com的IP地址记录。如果有,则直接返回给终端用户;如果没有,则向授权DNS查询。 当授权DNS解析www.a.com时,返回域名CNAME www.a.tbcdn.com对应IP地址。 域名解析请求发送至阿里云DNS调度系统,并为请求分配最佳节点IP地址。 LDNS获取DNS返回的解析IP地址。 用户获取解析IP地址。 用户向获取的IP地址发起对该资源的访问请求。

如果该IP地址对应的节点已缓存该资源,则会将数据直接返回给用户,例如,图中步骤7和8,请求结束。 如果该IP地址对应的节点未缓存该资源,则节点向源站发起对该资源的请求。获取资源后,结合用户自定义配置的缓存策略,将资源缓存至节点,例如,图中的北京节点,并返回给用户,请求结束。

GET和POST区别

说道GET和POST,就不得不提HTTP协议,因为浏览器和服务器的交互是通过HTTP协议执行的,而GET和POST也是HTTP协议中的两种方法。

HTTP全称为Hyper Text Transfer Protocol,中文翻译为超文本传输协议,目的是保证浏览器与服务器之间的通信。HTTP的工作方式是客户端与服务端之间的请求-应答协议。

HTTP协议中定义了浏览器和服务器进行交互的不同方法,基本方法有4种,分别是GET,POST,PUT,DELETE。这四种方法可以理解为,对服务器资源的查,改,增,删。

GET:从服务器上获取数据,也就是所谓的查,仅仅是获取服务器资源,不进行修改。 POST:向服务器提交数据,这就涉及到了数据的更新,也就是更改服务器的数据。 PUT:英文含义是放置,也就是向服务器新添加数据,就是所谓的增。 DELETE:从字面意思也能看出,这种方式就是删除服务器数据的过程。

GET和POST区别

Get是不安全的,因为在传输过程,数据被放在请求的URL中;Post的所有操作对用户来说都是不可见的。但是这种做法也不是绝对的,大部分人的做法也是按照上面的说法来的,但是也可以在get请求加上 request body,给 post请求带上 URL 参数。 Get请求提交的url中的数据最多只能是2048字节,这个限制是浏览器或者服务器给添加的,http协议并没有对url长度进行限制,目的是为了保证服务器和浏览器能够正常运行,防止有人恶意发送请求。Post请求则没有大小限制。 Get限制Form表单的数据集的值必须为ASCII字符;而Post支持整个ISO10646字符集。 Get执行效率比Post快。Get是form提交的默认方法。 GET产生一个TCP数据包;POST产生两个TCP数据包。 对于GET方式的请求,浏览器会把http header和data一并发送出去,服务器响应200(返回数据); 而对于POST,浏览器先发送header,服务器响应100 continue,浏览器再发送data,服务器响应200 ok(返回数据)。

GetPost安全性Get是不安全的,因为在传输过程,数据被放在请求的URL中Post的所有操作对用户来说都是不可见的,相对安全url数据大小Get请求提交的url中的数据受浏览器和服务器的限制,防止有人恶意发送请求Post请求url数据没有大小限制表单字符集Get限制Form表单的数据集的值必须为ASCII字符Post支持整个ISO10646字符集TCP数据包数量GET产生一个TCP数据包POST产生两个TCP数据包执行效率Get执行效率比Post快Post执行效率比Get慢

Session、Cookie和Token的主要区别

HTTP协议本身是无状态的。什么是无状态呢,即服务器无法判断用户身份。

什么是cookie

cookie是由Web服务器保存在用户浏览器上的小文件(key-value格式),包含用户相关的信息。客户端向服务器发起请求,如果服务器需要记录该用户状态,就使用response向客户端浏览器颁发一个Cookie。客户端浏览器会把Cookie保存起来。当浏览器再请求该网站时,浏览器把请求的网址连同该Cookie一同提交给服务器。服务器检查该Cookie,以此来辨认用户身份。

什么是session

session是依赖Cookie实现的。session是服务器端对象

session 是浏览器和服务器会话过程中,服务器分配的一块储存空间。服务器默认为浏览器在cookie中设置 sessionid,浏览器在向服务器请求过程中传输 cookie 包含 sessionid ,服务器根据 sessionid 获取出会话中存储的信息,然后确定会话的身份信息。

cookie与session区别

存储位置与安全性:cookie数据存放在客户端上,安全性较差,session数据放在服务器上,安全性相对更高; 存储空间:单个cookie保存的数据不能超过4K,很多浏览器都限制一个站点最多保存20个cookie,session无此限制 占用服务器资源:session一定时间内保存在服务器上,当访问增多,占用服务器性能,考虑到服务器性能方面,应当使用cookie。

什么是Token

Token的引入:Token是在客户端频繁向服务端请求数据,服务端频繁的去数据库查询用户名和密码并进行对比,判断用户名和密码正确与否,并作出相应提示,在这样的背景下,Token便应运而生。

Token的定义:Token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个Token便将此Token返回给客户端,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。

使用Token的目的:Token的目的是为了减轻服务器的压力,减少频繁的查询数据库,使服务器更加健壮。

session与token区别

session机制存在服务器压力增大,CSRF跨站伪造请求攻击,扩展性不强等问题; session存储在服务器端,token存储在客户端 token提供认证和授权功能,作为身份认证,token安全性比session好; session这种会话存储方式方式只适用于客户端代码和服务端代码运行在同一台服务器上,token适用于项目级的前后端分离(前后端代码运行在不同的服务器下)

Servlet接口中有哪些方法及Servlet生命周期探秘

在Java Web程序中,Servlet主要负责接收用户请求HttpServletRequest,在doGet(),doPost()中做相应的处理,并将回应HttpServletResponse反馈给用户。Servlet可以设置初始化参数,供Servlet内部使用。

Servlet接口定义了5个方法,其中前三个方法与Servlet生命周期相关:

void init(ServletConfig config) throws ServletException void service(ServletRequest req, ServletResponse resp) throws ServletException, java.io.IOException void destory() java.lang.String getServletInfo() ServletConfig getServletConfig()

生命周期:

Web容器加载Servlet并将其实例化后,Servlet生命周期开始,容器运行其init()方法进行Servlet的初始化;

请求到达时调用Servlet的service()方法,service()方法会根据需要调用与请求对应的doGet或doPost等方法;

当服务器关闭或项目被卸载时服务器会将Servlet实例销毁,此时会调用Servlet的destroy()方法。

init方法和destory方法只会执行一次,service方法客户端每次请求Servlet都会执行。Servlet中有时会用到一些需要初始化与销毁的资源,因此可以把初始化资源的代码放入init方法中,销毁资源的代码放入destroy方法中,这样就不需要每次处理客户端的请求都要初始化与销毁资源。

Servlet是线程安全的吗

Servlet不是线程安全的,多线程并发的读写会导致数据不同步的问题。

解决的办法是尽量不要在实现servlet接口的类中定义实例变量,而是要把变量分别定义在doGet()和doPost()方法内。虽然使用synchronized(name){}语句块可以解决问题,但是会造成线程的等待,不是很科学的办法。

注意:多线程的并发的读写Servlet类属性会导致数据不同步。但是如果只是并发地读取属性而不写入,则不存在数据不同步的问题。因此Servlet里的只读属性最好定义为final类型的。

Servlet执行流程

web客户向Servlet容器发出HTTP请求; Servlet容器解析web的HTTP请求. Servlet容器创建一个HttpRequest对象,在这个对象中封装了http请求信息; Servlet容器创建一个HttpResponse对象; Servlet容器(如果访问的该servlet不是在服务器启动时创建的,则先创建servlet实例并调用init()方法初始化对象)调用HttpServlet的service()方法,把HttpRequest和HttpResponse对象为service方法的参数传给HttpServlet对象; HttpServlet调用HttpRequest的有关方法,获取HTTP请求信息; HttpServlet调用HttpResponse的有关方法,生成响应数据; Servlet容器把HttpServlet的响应结果传给web客户.

forward和redirect的区别

实际发生地址不同,地址栏不同

转发是发生在服务器的,转发是由服务器进行跳转的,浏览器的地址栏是没有发生变化的,实现转发只是一次的http请求 重定向是由浏览器进行跳转的,浏览器的地址会发生变化的,由浏览器进行的页面跳转,实现重定向会发出两个http请求,request域对象是无效的,因为它不是同一个request对象

访问范围不一样

转发是服务器跳转只能去往当前web应用的资源 重定向是浏览器跳转,可以去往任何的资源

传递数据的类型不同

转发的request对象可以传递各种类型的数据,包括对象 重定向只能传递字符串

跳转的时间不同

转发:执行到跳转语句时就会立刻跳转 重定向:整个页面执行完之后才执行跳转

那么转发(forward)和重定向(redirect)使用哪一个?

根据上面说明了转发和重定向的区别也可以很容易概括出来。转发是带着转发前请求的参数。重定向是新的请求。

典型的应用场景:

forward一般用于用户登陆的时候根据角色转发到相应的模块。 redirect一般用于用户注销登陆时返回主页面和跳转到其它的网站等。

如何解决跨域问题?

CORS全称Cross-Origin Resource Sharing,意为跨域资源共享。当一个资源去访问另一个不同域名或者同域名不同端口的资源时,就会发出跨域请求。如果此时另一个资源不允许其进行跨域资源访问,那么访问就会遇到跨域问题。

跨域指的是浏览器不能执行其它网站的脚本,它是由浏览器的同源策略造成的,是浏览器对JavaScript 施加的安全限制。

同源策略它是由 Netscape 提出的一个安全策略,它是浏览器最核心也是最基本的安全功能,如果缺少同源策略,则浏览器的正常功能可能都会受到影响,现在所有支持JavaScript的浏览器都会使用这个策略。

所谓同源指的是:协议、域名、端口号都相同,只要有一个不相同,那么都是非同源。

解决方案:

使用ajax的jsonp nginx 转发:利用nginx反向代理,将请求分发到部署相应项目的tomcat服务器,当然也不存在跨域问题。 使用cors:写一个配置类实现WebMvcConfigurer接口或者配置FilterRegistrationBean

什么是 CSRF 攻击?如何防御CSRF 攻击

CSRF(Cross-site request forgery) 跨站请求伪造。CSRF 攻击是在受害者毫不知情的情况下,以受害者名义伪造请求发送给受攻击站点,从而在受害者并未授权的情况下执行受害者权限下的各种操作。

CSRF 攻击专门针对状态改变请求,而不是数据窃取,因为攻击者无法查看对伪造请求的响应。

目前防御 CSRF 攻击主要有三种策略:

验证 HTTP Referer 字段 在请求地址中添加 token 并验证 在 HTTP 头中自定义属性并验证

什么是Socket

在计算机通信领域,socket 被翻译为“套接字”,它是计算机之间进行通信的一种约定。网络上的两个程序通过一个双向的链路连接实现数据的交换,这个双向链路的一端称为一个Socket,一个Socket由一个IP地址和一个端口确定唯一性。

Socket所支持的协议种类不光是TCP/IP、UDP,因此两者之间是没有必然联系的。在Java环境下,Socket编程主要是指基于TCP/IP协议的网络编程。

socket连接就是所谓的长连接,客户端和服务器需要互相连接,理论上客户端和服务器端一旦建立起连接将不会主动断掉的,但是有时候网络波动还是有可能的。

Socket偏向于底层。一般很少直接使用Socket来编程,框架底层使用Socket比较多。

socket 的典型应用就是 Web 服务器和浏览器:浏览器获取用户输入的 URL,向服务器发起请求,服务器分析接收到的 URL,将对应的网页内容返回给浏览器,浏览器再经过解析和渲染,就将文字、图片、视频等元素呈现给用户。例如我们每天浏览网页、QQ 聊天、收发 email 等等。

Socket属于网络的哪个层面

Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket其实就是一个外观模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。

Socket通讯的过程

基于TCP:服务器端先初始化Socket,然后与端口绑定(bind),对端口进行监听(listen),调用accept阻塞,等待客户端连接。在这时如果有个客户端初始化一个Socket,然后连接服务器(connect),如果连接成功,这时客户端与服务器端的连接就建立了。客户端发送数据请求,服务器端接收请求并处理请求,然后把响应数据发送给客户端,客户端读取数据,最后关闭连接,一次交互结束。 基于UDP:UDP 协议是用户数据报协议的简称,也用于网络数据的传输。虽然 UDP 协议是一种不太可靠的协议,但有时在需要较快地接收数据并且可以忍受较小错误的情况下,UDP 就会表现出更大的优势。我客户端只需要发送,服务端能不能接收的到我不管

http和https的基本概念

HTTP:是互联网上应用最为广泛的一种网络协议,是一个客户端和服务器端请求和应答的标准(TCP),用于计算机之间传输文字,图片,音频,视频等超文本数据的协议,它可以使浏览器更加高效,使网络传输减少。Http协议属于应用层

HTTPS:是以安全为目标的HTTP通道,简单讲是HTTP的安全版,即HTTP下加入SSL层,HTTPS的安全基础是SSL,HTTPS就是从HTTP加上加密处理(一般是SSL安全通信线路)+认证+完整性保护

HTTPS协议的主要作用可以分为两种:一种是建立一个信息安全通道,来保证数据传输的安全;另一种就是确认网站的真实性。

http和https的区别?

HTTP存在的不足

通信内容明文传输,容易被第三方窃听 容易被第三方劫持、篡改从而无法保证内容的完整性与正确性 不验证通信方的身份,因此有可能遭遇伪装,无法保证信息的来源

既然HTTP存在这些问题,那么HTTPS就是为了解决这些问题的。

HTTPS的优点

通信内容进行加密,防止信息在传输过程中泄露 保证数据完整性、准确性 对数据来源进行验证,确保来源无法伪造

区别HTTPHTTPS协议运行在 TCP 之上,明文传输,客户端与服务器端都无法验证对方的身份身披 SSL( Secure Socket Layer )外壳的 HTTP,运行于 SSL 上,SSL 运行于 TCP 之上, 是添加了加密和认证机制的 HTTP。端口80443资源消耗较少由于加解密处理,会消耗更多的 CPU 和内存资源证书无需证书需要证书,而证书一般需要向认证机构购买加密机制无共享密钥加密和公开密钥加密并用的混合加密机制安全性弱由于加密机制,安全性强

HTTPS工作原理

Client发起一个HTTPS(https:/demo.linianhui.dev)的请求,根据RFC2818的规定,Client知道需要连接Server的443(默认)端口。 Server把事先配置好的公钥证书(public key certificate)返回给客户端。 Client验证公钥证书:比如是否在有效期内,证书的用途是不是匹配Client请求的站点,是不是在CRL吊销列表里面,它的上一级证书是否有效,这是一个递归的过程,直到验证到根证书(操作系统内置的Root证书或者Client内置的Root证书)。如果验证通过则继续,不通过则显示警告信息。 Client使用伪随机数生成器生成加密所使用的会话密钥,然后用证书的公钥加密这个会话密钥,发给Server。 Server使用自己的私钥(private key)解密这个消息,得到会话密钥。至此,Client和Server双方都持有了相同的会话密钥。 Server使用会话密钥加密“明文内容A”,发送给Client。 Client使用会话密钥解密响应的密文,得到“明文内容A”。 Client再次发起HTTPS的请求,使用会话密钥加密请求的“明文内容B”,然后Server使用会话密钥解密密文,得到“明文内容B”。

简单总结下,HTTPS是使用了证书的一个混合密码系统,其中证书的作用在于传递会话密钥,以及验证网站的真实性

一次完整的HTTP请求所经历几个步骤?

根据域名和 DNS 解析到服务器的IP地址 (DNS + CDN) 通过ARP协议获得IP地址对应的物理机器的MAC地址 浏览器对服务器发起 TCP 3 次握手 建立 TCP 连接后发起 HTTP 请求报文 服务器响应 HTTP 请求,将响应报文返回给浏览器 短连接情况下,请求结束则通过 TCP 四次挥手关闭连接,长连接在没有访问服务器的若干时间后,进行连接的关闭 浏览器得到响应信息中的 HTML 代码, 并请求 HTML 代码中的资源(如js、css、图片等) 浏览器对页面进行渲染并呈现给用户

常用HTTP状态码是怎么分类的,有哪些常见的状态码?

HTTP状态码表示客户端HTTP请求的返回结果、标识服务器处理是否正常、表明请求出现的错误等。

状态码的类别:

状态码类别描述1xxInformational(信息状态码)信息,服务器收到请求,需要请求者继续执行操作2xxSuccess(成功状态码)成功,操作被成功接收并处理3xxRedirection(重定向状态码)重定向,需要进一步的操作以完成请求4xxClient Error(客户端错误状态码)客户端错误,请求包含语法错误或无法完成请求5xxServer Error(服务器错误状态码)服务器错误,服务器在处理请求的过程中发生了错误

HTTP的状态码总数达60余种,但是常用的大概只有14种。接下来,我们就介绍一下这些具有代表性的14个状态码。

14种常用的HTTP状态码列表

状态码状态码英文名称中文描述2xx200OK请求成功。一般用于GET与POST请求204No Content无内容。服务器成功处理,但未返回内容。在未更新网页的情况下,可确保浏览器继续显示当前文档206Partial Content是对资源某一部分的请求,服务器成功处理了部分GET请求,响应报文中包含由Content-Range指定范围的实体内容。3xx301Moved Permanently永久性重定向。请求的资源已被永久的移动到新URI,返回信息会包括新的URI,浏览器会自动定向到新URI。今后任何新的请求都应使用新的URI代替302Found临时性重定向。与301类似。但资源只是临时被移动。客户端应继续使用原有URI303See Other查看其它地址。与302类似。使用GET请求查看304Not Modified未修改。所请求的资源未修改,服务器返回此状态码时,不会返回任何资源。客户端通常会缓存访问过的资源,通过提供一个头信息指出客户端希望只返回在指定日期之后修改的资源307Temporary Redirect临时重定向。与302类似。使用GET请求重定向,会按照浏览器标准,不会从POST变成GET。4xx400Bad Request客户端请求报文中存在语法错误,服务器无法理解。401Unauthorized请求要求用户的身份认证,通过HTTP认证(BASIC认证,DIGEST认证)的认证信息,若之前已进行过一次请求,则表示用户认证失败402Payment Required保留,将来使用403Forbidden服务器理解请求客户端的请求,但是拒绝执行此请求404Not Found服务器无法根据客户端的请求找到资源(网页)。通过此代码,网站设计人员可设置"您所请求的资源无法找到"的个性页面。也可以在服务器拒绝请求且不想说明理由时使用5xx500Internal Server Error服务器内部错误,无法完成请求,也可能是web应用存在bug或某些临时故障501Not Implemented服务器不支持请求的功能,无法完成请求503Service Unavailable由于超载或系统维护,服务器暂时的无法处理客户端的请求。延时的长度可包含在服务器的Retry-After头信息中

Http有哪些请求方法

根据 HTTP 标准,HTTP 请求可以使用多种请求方法。

HTTP1.0 定义了三种请求方法:GET, POST 和 HEAD方法。

HTTP1.1 新增了六种请求方法:OPTIONS、PUT、PATCH、DELETE、TRACE 和 CONNECT 方法。

序号方法描述1GET请求指定的页面信息,并返回实体主体。2HEAD类似于 GET 请求,只不过返回的响应中没有具体的内容,用于获取报头3POST向指定资源提交数据进行处理请求(例如提交表单或者上传文件)。数据被包含在请求体中。POST 请求可能会导致新的资源的建立和/或已有资源的修改。4PUT从客户端向服务器传送的数据取代指定的文档的内容。5DELETE请求服务器删除指定的页面。6CONNECTHTTP/1.1 协议中预留给能够将连接改为管道方式的代理服务器。7OPTIONS允许客户端查看服务器的性能。8TRACE回显服务器收到的请求,主要用于测试或诊断。9PATCH是对 PUT 方法的补充,用来对已知资源进行局部更新 。

Socket和http的区别和应用场景

Socket连接就是所谓的长连接,理论上客户端和服务器端一旦建立起连接将不会主动断掉;

Socket适用场景:网络游戏,银行持续交互,直播,在线视屏等。

http连接就是所谓的短连接,即客户端向服务器端发送一次请求,服务器端响应后连接即会断开等待下次连接

http适用场景:公司OA服务,互联网服务,电商,办公,网站等等等等

什么是对称加密与非对称加密

对称密钥加密是指加密和解密使用同一个密钥的方式,这种方式存在的最大问题就是密钥发送问题,即如何安全地将密钥发给对方; 而非对称加密是指使用一对非对称密钥,即公钥和私钥,公钥可以随意发布,但私钥只有自己知道。发送密文的一方使用对方的公钥进行加密处理,对方接收到加密信息后,使用自己的私钥进行解密。由于非对称加密的方式不需要发送用来解密的私钥,所以可以保证安全性;但是和对称加密比起来,非常的慢

多线程高并发

并发编程三要素是什么?在 Java 程序中怎么保证多线程的运行安全?

并发编程三要素(线程的安全性问题体现在):

原子性:一个或多个操作要么全部执行成功要么全部执行失败。

可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。

有序性:程序执行的顺序按照代码的先后顺序执行,避免指令重排。

出现线程安全问题的原因:

线程切换带来的原子性问题 缓存导致的可见性问题 编译优化带来的有序性问题

解决办法:

JDK Atomic开头的原子类、synchronized、lock,可以解决原子性问题 volatile、synchronized、lock,可以解决可见性问题 volatile、Happens-Before 规则可以解决有序性问题

什么是上下文切换?

概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。

形成死锁的四个必要条件是什么

互斥条件:线程(进程)对于所分配到的资源具有排它性,即一个资源只能被一个线程(进程)占用,直到被该线程(进程)释放 请求与保持条件:一个线程(进程)因请求被占用资源而发生阻塞时,对已获得的资源保持不放。 不剥夺条件:线程(进程)已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。 循环等待条件:当发生死锁时,所等待的线程(进程)必定会形成一个环路(类似于死循环),造成永久阻塞

创建线程有哪几种方式?

创建线程有4种方式:

继承 Thread 类; 实现 Runnable 接口;无返回值 实现 Callable 接口;有返回值,通过Future、FutureTask获取 线程池

线程的 run()和 start()有什么区别?

start() 方法用于启动线程,run() 方法用于执行线程任务。run() 可以重复调用,而 start() 只能调用一次。

start()方法来启动一个线程,真正实现了多线程运行。调用start()方法无需等待run方法体代码执行完毕,可以直接继续执行其他的代码;此时线程是处于就绪状态,并没有运行。然后通过此Thread类调用方法run()来完成其运行状态, run()方法运行结束, 此线程终止。然后CPU再调度其它线程。

run()方法是在本线程里的,只是线程里的一个函数,而不是多线程的。如果直接调用run(),其实就相当于是调用了一个普通函数而已,直接调用run()方法必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。

说说java线程的生命周期及五种基本状态?

新建(new):新创建了一个线程对象。 可运行(runnable):线程对象创建后,当调用线程对象的 start()方法,该线程处于就绪状态,等待被线程调度选中,获取cpu的使用权。 运行(running):可运行状态(runnable)的线程获得了cpu时间片(timeslice),执行程序代码。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中; 阻塞(block):处于运行状态中的线程由于某种原因,暂时放弃对 CPU的使用权,停止执行,此时进入阻塞状态,直到其再次进入到就绪状态,才有机会再次被 CPU 调用以进入到运行状态。 阻塞的情况分三种: (一). 等待阻塞:运行状态中的线程执行 wait()方法,JVM会把该线程放入等待队列(waitting queue)中,使本线程进入到等待阻塞状态; (二). 同步阻塞:线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),则JVM会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态; (三). 其他阻塞:通过调用线程的 sleep()或 join()或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪runnable状态。 死亡(dead):线程run()、main()方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期,死亡的线程不可再次复生。

注:Java虚拟机的线程调度采用抢占式调度模型。

线程池有什么优点?

降低资源消耗,提高资源利用率:重用存在的线程,减少对象创建销毁的开销,提高系统资源的利用率。 提高响应速度:可有效的控制最大并发线程数,同时避免过多资源竞争,避免堵塞。当任务到达时,任务可以不需要的等线程创建就能立即执行。 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

附加功能:提供定时执行、定期执行、单线程、并发数控制等功能。

综上所述使用线程池框架 Executor 能更好的管理线程、提高系统资源利用率。

线程池都有哪些状态?

RUNNING:这是最正常的状态,接受新的任务,处理等待队列中的任务。 SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务。 STOP:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程。 TIDYING:所有的任务都销毁了,workCount 为 0,线程池的状态在转换为 TIDYING 状态时,会执行钩子方法 terminated()。 TERMINATED:terminated()方法结束后,线程池的状态就会变成这个。

JUC并发工具类

Semaphore信号量:使用场景:同一时间控制并发线程数

准入数量N,N=1则等同于独占锁(即Synchronized) 相当于Synchronized的进化版

CountDownLatch: 阻塞主线程,N个子线程满足条件时主线程继续。

CyclicBarrier循环栅栏:任务执行到一定阶段,等待其他任务对齐,阻塞N个线程时所有线程被唤醒继续。

锁机制

synchronized 的作用?

在 Java 中,synchronized 关键字是用来控制线程同步的,就是在多线程的环境下,synchronized 修饰的代码段不被多个线程同时执行。synchronized 可以修饰静态方法,实例方法和代码块

synchronized 底层实现原理

通过JDK 反汇编指令 javap -c -v SynchronizedDemo 可以看出在执行同步代码块之前之后都有一个monitor字样,其中前面的是monitorenter,后面的是离开monitorexit,不难想象一个线程在执行同步代码块时,首先要获取锁,而获取锁的过程就是monitorenter ,在执行完代码块之后,要释放锁,释放锁就是执行monitorexit指令。

为什么会有两个monitorexit呢?

保证在异常情况下,锁也可以得到释放,避免死锁。

synchronized可重入的原理

重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁。底层原理维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。

什么是自旋

很多 synchronized 里面的代码只是一些很简单的代码,执行时间非常快,此时等待的线程都加锁可能是一种不太值得的操作,因为线程阻塞涉及到用户态和内核态切换的问题。既然 synchronized 里面的代码执行得非常快,不妨让等待锁的线程不要被阻塞,而是在 synchronized 的边界做循环(尝试获取锁,不放弃CPU时间片),这就是自旋。如果做了多次循环发现还没有获得锁,再阻塞,这样可能是一种更好的策略。

多线程中 synchronized 锁升级的原理是什么?

锁的级别从低到高:

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

锁分级别原因:

没有优化以前,sychronized是重量级锁(悲观锁),使用 wait 和 notify、notifyAll 来切换线程状态非常消耗系统资源;线程的挂起和唤醒间隔很短暂,这样很浪费资源,影响性能。所以 JVM 对 sychronized 关键字进行了优化,把锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。

锁状态对比:

无锁偏向锁轻量级锁重量级锁定义没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其他修改失败的线程会不断重试直到修改成功。对象的代码一直被同一线程执行,不存在多个线程竞争,该线程在后续的执行中自动获取锁,降低获取锁带来的性能开销。偏向锁,指的就是偏向第一个加锁线程,该线程是不会主动释放偏向锁的,只有当其他线程尝试竞争偏向锁才会被释放。偏向锁的撤销,需要在某个时间点上没有字节码正在执行时,先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁;如果线程处于活动状态,升级为轻量级锁的状态。轻量级锁是指当锁是偏向锁的时候,被第二个线程 B 所访问,此时偏向锁就会升级为轻量级锁,线程 B 会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。当前只有一个等待线程,则该线程将通过自旋进行等待。但是当自旋超过一定的次数时,轻量级锁便会升级为重量级锁;当一个线程已持有锁,另一个线程在自旋,而此时又有第三个线程来访时,轻量级锁也会升级为重量级锁。指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。重量级锁通过对象内部的监视器(monitor)实现,而其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。适用场景无需保证线程安全的场景偏向锁的撤销,需要在某个时间点上没有字节码正在执行时,先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁;只有一个线程进入同步块当前只有一个等待线程,则该线程将通过自旋进行等待。但是当自旋超过一定的次数时,轻量级锁便会升级为重量级锁;当一个线程已持有锁,另一个线程在自旋,而此时又有第三个线程来访时,轻量级锁也会升级为重量级锁。虽然很多线程,但是没有冲突:多条线程进入同步块,但是线程进入时间错开因而并未争抢锁重量级锁通过对象内部的监视器(monitor)实现,而其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。发生了锁争抢的情况:多条线程进入同步块并争用锁本质如果线程处于活动状态,升级为轻量级锁的状态。取消同步操作CAS操作代替互斥同步互斥同步优点不阻塞,执行效率高不阻塞,执行效率高(只有第一次获取偏向锁时需要CAS操作,后面只是比对ThreadId)不会阻塞不会空耗CPU缺点线程不安全适用场景太局限。若竞争产生,会有额外的偏向锁撤销的消耗长时间获取不到锁空耗CPU

synchronized 和 Lock 有什么区别?

首先synchronized是Java关键字,Lock是 Java 接口; synchronized 可以给方法、代码块加锁;而 lock 只能给代码块加锁。 synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁可能造成死锁。 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。

synchronized 和 ReentrantLock 区别是什么?

相同点:两者都是可重入锁。

主要区别如下:

本质:synchronized 是关键字,ReetrantLock是类,这是二者的本质区别。既然 ReentrantLock 是类,那么它就提供了比 synchronized 更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的变量 加锁和释放锁:ReentrantLock 必须手动获取与释放锁,而 synchronized 不需要手动开启和释放锁; 作用域:ReentrantLock 只能给代码块加锁,而 synchronized 可以给方法、代码块加锁。 性能:早期版本 synchronized 在很多场景下性能较差,在后续版本进行了较多改进,在低竞争场景中表现可能优于 ReentrantLock。 底层实现:二者的锁机制其实也是不一样的。ReentrantLock 底层调用的是 Unsafe 的 park 方法加锁,synchronized 操作的是对象头中 mark word

volatile 关键字的作用

Java 提供了 volatile 关键字来保证可见性和禁止指令重排(有序性)。volatile 提供 happens-before 的保证,同时确保一个线程对共享变量的修改能对其他线程是可见的。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到内存,当有其他线程需要读取时,它会去内存中读取新值。

从实践角度而言,volatile 的一个重要作用就是和 CAS 结合,保证了原子性,详细的可以参见 java.util.concurrent.atomic 包下的类,比如 AtomicInteger。

ThreadLocal造成内存泄漏的原因?

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现key为null的Entry。假如我们不做任何措施的话,value 永远无法被GC 回收,这个时候就可能会产生内存泄露。

解决方案:使用完 remove;

BIO,NIO,AIO 有什么区别?

在讲 BIO,NIO,AIO 之前先来回顾一下这样几个概念:同步与异步,阻塞与非阻塞。

同步与异步

同步: 同步就是发起一个请求,被调用者未处理完请求之前,调用不返回。 异步: 异步就是发起一个请求,立刻得到被调用者的响应表示已接收到请求,但是被调用者并没有返回请求处理结果,此时我们可以处理其他的请求,被调用者通过事件和回调等机制来通知调用者其返回结果。

同步和异步的区别在于调用者需不需要等待被调用者的处理结果。

阻塞和非阻塞

阻塞: 阻塞就是发起一个请求,调用者一直等待请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当返回结果才能继续。 非阻塞: 非阻塞就是发起一个请求,调用者不用一直等着结果返回,可以先去干其他事情。

阻塞和非阻塞的区别在于调用者的线程需不需要挂起。

Java IO 方式有很多种,基于不同的 IO 抽象模型和交互方式,可以进行简单区分。

BIO(jdk1.4之前):Block IO 同步阻塞式 IO,就是我们平常使用的传统 IO,它基于流模型实现,一个连接一个线程,客户端有连接请求时,服务器端就需要启动一个线程进行处理,线程开销大。伪异步IO:将请求连接放入线程池,一对多,但线程还是很宝贵的资源。它的特点是模式简单使用方便,但并发处理能力低,容易成为应用性能的瓶颈。BIO是面向流的,BIO的Stream是单向的。 很多时候,人们也把 java.net 包下的部分网络 API,比如 Socket、ServerSocket、HttpURLConnection 也归类到同步阻塞 IO,因为网络通信同样是 IO 行为。 NIO(jdk1.4 之后 linux的多路复用技术 select 模式):Non IO 同步非阻塞 IO,是传统 IO 的升级,提供了 Channel、Selector、Buffer 等新的抽象,客户端和服务器端通过 Channel(通道)通讯,实现了多路复用。客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。Mina2.0和Netty5.0网络通信框架都是通过NIO实现的网络通信。NIO是面向缓冲区的,NIO的channel是双向的。 NIO 能解决什么问题? 通过一个固定大小的线程池,来负责管理工作线程,避免频繁创建、销毁线程的开销,这是我们构建并发服务的典型方式。 NIO 则是利用了单线程轮询事件的机制,通过高效地定位就绪的 Channel,来决定做什么,仅仅 select 阶段是阻塞的,可以有效避免大量客户端连接时,频繁线程切换带来的问题,应用的扩展能力有了非常大的提高。 AIO(jdk 1.7过后 又叫NIO 2):Asynchronous IO 异步非堵塞 IO,是 NIO 的升级,异步 IO 的操作基于事件和回调机制,性能是最好的。底层实现是通过epoll的I/O多路复用机制。

BIO、NIO、AIO 实现原理

BIO 实现原理

BIO模型是最早的jdk提供的一种处理网络连接请求的模型,是同步阻塞结构,通常由一个独立的Acceptor线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成后,通过输出流返回应答给客户端,线程销毁。即典型的一请求一应答模型。

BIO是同步阻塞式IO,通常在while循环中服务端会调用accept方法等待接收客户端的连接请求,一旦接收到一个连接请求,就可以建立通信套接字在这个通信套接字上进行读写操作,此时不能再接收其他客户端连接请求,只能等待同当前连接的客户端的操作执行完成。

如果BIO要能够同时处理多个客户端请求,就必须使用多线程,即每次accept阻塞等待来自客户端请求,一旦受到连接请求就建立通信套接字同时开启一个新的线程来处理这个套接字的数据读写请求,然后立刻又继续accept等待其他客户端连接请求,即为每一个客户端连接请求都创建一个线程来单独处理。

NIO 实现原理

nio模型事件处理流程:

Acceptor注册Selector,监听accept事件; 当客户端连接后,触发accept事件; 服务器构建对应的Channel,并在其上注册Selector,监听读写事件; 当发生读写事件后,进行相应的读写处理。

NIO本身是基于事件驱动思想来完成的,其主要想解决的是BIO的大并发问题,即在使用同步I/O的网络应用中,如果要同时处理多个客户端请求,或是在客户端要同时和多个服务器进行通讯,就必须使用多线程来处理。也就是说,将每一个客户端请求分配给一个线程来单独处理。这样做虽然可以达到我们的要求,但同时又会带来另外一个问题。由于每创建一个线程,就要为这个线程分配一定的内存空间(也叫工作存储器),而且操作系统本身也对线程的总数有一定的限制。如果客户端的请求过多,服务端程序可能会因为不堪重负而拒绝客户端的请求,甚至服务器可能会因此而瘫痪。

NIO基于Reactor,当selector有流可读或可写入socket时,操作系统会相应的通知应用程序进行处理,应用再将流读取到缓冲区或写入操作系统。

也就是说,这个时候,已经不是一个连接就要对应一个处理线程了,而是有效的请求,对应一个线程,当连接没有数据时,是没有工作线程来处理的。

Reactor单线程模型

这是最简单的单Reactor单线程模型。Reactor线程负责多路分离套接字、accept新连接,并分派请求到处理器链中。该模型适用于处理器链中业务处理组件能快速完成的场景。不过,这种单线程模型不能充分利用多核资源,所以实际使用的不多。

这个模型和上面的NIO流程很类似,只是将消息相关处理独立到了Handler中去了。

虽然上面说到NIO一个线程就可以支持所有的IO处理。但是瓶颈也是显而易见的。我们看一个客户端的情况,如果这个客户端多次进行请求,如果在Handler中的处理速度较慢,那么后续的客户端请求都会被积压,导致响应变慢!所以引入了Reactor多线程模型。

Reactor多线程模型

相比上一种模型,该模型在处理器链部分采用了多线程(线程池):

Reactor多线程模型就是将Handler中的IO操作和非IO操作分开,操作IO的线程称为IO线程,非IO操作的线程称为工作线程。这样的话,客户端的请求会直接被丢到线程池中,客户端发送请求就不会堵塞。

但是当用户进一步增加的时候,Reactor会出现瓶颈!因为Reactor既要处理IO操作请求,又要响应连接请求。为了分担Reactor的负担,所以引入了主从Reactor模型。

主从Reactor多线程模型

主从Reactor多线程模型是将Reactor分成两部分,mainReactor负责监听server socket,accept新连接,并将建立的socket分派给subReactor。subReactor负责多路分离已连接的socket,读写网络数据,对业务处理功能,其扔给worker线程池完成。通常,subReactor个数上可与CPU个数等同:

可见,主Reactor用于响应连接请求,从Reactor用于处理IO操作请求。

AIO

与NIO不同,当进行读写操作时,只须直接调用API的read或write方法即可。这两种方法均为异步的,对于读操作而言,当有流可读取时,操作系统会将可读的流传入read方法的缓冲区,并通知应用程序;对于写操作而言,当操作系统将write方法传递的流写入完毕时,操作系统主动通知应用程序。

AIO是一种接口标准,各家操作系统可以实现也可以不实现。在不同操作系统上在高并发情况下最好都采用操作系统推荐的方式。Linux上还没有真正实现网络方式的AIO。

异步IO则采用“订阅-通知”模式:即应用程序向操作系统注册IO监听,然后继续做自己的事情。当操作系统发生IO事件,并且准备好数据后,在主动通知应用程序,触发相应的函数。也可以如下图理解:

和同步IO一样,异步IO也是由操作系统进行支持的。微软的windows系统提供了一种异步IO技术:IOCP(I/O CompletionPort,I/O完成端口);Linux下由于没有这种异步IO技术,所以使用的是epoll对异步IO进行模拟。

反射

什么是反射机制?

JAVA反射机制是在程序运行过程中,对于任意一个类或对象,都能够知道这个类或对象的所有属性和方法,这种动态获取信息以及动态调用对象方法的功能称为java语言的反射机制。

静态编译和动态编译

静态编译:在编译时确定类型,绑定对象 动态编译:在运行时确定类型,绑定对象

反射机制优缺点

优点 :运行期类型的判断,动态加载类,提高代码的灵活性。 缺点:性能瓶颈:反射相当于一系列解释操作,通知 JVM 要做的事情,性能比直接的java代码要慢很多。

反射为什么慢

反射调用过程中会产生大量的临时对象,这些对象会占用内存,可能会导致频繁 gc,从而影响性能。 反射调用方法时会从方法数组中遍历查找,并且检查可见性等操作会比较耗时。 反射在达到一定次数时,会动态编写字节码并加载到内存中,这个字节码没有经过编译器优化,也不能享受 JIT 优化。 反射一般会涉及自动装箱/拆箱和类型转换,都会带来一定的资源开销。

JAVA高级

JVM

Java内存区域

说一下 JVM 的主要组成部分及其作用?

JVM包含两个子系统和两个组件,两个子系统为Class loader(类装载)、Execution engine(执行引擎);两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)。

Class loader(类装载):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到运行时数据区中的方法区。 Execution engine(执行引擎):执行字节码中的指令。 Native Interface(本地接口):与native libraries交互,是与其它编程语言交互的接口。 Runtime data area(运行时数据区域):这就是我们常说的JVM的内存。

作用 :首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的解释器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。

说一下 JVM 运行时数据区

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存区域划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有些区域随着虚拟机进程的启动而存在,有些区域则是依赖线程的启动和结束而建立和销毁。Java 虚拟机所管理的内存被划分为如下几个区域:

不同虚拟机的运行时数据区可能略微有所不同,但都会遵从 Java 虚拟机规范, Java 虚拟机规范规定的区域分为以下 5 个部分:

Java 堆(Java Heap):Java 虚拟机中内存最大的一块,是被所有线程共享的,几乎所有的对象实例都在这里分配内存; 方法区(Method Area):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。 Java 虚拟机栈(Java Virtual Machine Stacks):用于存储局部变量表、操作数栈、动态链接、方法出口等信息; 本地方法栈(Native Method Stack):与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的; 程序计数器(Program Counter Register):当前线程所执行的字节码的行号指示器,字节码解释器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成;

深拷贝和浅拷贝

浅拷贝(shallowCopy)只是增加了一个指针指向已存在的内存地址

深拷贝(deepCopy)是增加了一个指针并且申请了一个新的内存,使这个增加的指针指向这个新的内存,

使用深拷贝的情况下,不会出现浅拷贝时释放同一个内存的错误。

说一下堆栈的区别?

堆栈物理地址物理地址分配是不连续的,因此性能慢些。在GC的时候需要考虑到不连续的分配,所以有各种垃圾回收算法。栈使用的是数据结构中的栈,具有先进后出的规则,物理地址分配是连续的,因此性能快分配内存时机堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般堆大小远远大于栈。栈是连续的,所以分配的内存大小要在编译期就确认,大小是固定的。存放的内容堆存放的是对象的实例和数组。此区域更关注的是数据的存储栈存放:局部变量,操作数栈,返回结果。此区域更关注的是程序方法的执行。程序的可见度堆对于整个应用程序都是共享的、可见的。栈对当前线程是可见的,是线程私有。他的生命周期和线程相同。

HotSpot虚拟机对象探秘

对象的创建

说到对象的创建,首先让我们看看 Java 中提供的几种对象创建方式:

Header解释使用new关键字调用了构造函数使用Class的newInstance方法调用了构造函数使用Constructor类的newInstance方法调用了构造函数使用clone方法没有调用构造函数使用反序列化没有调用构造函数

下面是对象创建的主要流程:

虚拟机遇到一条new指令时,先检查常量池是否已经加载相应的类,如果没有,必须先执行相应的类加载。类加载通过后,接下来分配内存。若Java堆中内存是绝对规整的,使用“指针碰撞“方式分配内存;如果不是规整的,就从空闲列表中分配,叫做”空闲列表“方式。划分内存时还需要考虑一个问题-并发,也有两种方式:CAS同步处理,或者本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。然后将分配到的内存空间都初始化为零值,接着是做一些必要的对象设置(元信息、哈希码...),最后执行方法。

为对象分配内存

类加载完成后,接着会在Java堆中划分一块内存分配给对象。内存分配根据Java堆是否规整,有两种方式:

指针碰撞:如果Java堆的内存是规整,即所有用过的内存放在一边,而空闲的的放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工作。 空闲列表:如果Java堆的内存不是规整的,则需要由虚拟机维护一个列表来记录哪些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。

选择哪种分配方式是由 Java 堆是否规整来决定的,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

处理并发安全问题

在虚拟机中对象的创建是一个非常频繁的行为,哪怕只是修改一个指针所指向的位置,在并发情况下也是不安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案:

对内存分配的动作进行同步处理(采用 CAS + 失败重试来保障更新操作的原子性); 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配。只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁。通过-XX:+UseTLAB参数来设定虚拟机是否使用TLAB。

对象的访问定位

Java程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有 句柄 和 直接指针 两种方式。

指针: 一种内存地址,代表一个对象在内存中的地址。

句柄: 可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。

句柄访问

Java堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息,具体构造如下图所示:

优势:引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改。

直接指针

如果使用直接指针访问,引用 中存储的直接就是对象地址。

优势:速度更快,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。HotSpot 中采用的就是这种方式。

内存溢出异常

Java会存在内存泄漏吗?请简单描述

内存泄漏是指不再被使用的对象或者变量一直存在于内存中。理论上来说,Java是有GC垃圾回收机制的,也就是说,不再被使用的对象,会被GC自动回收掉,自动从内存中清除。

但是,即使这样,Java也还是存在着内存泄漏的情况,Java导致内存泄露的原因很明确:长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是Java中内存泄露的发生场景。

垃圾收集器

Java垃圾回收机制

Java垃圾回收机制:GC 是垃圾收集的意思(Gabage Collection),在java中,程序员是不需要显示的去释放一个对象的内存的,而是由虚拟机自行执行,这就是垃圾回收机制,垃圾回收机制有效的防止了内存泄露

垃圾回收器的基本原理:在JVM中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫描那些没有被引用的对象,并将它们添加到要回收的集合中,进行回收。

垃圾回收器可以马上回收内存吗?:程序员不能实时的对某个对象或所有对象调用垃圾回收器进行垃圾回收。但可以手动执行System.gc()主动通知虚拟机进行垃圾回收,但是Java语言规范并不能保证GC一定会执行。

Java 中都有哪些引用类型?

在Java语言中,除了基本数据类型外,其他的都是指向各类对象的对象引用,根据其生命周期的长短,将引用分为4类。

不同的引用类型,主要体现的是对象不同的可达性状态和对垃圾收集的影响。

强引用:最常见的普通对象引用,通过关键字new创建的对象所关联的引用就是强引用,发生 gc 的时候不会被回收。 软引用:软引用的生命周期比强引用短一些。有用但不是必须的对象,在发生内存溢出之前会被回收。应用场景:软引用通常用来实现内存敏感的缓存。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。 弱引用:弱引用的生命周期比软引用短。有用但不是必须的对象,在下一次GC时会被回收。应用场景:弱应用同样可用于内存敏感的缓存。 虚引用(幽灵引用/幻象引用):无法通过虚引用获得对象,用 PhantomReference 实现虚引用。应用场景:虚引用的用途是在这个对象被 gc 时返回一个系统通知。

怎么判断对象是否可以被回收?在Java中,对象什么时候可以被垃圾回收

垃圾收集器在做垃圾回收的时候,首先需要判定的就是哪些内存是需要被回收的,哪些对象是「存活」的,是不可以被回收的;哪些对象已经「死掉」了,需要被回收。

一般有两种方法来判断:

引用计数器法:为每个对象创建一个引用计数器,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。它有一个缺点不能解决循环引用的问题; 可达性分析算法:当一个对象到GC Roots不可达时,在下一个垃圾回收周期中尝试回收该对象。定义一系列的 GC ROOT 为起点。从起点开始向下开始搜索,搜索走过的路径称为引用链。当一个对象到 GC ROOT没有任何引用链相连的话,则对象可以判定是可以被回收的。

可达性分析算法详答

当不能从GC Root寻找一条路径到达该对象时,将进行第一次标记。 第一次标记后检查对象是否重写了finalize() 和是否已经被调用了finalize()方法。若没有重写finalize()方法或已经被调用,则进行回收。 在已经重写finalize()方法且未调用的情况下,将对象加入一个F-Queue 的队列中,稍后进行第二次检查 在第二次标记之前,对象如果执行finalize()方法并完成自救,对象则不会被回收。否则完成第二次标记,进行回收。值得注意的是finalize()方法并不可靠。

虚拟机默认采用的是可达性分析算法。

可以作为 GC ROOT 的对象包括:

栈中引用的对象; 静态变量、常量引用的对象; 本地方法栈 native 方法引用的对象。

JVM中的永久代中会发生垃圾回收吗

垃圾回收一般不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。通过查看垃圾收集器的输出信息,就会发现永久代也是被回收的。所以正确的设置永久代大小可以有效避免Full GC。

Java8中已经移除了永久代,新加了一个叫做元数据区的native内存区,现在大多数的类元数据分配在本地化内存中。

说一下 JVM 有哪些垃圾回收算法?

标记-清除算法:标记无用对象,然后进行清除回收。缺点:效率不高,无法清除垃圾碎片。 复制算法:按照容量划分两个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的那块内存空间清理掉。缺点:内存使用率不高,只有原来的一半。 标记-整理算法:标记无用对象,让所有存活的对象都向一端移动,然后清除掉端边界以外的内存。 分代收集算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代采用复制算法,老年代采用标记整理算法。

标记-清除算法

标记无用对象,然后进行清除回收。

标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段:

标记阶段:标记出可以回收的对象。 清除阶段:回收被标记的对象所占用的空间。

标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。

优点:实现简单,不需要对象进行移动。

缺点:由于标记的过程需要遍历所有的 GC ROOT,清除的过程也要遍历堆中所有的对象,标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。

标记-清除算法的执行的过程如下图所示

复制算法

为了解决标记-清除算法的效率不高的问题,产生了复制算法。把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将已使用的内存空间一次清理掉。

优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。

缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。

复制算法的执行过程如下图所示

标记-整理算法

在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记-清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标记-整理算法(Mark-Compact)算法,与标记-清除算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对端边界以外的内存进行回收。回收后,已用和未用的内存都各自一边。

优点:解决了标记-清除算法存在的内存碎片问题。

缺点:需要进行局部对象移动,一定程度上降低了效率。

标记-整理算法的执行过程如下图所示

分代收集算法

当前商业虚拟机都采用分代收集的垃圾收集算法。分代收集算法,顾名思义是根据对象的存活周期将内存划分为几块。一般包括新生代、老年代,新生代采用复制算法,老年代采用标记-整理算法,注:Java8中已经移除了永久代,添加了元数据区。如图所示:

垃圾收集算法小结

说一下 JVM 有哪些垃圾回收引擎?

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图展示了7种作用于不同分代的收集器,其中用于回收新生代的收集器包括Serial、ParNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,还有用于回收整个Java堆的G1收集器。不同收集器之间的连线表示它们可以搭配使用。

Serial收集器(复制算法):新生代单线程收集器,标记和清理都是单线程,优点是简单高效; ParNew收集器 (复制算法):新生代并行收集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现; Parallel Scavenge收集器 (复制算法):新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景; Serial Old收集器 (标记-整理算法):老年代单线程收集器,Serial收集器的老年代版本; Parallel Old收集器 (标记-整理算法):老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本; CMS(Concurrent Mark Sweep)收集器(标记-清除算法):老年代并行收集器,追求最短GC回收停顿时间,具有高并发、低停顿的特点; G1(Garbage First)收集器 (标记-整理算法):Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。

新生代垃圾回收器和老年代垃圾回收器都有哪些?有什么区别?

新生代回收器:Serial、ParNew、Parallel Scavenge 老年代回收器:Serial Old、Parallel Old、CMS 整堆回收器:G1

新生代垃圾回收器一般采用的是复制算法

老年代垃圾回收器一般采用的是标记-整理的算法

详细介绍一下 CMS 垃圾回收器?

CMS 是英文 Concurrent Mark-Sweep 的简称,是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。

在启动 JVM 的参数加上“-XX:+UseConcMarkSweepGC”来指定使用 CMS 垃圾回收器。

CMS 使用的是标记-清除的算法实现的,所以在 gc 的时候会产生大量的内存碎片,当剩余内存不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure,临时 CMS 会采用 Serial Old 回收器进行垃圾清除,此时的性能将会被降低。

CMS 收集器是以获取最短停顿时间为目标的收集器。相对于其他的收集器 STW 的时间更短暂,可以并行收集是它的特点,同时它基于标记-清除算法。整个 GC 过程分为4步:

初始标记:标记 GC ROOT 能关联到的对象,需要 STW; 并发标记:从 GCRoots 的直接关联对象开始遍历整个对象图的过程,不需要 STW; 重新标记:为了修正并发标记期间,因用户程序继续运作而导致标记产生改变的标记,需要 STW; 并发清除:清理删除掉标记阶段判断的已经死亡的对象,不需要 STW。

从整个过程来看,并发标记和并发清除的耗时最长,但是不需要停止用户线程。而初始标记和重新标记的耗时较短,但是需要停止用户线程。总体而言,整个过程造成的停顿时间较短,大部分时候是可以和用户线程一起工作的。

G1垃圾回收器的原理了解吗?

G1 作为 JDK9 之后的服务端默认收集器,不再区分年轻代和老年代进行垃圾回收。

把内存划分为多个 Region,每个 Region 的大小可以通过 -XX:G1HeapRegionSize 设置,大小为1~32M。

对于大对象的存储则衍生出 Humongous 的概念。超过 Region 大小一半的对象会被认为是大对象,而超过整个 Region 大小的对象被认为是超级大对象,将会被存储在连续的 N 个 Humongous Region 中。

G1 在进行回收的时候会在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间优先回收收益最大的 Region。

G1 的回收过程分为以下四个步骤:

初始标记:标记 GC ROOT 能关联到的对象,需要 STW; 并发标记:从 GCRoots 的直接关联对象开始遍历整个对象图的过程,扫描完成后还会重新处理并发标记过程中产生变动的对象; 最终标记:短暂暂停用户线程,再处理一次,需要 STW; 筛选回收:更新 Region 的统计数据,对每个 Region 的回收价值和成本排序,根据用户设置的停顿时间制定回收计划。再把Region 中存活对象复制到空的 Region,同时清理旧的 Region。需要 STW。

总的来说除了并发标记之外,其他几个过程也还是需要短暂的 STW。G1 的目标是在停顿和延迟可控的情况下尽可能提高吞吐量。

简述分代垃圾回收器是怎么工作的?

根据对象的存活周期将堆内存划分:老年代和新生代,新生代默认的空间占比总空间的 1/3,老年代的默认占比是 2/3。

新生代使用的是复制算法,新生代里有 3 个分区:Eden、From Survivor、To Survivor,它们的默认占比是 8:1:1,它的执行流程如下:

把 Eden + From Survivor 存活的对象放入 To Survivor 区; 清空 Eden 和 From Survivor 分区; From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。

每次在 From Survivor 到 To Survivor 移动存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老年代。大对象会直接进入老年代。

老年代当空间占用到达某个值之后就会触发全局垃圾回收,一般使用标记整理算法。

以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。

内存分配策略

简述java内存分配与回收策略以及Minor GC和Major GC

所谓自动内存管理,最终要解决的也就是内存分配和内存回收两个问题。前面我们介绍了内存回收,这里我们再来聊聊内存分配。

对象的内存分配通常是在 Java 堆上分配(随着虚拟机优化技术的诞生,某些场景下也会在栈上分配,后面会详细介绍),对象主要分配在新生代的 Eden 区,如果启动了本地线程缓冲,则线程优先在 TLAB 上分配。少数情况下也会直接在老年代上分配。总的来说分配规则不是百分百固定的,其细节取决于哪一种垃圾收集器组合以及虚拟机相关参数有关,但是虚拟机对于内存的分配还是会遵循以下几种「普世」规则:

对象优先在 Eden 区分配

多数情况,对象都在新生代 Eden 区分配。当 Eden 区没有足够的空间进行分配时,虚拟机将会发起一次 Minor GC。如果本次 GC 后还是没有足够的空间,则将启用分配担保机制在老年代中分配内存。

这里我们提到 Minor GC,如果你仔细观察过 GC 日常,通常我们还能从日志中发现 Major GC/Full GC。

Minor GC 是指发生在新生代的 GC,因为 Java 对象大多都是朝生夕死,所以 Minor GC 非常频繁,一般回收速度也非常快; Major GC/Full GC 是指发生在老年代的 GC,出现了 Major GC 通常会伴随至少一次 Minor GC。Major GC 的速度通常会比 Minor GC 慢 10 倍以上。

大对象直接进入老年代

所谓大对象是指需要大量连续内存空间的对象,频繁出现大对象是致命的,会导致在内存还有不少空间的情况下提前触发 GC,以获取足够的连续空间来安置新对象。

新生代使用的是复制算法,如果大对象直接在新生代分配,就会导致 Eden 区和两个 Survivor 区之间发生大量的内存复制,因此对于大对象都会直接在老年代进行分配。

长期存活对象将进入老年代

虚拟机采用分代收集的思想来管理内存,会给每个对象定义了一个对象年龄的计数器,对象在 Eden 区出生,经过一次 Minor GC 对象年龄就会加 1,当年龄达到一定程度(默认 15) 就会被晋升到老年代,也就是长期存活对象将进入老年代。

类加载机制

简述Java类加载机制?类加载机制的原理

Java中的所有类,都需要由类加载器装载到JVM中才能运行,同时对数据进行验证,准备,解析和初始化,最终形成可以被虚拟机直接使用的类型。

类加载器本身也是一个类,而它的工作就是把class文件从硬盘读取到内存中。在写程序的时候,我们几乎不需要关心类的加载,因为这些都是隐式装载的,除非我们有特殊的需求,像是反射,就需要显式的加载所需要的类。

类装载方式,有两种 :

隐式装载, 程序在运行过程中当碰到通过new 等方式生成对象时,隐式调用类装载器加载对应的类到jvm中 显式装载, 通过class.forname()等方法,显式加载需要的类

Java中类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到jvm中,至于其他类,则在需要的时候才加载,这是为了节省内存开销。

什么是类加载器,类加载器有哪些?

类加载器负责将字节码文件的类加载到虚拟机内存中。

主要有一下四种类加载器:

启动类加载器(Bootstrap ClassLoader):用来加载Java核心类库,无法被Java程序直接引用。即用来加载JAVA_HOME/jre/lib目录中的,或者被 -Xbootclasspath 参数所指定的路径中并且被虚拟机识别的类库; 扩展类加载器(Extension ClassLoader):用来加载 Java 的扩展库。负责加载JAVA_HOME/jre/lib/ext目录或-Djava.ext.dir系统变量指定的路径中的所有类库; 应用程序类加载器(Application ClassLoader):用来加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器,默认就是用这个加载器。 用户自定义类加载器:通过继承 java.lang.ClassLoader类的方式实现用户自定义类加载器。

说一下类装载的执行过程?

类装载分为以下 5 个步骤:

加载:根据查找路径找到相应的 class 文件然后导入; 验证:检查加载的 class 文件的正确性; 准备:给类中的静态变量分配内存空间; 解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用可以理解为一个标识,而直接引用直接指向内存中的地址; 初始化:对静态变量和静态代码块执行初始化工作。

什么是双亲委派模型?双亲委派模型工作流程是怎样的?双亲委派模型的好处是什么?

在介绍双亲委派模型之前先说下类加载器。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立在 JVM 中的唯一性,每一个类加载器,都有一个独立的类名称空间。类加载器就是根据指定全限定名称将 class 文件加载到 JVM 内存,然后再转化为 class 对象。

双亲委派模型:如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去加载,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载器无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载此类。

自下而上检查类是否已经被加载,自上而下尝试加载类

双亲委派模型工作流程:

当Application ClassLoader 收到一个类加载请求时,他首先不会自己去尝试加载这个类,而是将这个请求委派给父类加载器Extension ClassLoader去完成。 当Extension ClassLoader收到一个类加载请求时,他首先也不会自己去尝试加载这个类,而是将请求委派给父类加载器Bootstrap ClassLoader去完成。 Bootstrap ClassLoader尝试加载此类,如果Bootstrap ClassLoader加载失败,就会让Extension ClassLoader尝试加载。 Extension ClassLoader尝试加载此类,如果Extension ClassLoader也加载失败,就会让Application ClassLoader尝试加载。 Application ClassLoader尝试加载此类,如果Application ClassLoader也加载失败,就会让自定义加载器尝试加载。 如果均加载失败,就会抛出ClassNotFoundException异常。

双亲委派模型的好处:保证核心类库不被覆盖。如果没有使用双亲委派模型,由各个类加载器自行加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统将会出现多个不同的Object类, Java类型体系中最基础的行为就无法保证,应用程序也将会变得一片混乱。

JVM调优

说一下 JVM 调优的工具?

JDK 自带了很多监控工具,都位于 JDK 的 bin 目录下,其中最常用的是 jconsole 和 jvisualvm 这两款可视化监控工具。

jconsole:JDK 自带的可视化管理工具,用于对 JVM 中的内存、线程和类等进行监控,对垃圾回收算法有很详细的跟踪,功能简单; jvisualvm:JDK 自带的全能分析工具,可以分析:内存快照、线程快照、程序死锁、监控内存的变化、gc 变化等,功能强大。

常用的故障检测,监视,修理工具

工具名称主要作用jps (JVM Process Status Tool)显示系统中所有的虚拟机进程jstat (JVM Statistics Monitoring Tool)收集虚拟机各方面的运行数据jinfo (Configuration Info for Java)显示虚拟机配置信息jmap (Memory Map for Java)生成虚拟机的内存转储快照jhat (JVM Heap Dump Browser)分析堆内存转储快照,不推荐使用,消耗资源而且慢jstack (Stack Trace for Java)显示线程堆栈快照

谈谈你的GC调优思路?

谈到调优,这一定是针对特定场景、特定目的的事情, 对于 GC 调优来说,首先就需要清楚调优的目标是什么?从性能的角度看,通常关注三个方面,内存占用(footprint)、延时(latency)和吞吐量(throughput)

基本的调优思路可以总结为:

理解应用需求和问题,确定调优目标。假设,我们开发了一个应用服务,但发现偶尔会出现性能抖动,出现较长的服务停顿。评估用户可接受的响应时间和业务量,将目标简化为,希望 GC 暂停尽量控制在 200ms 以内,并且保证一定标准的吞吐量。 掌握 JVM 和 GC 的状态,定位具体问题,确定是否有 GC 调优的必要。具体有很多方法,比如,通过 jstat 等工具查看 GC 等相关状态,可以开启 GC 日志,或者是利用操作系统提供的诊断工具等。例如,通过追踪 GC 日志,就可以查找是不是 GC 在特定时间发生了长时间的暂停,进而导致了应用响应不及时。 接着需要思考选择的 GC 类型是否符合我们的应用特征,具体问题表现在哪里。是 Minor GC 过长,还是 Mixed GC 等出现异常停顿情况;如果不是,考虑切换到什么类型,如 CMS 和 G1 都是更侧重于低延迟的 GC 选项。 通过分析确定具体调整的参数或者软硬件配置。 验证是否达到调优目标,如果达到目标,即可以考虑结束调优;否则,重复进行分析、调整、验证。

常用的 JVM 调优的参数都有哪些?

1、性能调优要做到有的放矢,根据实际业务系统的特点,以一定时间的JVM日志记录为依据,进行有针对性的调整、比较和观察。

2、性能调优是个无止境的过程,要综合权衡调优成本和更换硬件成本的大小,使用最经济的手段达到最好的效果。

3、性能调优不仅仅包括JVM的调优,还有服务器硬件配置、操作系统参数、中间件线程池、数据库连接池、数据库本身参数以及具体的数据库表、索引、分区等的调整和优化。

4、通过特定工具检查代码中存在的性能问题并加以修正是一种比较经济快捷的调优方法。

常用的 JVM 调优的参数

-Xms2g:初始化堆大小为 2g; -Xmx2g:堆最大内存为 2g; -Xmn1g:新生代内存大小为1g;-XX:NewSize 新生代大小,-XX:MaxNewSize 新生代最大值,-Xmn 则是相当于同时配置 -XX:NewSize 和 -XX:MaxNewSize 为一样的值; -XX:NewRatio=2:设置新生代的和老年代的内存比例为 1:2,即新生代占堆内存的1/3,老年代占堆内存的2/3; -XX:SurvivorRatio=8:设置新生代 Eden 和 两个Survivor 比例为 8:1:1; –XX:+UseParNewGC:对新生代使用并行垃圾回收器。 -XX:+UseParallelOldGC:对老年代并行垃圾回收器。 -XX:+UseConcMarkSweepGC:以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。 -XX:+PrintGC:开启打印 gc 信息; -XX:+PrintGCDetails:打印 gc 详细信息。

@数据结构

设计模式

工厂:

创建对象的方式交由工厂处理(factory),对象调用者不关心对象是如何创建的,把业务逻辑和对象的创建解耦创建接口和其他子类实现,创建工厂类,提供静态方法返回工厂类型的对象。

简单工厂: 根据类名创建子对象返回(如spring) 缺点:不方便拓展子类,需要改动工厂源码 工厂方法 将工厂抽象出来,一个工厂只创建一种产品,调用者先通过工厂静态方法获取产品工厂类,再获取对应对象 缺点:只能扩展产品,不能扩展产品族 工厂模板: 基于工厂方法,在工厂类中可以生产其他类型产品,如食品店可生产蛋糕和奶茶2种产品。

就设计方法推荐工厂方法,实际中一般使用简单工厂。

单例:

构造器私有化,提供静态方法获取对象

懒加载: 用到该类才创建 对象 = 初始化为null。 饿加载 在启动时就创建好类放在内存中 初始化不为bull。

代理:

jdk动态代理(通过接口):

创建接口及被代理类,创建处理器实现invocationhandler接口,重写invoke方法,在invoke方法中实现方法的增强。如统计方法耗时。创建的代理对象为jdk的Proxy类。

public class Client {

  public static void main(String[] args) {

      // 第一步:创建目标对象

      OrderService target = new OrderServiceImpl();

      // 第二步:创建代理对象

      OrderService orderServiceProxy = Proxy.newProxyInstance(target.getClass().getClassLoader(), target.getClass().getInterfaces(), 调用处理器对象);

      // 第三步:调用代理对象的代理方法

      orderServiceProxy.detail();

      orderServiceProxy.modify();

      orderServiceProxy.generate();

  }

}

public class TimerInvocationHandler implements InvocationHandler {

  // 目标对象

  private Object target;

  // 通过构造方法来传目标对象

  public TimerInvocationHandler(Object target) {

      this.target = target;

  }

  @Override

  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

      // 目标执行之前增强。

      long begin = System.currentTimeMillis();

      // 调用目标对象的目标方法

      Object retValue = method.invoke(target, args);

      // 目标执行之后增强。

      long end = System.currentTimeMillis();

      System.out.println("耗时"+(end - begin)+"毫秒");

      // 一定要记得返回哦。

      return retValue;

  }

}

cglib动态代理(通过子类,被代理类不能是final):

同jdk的差不多,只是要指定继承哪个父类;

public class Client {

  public static void main(String[] args) {

      // 创建字节码增强器

      Enhancer enhancer = new Enhancer();

      // 告诉cglib要继承哪个类

      enhancer.setSuperclass(UserServiceImpl.class);

      // 设置回调接口

      enhancer.setCallback(方法拦截器对象);

      // 生成源码,编译class,加载到JVM,并创建代理对象

      UserService userServiceProxy = (UserService)enhancer.create();

      userServiceProxy.login();

      userServiceProxy.logout();

  }

}

方法拦截器对象:

public class TimerMethodInterceptor implements MethodInterceptor {

  @Override

  public Object intercept(Object target, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {

      return null;

  }

}

装饰者:

许你动态地向对象添加新的行为而不影响其原有的行为

实现方式:装饰子接口声明了被装饰类的属性,并编写对应装饰器来实现功能的增强

代理和装饰者的区别:

目的不同 : 装饰者是为了增强目标对象 静态代理是为了保护和隐藏目标对象 获取目标对象构建的地方不同 : 装饰者是由外界传递进来,可以通过构造方法传递 静态代理是在代理类内部创建,以此来隐藏目标对象,专注于控制访问

装饰者器多的情况下比较难实现与管理,不推荐使用

模板:

一个抽象类中,有一个主方法,再定义n个方法,可以是抽象的,也可以是实际的方法,定义一个类,继承该抽象类,重写抽象方法,通过调用抽象类,实现对子类的调用。

举例:

public abstract class Game {

  abstract void initialize();

  abstract void startPlay();

  abstract void endPlay();

  //模板

  public final void play(){

    //初始化游戏

    initialize();

    //开始游戏

    startPlay();

    //结束游戏

    endPlay();

  }

}

public class LOLGame extends Game {

......

}

优点

封装不变的部分,将不变的部分抽取出来; 扩展可变部分,将可变的设置抽象方法,让具体子类来实现。 抽取的公共代码,便于后期维护 行为有基类来控制,具体操作有子类实现。

缺点

每一个不同的实现都需要有一个子类来实现,这样就会导致类的数量大大的增加,使得系统更加庞大。

注意事项: 为防止恶意操作,一般模板方法都加上 final 关键词。

精彩链接

评论可见,请评论后查看内容,谢谢!!!评论后请刷新页面。