java面试总结之一

前言

  从事java开发也有个三年多了,本着不本分的精神想出来蹦哒蹦哒,最近经历了比较痛苦的一个月,面试了大大小小的十多家公司,有过欣喜,有过悲伤,有过期待,当然也有失望,个人觉得这是一个值得怀念的经历,所以想把这些写下来,也希望能帮助到一些和我一样为梦想而努力的人。此篇博客主要讲述一些面试遇到的技术问题,部分地方可能不够详尽,大家可以根据题意再深入学习之。

一、自我简介

  此处不是我们重点关注的地方,但是切记不要太过紧张。首先,说话的时候要自然得体,不要显得沉闷,要给人一种亲和、热情、开朗的感觉,性格往往也是决定面试的关键因素之一;其次,要在自我简介中突出自己的优势,比如学历、学校或者年龄等等,不要觉得简历上都有,面试官对基本信息都是一扫而过的,你说出来会让他加深记忆;再次,要懂得适度谦虚,如我是XX大学的,我们学校是985院校,后面这句是装X用的,会给你的面试打折扣。对于自己不是很了解的问题,可以虚心请教,也会给人一种好学的赶脚;最后,要懂得察言观色,根据面试官的长相和言行举止get到他的性格,投其所好,这个看个人能力了。

二、java笔试篇

      很多公司还是有这个恶心的过程的(笔试题都是基础题,可以自行百度),即使没有笔试,面试官基本也会让你写两个算法,通常来说都比较相似,总结如下:

  1、冒泡排序  

    // 冒泡算法
    void sort(int[] array) {
        if (array == null || array.length == 0) {// 数据校验
            return;
        }
        int tem;// 临时变量
        for (int i = 0; i < array.length - 1; i++) {
            for (int j = i + 1; j < array.length; j++) {
                if (array[i] > array[j]) {// 交换位置
                    tem = array[i];
                    array[i] = array[j];
                    array[j] = tem;
                }
            }
        }
    }

  冒泡算法是比较基础的算法,更多的需要了解一下思路,这里不再多说。写代码时需要注意的几点,思路、边界值验证、注释、命名规则以及代码格式,可以从侧面提现一个人的编码风格。

  补充:上面的算法和网上的略有不同,此算法是将最小值放到数组的首位,网上给的算法是将最大值放到数组的末尾,其原理是一样的,这里贴上最大值放到数据末尾的代码

 1     static void sort(int[] array) {
 2         if (array == null || array.length == 0) {
 3             return;
 4         }
 5         for (int i = 0; i < array.length - 1; i++) {// 控制循环次数
 6             for (int j = 0; j < array.length - 1 - i; j++) {// 控制每次循环排序次数
 7                 if (array[j] > array[j + 1]) {
 8                     int temp = array[j];
 9                     array[j] = array[j + 1];
10                     array[j + 1] = temp;
11                 }
12             }
13         }
14     }

  2、快速排序

 1     // 分割数组
 2     static int partition(int[] array, int low, int high) {
 3         int key = array[low];
 4         while (low < high) {
 5             while (key <= array[high] && low < high) {
 6                 high--;
 7             }
 8             array[low] = array[high];
 9             while (key >= array[low] && low < high) {
10                 low++;
11             }
12             array[high] = array[low];
13         }
14         array[high] = key;
15         return high;
16     }
17 
18     // 快速排序
19     static void quickSort(int[] array, int low, int high) {
20         // TODO 数据校验
21         if (low >= high) {
22             return;
23         }
24         int index = partition(array, low, high);
25         quickSort(array, low, index - 1);
26         quickSort(array, index + 1, high);
27     }

  快速排序就是"分割-递归"的思想,只要理解思想之后,代码很easy,数据校验这块包括null的判断,面试的时候写个标记就OK,让面试官知道你有这方面的考虑。

  3、二分查找法

 1     /**
 2      * 二分查找法
 3      * @param array
 4      * @param low
 5      * @param high
 6      * @param key 要查询的值
 7      * @return
 8      */
 9     static int binarySearch(int[] array, int low, int high, int key) {
10         // TODO 数据校验
11         if (array[low] > key || array[high] < key) {
12             return -1;// 未找到
13         }
14         int mid = (low + high) / 2;// 中位数
15         if (array[mid] < key) {// 在左侧
16             return binarySearch(array, mid + 1, high, key);
17         } else if (array[mid] > key) {// 在右侧
18             return binarySearch(array, low, mid - 1, key);
19         } else if (array[mid] == key) {// 找到值
20             return mid;
21         }
22         return -1;
23     }

  二分查找法又名折半查找法,思想相对来说还是比较好理解的。

  4、其他算法

  面试的时候还会问到一些其他的算法问题,没有具体的标准答案,无非就是根据题目的思想完成代码的编写,如果实在写不出来,可以用伪代码或者文字来描述,切记不要说不会,如果真的一点思路没有,可以让面试官给点提示,就看个人的发挥水平了,这里简单说几个面试遇到的问题。

  1)在一个int数组里,记录着一个人参加社保的年度,中间可能出现断档的情况,求这个人连续参保的最大年限,数组形式如下:

1 int[] array = {1990, 1991, 1992, 1993, 1994, 1996, 1997, 1998, 1999, 2000, 2001, 2003, 2005, 2006, 2007 ...};

  思考:观察数组,理解题意,无非就是求最大连续的元素个数

  解决方法:可以通过一个for循环,循环数组的每个元素,比较相邻的两个元素是否相差1,如果是则计数器+1,否则,比较当前计数器的值与最大值的小大,将二者较大的赋值给最大值,然后重置计数器,最终返回最大值就OK;

  代码如下:

 1   static int getMaxLength(int[] array) {
 2         // TODO 数据校验,null和长度为1的校验
 3         int max = 0;// 最大值
 4         int count = 1;// 计数器
 5         for (int i = 0; i < array.length - 1; i++) {// 遍历至lengh-1即可,否则会数组下标越界
 6             if (isContinue(array[i], array[i + 1])) {// 连续,计数器+1
 7                 count++;
 8             } else {// 不连续,将max和count较大的赋值给max,并重置计数器
 9                 max = max > count ? max : count;
10                 count = 1;
11             }
12         }
13         return max;
14     }
15 
16     private static boolean isContinue(int x, int y) {
17         return (x + 1) == y;
18     }

  2)在一台内存只有1G的电脑磁盘中,有一个10G大的数据文件,里面存储的都是整数,请找出这10G大小的整数中最小的前100个。

  思考:内存有限,无法一次性将数据全部加载到内存进行排序,考虑文件分割的方式;一大份数据中最小的前100个,也就是从n小份数据各自最小的前100个集合中选取最小的前100个。

  解决方法:将文件平均分成10份,每份大小为1G,依次在内存中进行堆排序(其他排序也OK,这里不是重点),选出最小的前100个数字,之后再从10份最小的前100个数字集合中选择最小的前100个就是结果。

  PS:也可以将数据存储到数据库之类的系统中进行排序,不过这应该属于投机取巧的方式,不推荐首先使用,如果上述办法能想出来再说这个。

  3)其他问题

    a)链表是否有环的问题,使用不同步长去遍历链表,如果经过n次循环之后,不同步长处于同一元素节点,说明链表有环。

    b)去除数组重复元素,放入set即可

    c)12345-->一万两千三百四十五,每次对数字进行除10的操作,使用stringbuilder的insert方法来实现

  上述问题可以自行百度搜一下,这里不再阐述。算法是面试中不可获取的一部分,而且每个面试官的问题也有所不同,所以不要死记硬背,掌握基本的算法原理,根据面试官的需求去解决问题,脑洞大开吧!这里做个友情提示,不要只局限于代码逻辑和java,可以适当的使用一些类或者其他工具的特性来实现,比如BlockingQueue,Atomic类,ThreadLocal,CountDownLatch,CycleBarrier,Semaphore等等,甚至可以使用redis等,诸如redis的list实现队列或者栈的功能,进行数组的交集、并集、差集的运算。

三、java基础篇

  1、集合

  java的集合类是开发中经常使用的,诸如ArrayList、LinkedList、HashSet、HashMap、TreeMap、LinkedHashMap等等,相信很多人都了解list、set、map的区别,这里也不做阐述。面试主要的关注点有以下几方面:

  1)数据结构

    ArrayList-->基于数组实现的,可以自动扩容,每次扩容1.5倍,初始容量为10;查询速度快,添加删除速度慢(这里才是重点,为什么慢,因为要移动元素的位置)

    LinkedList-->基于双向链表实现的;查询速度慢,添加删除速度快(移动指针即可)

    HashMap-->基于数组+单链表实现的,允许有一个空的key(空的key放在数组第一个位置,每次get/put方法都会优先考虑空的key)和多个空的value

    TreeMap-->基于红黑树实现的,可以对数据进行排序;红黑树是啥?有兴趣的可以自行研究学习,反正我看了俩点没懂,一般的面试官也说不出个所以然来,只要知道是一个有颜色的平衡二叉树即可,每次添加删除元素都是基于二叉树实现的,然后进行变形补色转成红黑树。

    HashSet-->和HashMap一致,没什么好说的,只不过value是new Object()而已。

    LinkedHashMap-->继承HashMap,除了拥有父类的数据结构,内部还维护着一个双向链表,用来存储数据的顺序,可以根据accessOrder(boolean值)属性来设置根据插入顺序排序还是根据访问顺序排序,默认是false,按照插入顺序排序。新数据放在双向链表的尾部,所以可以用来实现lru(最近最少使用)算法,第一个元素就是最近最少访问的。

  2)线程安全

    线程安全的集合类,我们能接触的只有hashtable,可以做适当的了解(内部都是synchronized方法),其他上述的集合类都是线程不安全的,在多线程情况下使用时需要注意,可以使用一些类带代替,如ConcurrentHashMap。也可以使用Collections.synchronized系列的方法来实现对非线程安全的集合类转成线程安全的。这里需要提出的是Collections的同步方法其实是生成一个静态内部类,这个类的方法中都使用了同步代码块,所以具有线程安全的作用,可以简单的看一下源码,非常简单,万一面试官问你Collections类怎么实现线程安全的,你可以好说出个一二来,我就被问过,当然我肯定没答上来,见笑!

  3)源码

    源码不要求把所有的集合类源码都记住,但是HashMap一定要十分熟悉,包括get/put方法的流程,put时需要注意什么(作为key的对象要不可变的,可变的话hashcode就变了;hash碰撞,这也是为什么key的对象要重写hashcode和equals方法;resieze,然后触发rehash,会问你扩容的流程),再有就是加载因子这个参数的意义,阀值=容量*加载因子,当hashmap大小达到阀值时就会自动扩容,所以使用hashmap时,如果知道确切的大小,最好初始化的时候就指定大小,防止resize发生。另外一点作为了解的就是hashmap的容量永远是2的n次方,你初始化时设置的容量大小会向上增加至最近的一个2的n次方,比如你设置容量大小为10,因为23=8,24=16,所以实际hashmap的容量是16(初始化时设置9,10,11,12,13,14,15,其结果都一样),主要是为了进行hash运算的方便,当length为2的n次方时,hashcode%lengh == hashcode & (length - 1)。其他的如ArrayList和LinkedList源码相对较简单,HashSet和HashMap是一样的,熟悉即可。

  2、IO、线程和反射

  1)IO

    BIO和NIO的区别,有兴趣的同学可以了解一下NIO,一般网络开发的话都会有NIO的要求,不过我这块比较渣,只知道selector,buffer,channel这些。

    IO使用的设计模式,装饰模式,在缓冲流中会用到,根据io流创建缓冲流,然后在创建之前和之前做了一些操作,有点像代理模式,这个地方可以了解一下

  2)Thread

    继承Thread类或者实现Runnable接口,推荐实现接口(java单继承的原因)。线程的五种状态,新建、就绪、运行、阻塞和死亡。更多的会使用线程池,后面会讲到。

  3)反射

    其实个人觉得反射这块没有什么好问的问题,但是如果不会反射的人不能说会java,在面试的时候遇到private之类的东西问你怎么访问,果断反射怼上去,绝对没问题的。

  3、异常

  1)Error,严格来说,Error不属于异常,Error和Exception属于Throwable,常见的Error就是OutOfMemoryError和StackOverFlowError,前者是内存溢出,出现的原因就是内存不足,可能是内存碎片较多而无法申请足够大的连续空间造成的,这个可能和GC算法有关,也可能是java堆过小等等;后者是栈溢出,方法中调用另一个方法的时候就是入栈,返回数据的时候就是出栈,如果调用的深度过高就会出现这种情况,常见出现这种情况的原因就是递归调用次数太多。这两个Error要有所了解,包括是什么,为什么会出现。

  2)运行时异常,常见的空指针,数组下标越界,格式转换错误等等。通常都可以通过修改代码来解决,不需要捕获,继承至RunTimeException。

  3)非运行时异常,常见的FileNotFoundException、IOException等,这种异常在编译阶段就可以发现,可以通过try/catch来捕获处理,或者throws到上一层进行集中处理。

  4、类加载器

  java的类加载器分为四种,BootstrapClassLoader(启动类加载器,C语言编写,所以java里为null),ExtentionClassLoader(扩展类加载器)、ApplicationClassLoader(应用类加载器)和用户自定义的ClassLoader。不同的classloader加载同一个类时,返回的是不同的实例,因为命名空间不一样。classloader采用双亲委派模式来加载类,即一个类加载器在加载类之前会先让加载的请求让上一级类加载器去执行,如果上一级类加载器加载不到再由自己去加载,这样做的好处是安全性的考虑。

四、java高级

  1、线程池

  1)Executors类创建的四种线程池都是什么,有哪些特性?

    a)newCachedThreadPool,创建一个阻塞队列为SynchronousQueue的线程池,核心线程数为0,线程最大值为Integer的最大值,线程的失效时间为一分钟,其阻塞队列不存储任务,也就是说,当新来一个任务时,如果没有空闲线程,则创建一个新的线程去执行。

    b)newFixedThreadPool,根据参数创建一个核心线程数和最大线程数一致的线程池,其阻塞队列为LinkedBlockingQueue,大小为Integer的最大值,默认线程池中的线程均不会失效,当新来一个任务时,如果没有空闲线程,则将任务放入阻塞队列中。

    c)newSingleThreadExecutor,创建一个单线程的线程池,能保证所有的任务按照顺序执行,当这个唯一的线程消亡后,会产生的一个新的线程继续执行任务。

    d)newScheduledThreadPool,创建一个可以定时循环执行任务的线程池。

  2)自定义ThreadPoolExecutor

    在实际的开发中,上述的四种线程池可能无法满足我们的需求,我们可以根据实际情况通过new newScheduledThreadPool()去自定义线程池,也可以使用spring的org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor来创建线程池,其实spring的底层也是通过new newScheduledThreadPool()来创建,不过spring的bean默认是单例的,使用起来更加方便。关于new newScheduledThreadPool()的参数需要做一个了解,包括Reject策略。

  3)线程池的流程

    首先,判断线程池中线程个数是否达到核心线程数,如果没有达到,则创建一个核心线程去执行任务;如果已经达到核心线程数,则执行第二步;其次,判断阻塞队列是否已满,如果阻塞队列未满,则将任务放入阻塞队列,等待任务被执行;如果阻塞队列已满,则执行第三步;再次,判断线程池中线程个数是否已经达到最大线程数,如果未达到,则创建一个缓存线程来执行任务;如果已经达到,则根据拒绝策略执行任务处理。

    思想就是:核心线程优先创建,其次是向阻塞队列中存放任务,再次是创建缓存线程(创建线程的代价比较高,所以能使用阻塞队列尽量使用阻塞队列,个人觉得设计者应该是这么考虑的),最后任务实在没有地方能处理或存放,只能根据拒绝策略来处理了。

  4)线程池如何维护线程,即缓存线程如何消亡,核心线程如何一直工作?在runWork方法中有一个while循环一直调用getTask方法从阻塞队列中获取任务,由于阻塞队列的take()方法可以设置时间,当在规定时间内没有获取到任务,则返回false,线程跳出while循环,执行完毕即死亡,而核心线程会一直处于while循环中,会一直阻塞直到阻塞队列有新的任务,从而不会死亡。

  5)线程池如何保证同一个任务只被一个线程执行?ReentrantLock加锁来实现

  2、锁

  在java中锁分为乐观锁和悲观锁,所谓的悲观锁就是真正意义上的加锁,即同一时间内只有一个线程能获取数据的访问权限,其他线程需要阻塞等待;乐观锁只是概念上的锁,实际中并没有加锁的操作,其思想就是乐观的认为对象并没有被其他线程所修改,根据自己的预期值和对象的实际值进行比较,如果结果一致则进行访问处理,如果不一致,则在此过程中有其他线程对该对象进行了访问修改,会重新循环获取并操作,直到预期值和对象实际值一致为止。乐观锁是基于CAS(compare and swap)的思想,例如在java中的Atomic类的方法。CAS会带来一个叫ABA的问题,即一个对象的值从A到B再到A的变化过程,对于另一个线程来说是感觉不到的,因为第二个线程两次拿到的值均为A。通常我们在数据库中添加一个冗余字段version记录当前数据的版本,每次修改时,version做加1的操作。在多线程情况下,每次修改数据库数据时,可以在where条件中添加一个version的判断,如果version是最新的值,则允许修改,否则说明有其他用户或其他线程对该数据进行过修改,拒绝当前修改操作。

  synchronized和ReentrantLock,syschronized是jvm来实现同步的,ReentrantLock是通过代码来控制的,需要加锁和解锁(finally里解锁),内部有一个计数器,加锁时计数器+1,解锁时计算器-1,当计数器为0时才释放锁,可重入锁,但加锁几次也要解锁几次。也可以锁等候,即加锁时可以设置等待时间。

  3、volatile关键字

  有些公司会问此关键字的含义,volatile和synchronized的区别等等。在多线程开发中,对于共享变量的修改,其实是在线程私有区对共享变量做了一个拷贝,每次的读取和修改都是针对本地的拷贝变量,在线程退出之前,会将本地的拷贝写回共享变量。volatile修饰的变量会避免这种方式,每次强制线程从共享区读取数据,并将修改的数据马上写回。但volatile只支持可见性,并不支持原子性。所谓的可见性就是当一个线程对一个共享数据做修改时,另一个线程能马上发现该值被修改;原子性是指,从获取该对象的值到修改,最后写回共享数据的过程是原子性的,也就是说volatile并不能保证线程安全。

  volatile关键字的另一个作用是在字节码中设置一个栅栏(字节码中有一个lock字样),我们都知道,在编译阶段会对代码进行重排序,volatile能保证在它之前的代码不会因为重排序而在它之后执行,在它之后的代码也不会因为重排序在它之前执行。

  4、并发包

  并发包下最常见的类就是ConcurrentHashMap,熟悉ConcurrentHashMap、HashMap和HashTable的区别。HashMap是线程不安全的,HashTable通过同步方法对整个数组加锁,而ConCurrentHashMap是两者的折中,采用二次hash,降低了锁的粒度。ConCurrentHashMap将内部的数据分成一个个的段(segment),每次只对某个段进行加锁操作,比如线程t1访问的数据在第一个段,则对第一个段加锁,而线程t2访问的数据在第二个段,则不会因为第一个段加锁而阻塞等待。建议看一看源码

  再有就是CountDownLatch、CycleBarrier、Semaphore工具类的功能,前两者的区别,CountDownLatch采用计数器减一的策略,阻塞的是主线程;CycleBarrier采用计数器加一的策略,阻塞的是同时运行的多个线程

  5、jvm和gc

  1)jmm(java内存模型)

  包括线程私有区和线程共享区,线程私有区又分为线程私有栈(我通常说的栈,存放基本数据类型、对象引用等)、程序计数器(记录下一行该执行程序的位置)、本地方法栈(执行本地方法时使用);线程共享区分为方法区(HotSpot又称永久代,存放class信息等)和java堆(存放对象的共享区域)。

  2)内存分代

  按照分代算法的思想,将内存分为年轻代、年老代和永久代。其中年轻代包括一个Eden区和两个Survivor区,大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当这个 Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制年老代。需要注意,Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时存在从Eden复制过来对象,和从前一个Survivor复制过来的对象,而复制到年老代的只有从第一个Survivor区过来的对象。而且,Survivor区总有一个是空的。同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。

  3)gc算法

    常用的算法有,标记-清除、标记-整理、复制算法,根据分代的思想,不同地方使用不同的算法。在年轻代,由于对象存活率较低,所以采用复制算法,可以整理内存空间,避免内存碎片;在年老代,基于垃圾收集器的不同采用不同的算法;垃圾收集器分为串行、并行和并发三种。典型的CMS(并发)和G1(并行),串行使用的较少,会严重阻塞用户线程。

  6、简单的linux命令

  对于linux命令不熟悉的同学需要花点时间去学习一下,只要掌握常用的就OK。一般面试问到的如下:

  kill——杀进程

  netstat——查看端口号,可根据参数执行类别(-a 所有,-e 连接,-l 监听,-t tcp协议的,-u udp协议的)

  free——查看内存情况

  top——查看服务器CPU、内存等情况

  df——查看磁盘情况

  tail——查看日志

  其他的适当也了解一些吧,毕竟服务器基本都是linux系统的,将来连切换目录都不知道就尴尬了。

  还有一些jvm自带的命令也需要了解一些:

  jps——可以看到当前运行了多少个jvm以及进程号

  jmap——查看内存情况

  jstat——gc次数

  最常问的问题就是,系统执行的慢了,怎么排查,无非就是通过top命令查看cpu使用率高的进程号,然后通过ps -mp pid -o THREAD,tid定位到具体的线程,printf "%x " tid将tid转成16进制的数,最后使用jstack pid|grep 16进制的tid -A 30打印出日志,其中30表示打印的行数。或者通过top命令查看cpu使用率高的进程号pid之后,使用top -H -p pid查询这个进程下线程的情况,-H会显示出线程号tid,最后使用jstack tid > 指定文件,将日志信息dump到指定文件。这里涉及到另外一个问题就是线程和进程的区别,可以自行查询,这里不再累述。

  7、设计模式

    单例模式:spring bean默认是单例的,自己也要会写单例模式,推荐枚举或静态内部类方式。

    工厂模式:Executors就是简单工厂模式,再有就是工厂方法模式,通过不同的实现类对同一个接口的不同实现方式,从而达到生产不同产品的目的,有点多态的意思。抽象工厂模式应用的不算太多,大概的意思就是一个接口里多个抽象方法,每个实现类根据各个抽象方法的产品进行组合,最后返回给用户,比如造车的工厂可以生产白色的宝马车、黑色的奔驰车,也可以生产黑色的宝马车和白色的奔驰车,其中颜色和车就是一种组合。

    代理模式:spring的AOP,mybatis的接口绑定都是使用的代理模式,常见的代理方式有两种,jdk proxy和cglib,两者的区别就是jdk proxy要求被代理类必须实现接口,cglib没有这个约束,因为生成的代理类是被代理类的子类。

    策略模式:通过不同的实现方式进而达到同一目的,比如我做图片上传的时候,你可以提供一个图片的URL,也可以提供一个图片的IO,最终的目的是一样的,方式可以让用户来选择,spring加载配置文件也是如此。

    模板方法:定义了整个流程或者算法,具体的细节通过子类去逐步实现,典型的就是spring的dao模块,里面的preparedstatement对象是由用户来创建生成的,通常我们都是使用new PreparedStatementCreator的方式创建一个匿名内部类。

    观察者模式:spring启动的时候是通过ContextLoaderListener来实现的,再有就是active mq的消费者也可以通过Listener来消费消息。

    生产者-消费者模式:active mq就是最经典的。

    装饰模式:前面提到的缓冲流

    其实设计模式就是一个概念级别的东西,有时候你可能不知道却无意间使用了,所以一般面试官不会在这个地方为难你,除非你连单例、工厂和代理都不知道!

至此,java相关的面试题大概这么多,里面可能有些地方描述的不够准确或不够详细,希望大家及时指出更正,之后会更新其他相关技术的面试经验,敬请期待!面试过程中切记不要被面试官牵着鼻子走,更要将被动转为主动,不要人家问了你才说,人家不问你只字不提,记得把相关的知识点都说出来,这样才能给自己加分!希望能帮助更多的人找到更理想的工作~如果觉得还可以或者对你有帮助,可以打赏呦,我会将所有的钱捐给腾讯公益!

             一分也是爱                                                                      一元见真情

本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

原文链接:http://www.cnblogs.com/1ning/p/6692866.html

原文地址:https://www.cnblogs.com/1ning/p/6692866.html