面试之Java八股文

发布时间 2023-09-27 21:00:43作者: 万里阳光号船长

面向过程与面向对象

  • 面向过程

    顾名思义,注重过程。解决问题时按步骤一步一步来,在程序中体现为按照一定顺序执行方法

    优点:效率高,无需类加载、对象实例化

    缺点:程序耦合度高

  • 面向对象

    顾名思义,注重对象。解决问题时先抽象出场景中的对象(实体类),给其添加属性和方法,让对象去执行方法

    优点:程序易维护、易服用、易扩展(都是耦合度低的好处)

    缺点:相比面向过程效率更低

  • 洗衣机洗衣服的例子

    面向过程:1. 加洗衣粉;2. 加水; 3. 开始洗衣服; 4. 清洗衣服; 5. 甩干

    面向对象:抽象出洗衣机和人两个对象。洗衣机添加方法:洗衣服、清洗衣服、甩干;人添加方法:加洗衣粉、加水。然后调用依次调用对象的方法来解决问题

聚合和组合

整体和部分不可分离称为组合,可以分离称为聚合。例:A类中有成员变量B b = new B(),此时创建A类对象时就附带创建了B类对象,当A类对象被销毁时B类对象也不复存在,这种关系称为组合。当A类中有成员变量B b,此时创建A类时可以选择不初始化B类成员变量(需要时可使用setter方法),这种关系称为聚合

自动拆箱装箱

  • 装箱:将基本类型包装为其对应的引用类型,本质是调用xxx.valueOf()方法。
  • 拆箱:将包装类型转换为基本数据类型,本质是调用xxx.xxxValue方法。
Integer i = 10;  //装箱,等价于Integer i = Integer.valueOf(10); 
int n = i;   //拆箱,等价于 int n = i.intValue();

java集合

Collection接口

​ |---- List接口

​ |---- ArrayList实现类,底层使用数组存储,适用于查询多增删少的场景,线程不安全,默认初始容量为10,达到临界值扩容1.5倍。

​ |---- LinkedList实现类,底层使用链表存储,适用于查询少增删多的场景,线程不安全。

​ |---- Stack实现类,底层使用数组存储,栈元素先进后出。

​ |---- Set接口

​ |---- HashSet实现类,本质是HashMap,元素只有key没有value,元素不重复,无放入顺序。

​ |---- TreeSet实现类,底层使用红黑树存储,元素不重复,无放入顺序但会根据元素内容排序。

​ |---- Quque接口,下面的实现类可以是数组或者链表实现,队列元素先进先出。

Map接口

​ |---- HashMap实现类。

​ |---- LinkedHashMap实现类,链表+map实现元素的有序放入。

​ |---- TreeMap实现类。

集合与数组的区别

  • 数组长度固定,元素类型一致,可存放基本数据类型或引用类型。
  • 集合长度不固定,可存储多种元素类型,只能存放引用类型(如int必须用包装类Integer存储)。

list和set的区别

  • list可以用索引访问元素,但set必须用迭代器或增强for循环
  • list的实现类都可以存放任意数量的null,但HashSet仅能有一个null,TreeSet不能有null

集合删除元素

如果在遍历时使用集合提供的remove方法,整个集合大小-1且删除元素后面的所有元素会往前顶上来,正确的写法如下:

ArrayList<String> list = new ArrayList();
//正向遍历时需i--
for(int i = 0;i < list.size();i++) {
    if(list.get(i).equals("xxx")) {
        list.remove(i);
        i--;
    } 
}
//反向遍历
for(int i = list.size()-1;i >= 0;i--) {
    if(list.get(i).equals("xxx"))
        list.remove(i);
}
//迭代器遍历,推荐使用
Iterator<String> it = list.iterator();
while(it.hasNext()) {
    if(it.next().equals("xxx"))
        it.remove();
    //下面的语句会报并发修改异常
    //list.remove("xxx");
}

注:使用迭代器或增强for遍历集合时,如果调用了集合本身的增加/删除方法会直接报并发修改异常。

ArrayList的扩容过程

采用默认参数创建时首先创建一个空的Object数组,当第一次添加元素时扩容到10。此后每次添加元素时如果超出当前数组容量时就会扩容,每次扩容1.5倍,将旧数组内容拷贝到新数组中,旧数组被抛弃。

类加载过程

  1. 加载,根据全限定类名获取对应的class文件的二进制流并将其转化为方法区中存储的类模板信息,创建Class实例(存储在堆空间中的访问方法区类模板数据的入口,仅jvm能创建);
  2. 链接,又分为如下三步:
    1. 验证:文件、语法合法性检查;
    2. 准备:给静态变量分配内存,并赋默认初始值(如果是fianl修辞的变量其对应的值已经存放在class文件的常量池中,在这一步直接显示赋值);
    3. 解析:将常量池中的符号引用转为真实引用,这一步可能会引起该类依赖的其他类的加载。
  3. 初始化,执行静态变量的显示赋值,以及static块中的代码,这一步涉及到真正执行程序员写的java代码。

类加载器

类加载器负责执行类加载过程中第一步中的将class文件的二进制流加载到方法区中。

四种类加载器

  • 引导类加载器(Bootstrap Class Loader),由C语言编写故获取不到,用于加载JVM运行所必需的类(jre/lib目录下的部分jar包),出于安全考虑只加载java、javax、sun开头的类,同时也负责加载扩展类加载器和系统类加载器。
  • 扩展类加载器(Extension Class Loader),负责加载jre/lib/ext目录下的jar包,如果用户将jar包放在该目录下也会被扩展类加载器加载。
  • 系统类加载器(System Class Loader),也称应用类加载器,是用户自定义类默认的类加载器。
  • 用户自定义类加载器(User Define Class Loader),可以用于隔离加载类、实现java相关文件的加密存储(加载时解密)。

双亲委派机制

当一个类加载器接收到类加载请求后,会先将其委托给自己的父类加载器,如果父类加载器成功加载则直接返回,否则再由子类加载器加载。这是一个递归的过程,每次类加载时先递归向上到最顶层引导类加载器看是否加载过,没有再交给子类加载器。JVM内置的三种类加载器从上至下的父子关系为:引导类加载器->扩展类加载器->系统类加载器(也称为应用程序类加载器),三者间的关系是组合而不是继承。

自定义java.lang.String的问题

有了双亲委派机制的存在,自定义的java.lang.String并不会被加载,因为在JVM启动时引导类加载器就加载了官方的java.lang.String。且用户自定义类的包名也不允许是java.lang包,编译会直接报错。

抽象类和接口

二者都不可以被实例化,都可以包含静态/普通成员变量与静态方法。区别如下:

  • 接口本身以及成员变量、方法的访问权限都必须是public,普通方法不可以有返回体,除非是静态方法或者default修辞,实现接口的类可以选择不重写default修辞的方法,但普通方法必须被重写。
  • 抽象类本身的访问权限都必须是public,成员变量和普通方法的访问权限没有要求,但抽象方法不能是private且必须被重写(除非子类也是抽象类)。

子类重写父类的方法时修饰符必须相同或者更大。

static关键字与final关键字

static关键字

static可以作用于方法或成员变量前,称为静态方法和静态变量,使得不生成对象也可以直接通过类访问该方法或变量。类的加载分为三个阶段:加载->链接->初始化。其中,链接阶段就会对类的所有静态变量分配空间并赋以初值,在初始化阶段时才会执行静态变量的显示赋值和静态代码块的内容,本质是jvm会生成一个方法将静态变量显示赋值语句和静态代码块按代码中出现的顺序执行。

public class Test {

    static {
        //只能赋值不能使用,下面语句会报错
        //System.out.println(a);
        a = 2;
    }

    //把这一行代码换到最上面输出结果就是2
    private static int a = 1;

    public static void main(String[] args) {
        System.out.println(a);
        //输出结果应该是1
    }

}

final关键字

  • 用于修辞类,表示该类不可被继承。
  • 用于修辞方法,表示该方法不可被重写。
  • 用于修辞成员变量,则必须在声明时就初始化值,且以后不可被修改。如果是普通类型则不能修改值,如果是引用类型则不能修改指向(但可以修改指向的对象内部属性)。

volatile关键字

作用有两点:1. 防止指令重排序;2. 保证共享变量内存可见性。

防止指令重排序

编译器为了优化执行效率,可能会对JVM指令进行重排序,如果指令间有依赖关系的话则不会重排序(从而保证不会影响程序执行的正确性)。在单线程下这是正确的,但多线程则可能会引发错误。经典的双重判断锁单例模式:

public class Single {

    private Single() {
    }

    private volatile static Single single = null;

    public static Single getInstance() {
        if (single == null) {	//1
            synchronized (Single.class) {	//2
                if (single == null) {
                    single = new Single();	//3
                }
            }
        }
        return single;
    }

}

如果不加volatile修辞可能会发生什么?首先,new操作并不是一个原子操作,大致可以分为如下三步:a. 分配内存;b. 初始化对象; c. 返回内存地址。显然,步骤b和c依赖步骤a,但步骤c并不依赖步骤b。所以这里可能会发生指令重排序,即可能先返回地址然后初始化对象,单线程下这并不会影响操作的正确性。假设线程A执行到代码3处发生指令重排序,变为acb,当执行完步骤c切换到线程B,线程B执行到代码1处判断得知single不为null,直接返回single。这样一来线程B获得single则会出错,因为此时的single虽然有内存指向,但对象还未初始化。所以必须用volatile修辞single,保证对single执行操作时不会出现指令重排序的现象。

注:对象在内存中主要包含对象头和实例数据。其中对象头包含对类元数据的指针、hashcode值、minorGC年龄、持有锁状态等。实例数据包括自己特有的实例数据以及继承自父类的实例数据。

共享变量可见性

JAVA内存模型(不同于JVM内存模型)为了缓解CPU与内存的速度差异,给每个线程分配了各自的本地内存(高速缓存),当任意线程对共享变量操作时会从主内存拷贝一份到自己的本地内存进行操作,这就有了经典的一致性问题。某一线程对共享变量执行写操作后,其他线程在本地内存缓存的仍是旧的值,见如下代码:

public class Test {

    private boolean flag = false;

    public static void main(String[] args) {
        Test test = new Test();
        new Thread(() -> {
            while(!test.flag);
            System.out.println("flag终于变成true啦!");
        },"Thread1").start();
        new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            test.flag = true;
        },"Thread2").start();
    }

}

Thread1与Thread2共享test对象。Thread1会一直循环去判断test中flag是否为true,为true则输出一句话。Thread2先睡眠1秒,然后将flag改为true。运行程序发现永远输出不了这句话,虽然Thread2在本地内存修改了flag的值后将其写回了主内存(经测试,线程结束后会将本地内存同步到主内存),但Thread1并不会去主内存重新读取,造成程序死循环。但若添加volatile修辞flag变量,则会保证当有线程在本地内存对flag执行写操作后,会强制同步到主内存,并使其他线程的本地内存中flag所在缓存行失效,若需使用则要重新到主内存读取,故保证了线程间共享变量的可见性,上述程序可正常结束。

volatile不保证原子性

这一块容易引起误解,见如下代码:

public class Test {

    public volatile int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        Test test = new Test();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    test.increase();
                }
            }).start();
        }
        while (Thread.activeCount() > 2) ;
        System.out.println(test.inc);
    }

}

新建10个线程对test对象中volatile修辞的inc变量重复1000次加1,乍一看结果应该为10*1000=10000,但实际上有不少概率结果小于10000。volatile保证线程对inc加1操作后同步到主内存并使其他线程的inc缓存失效,但inc++并不是原子操作,详见https://blog.csdn.net/qq_40246487/article/details/115520920?spm=1001.2014.3001.5501。假设线程A已经在操作数栈中放入了inc的值(此时volatile保证了inc是最新的值),然后切换到线程B执行完加1操作后又切换回线程A,此时线程A中的inc缓存虽然失效,但它已经将具体的值放入操作数栈中,无需重新读取inc的值,自然不会从主内存读取inc,而是直接执行加1并写回主存。此时实际上inc只加了1次,造成错误,故volatile不保证原子性,也即无法保证绝对的线程安全。

此时对inc++操作加锁即可解决上述问题。加锁保证了同一时间只有一个线程执行inc++操作,自然不存在原子性问题。与此同时,加锁也可以解决可见性问题。当线程获得锁时,会把本地内存的所有缓存置为无效,从而使得若要使用共享变量需从主内存重新读取;当线程释放锁时,会把本地内存的所有缓存同步到主内存中。所以加锁也可以解决可见性问题。另一种解决方案:将inc的类型改为JUC下的AtomicInteger,将increase方法中的inc++改为inc.incrementAndGet(),使用非阻塞同步的方式CAS机制保证原子性。

HashMap

实现的是Map接口,并非Collection接口。底层使用的是Hash表即数组,里面是一个个Entry,Entry里面主要包含Key和Value。HashMap是线程不安全的,可以有且仅有一个key值为null,value无限制,不允许重复key。

解决Hash冲突的办法

  • 拉链法,即HashMap的底层原理,每个位置存放一个链表或红黑树等数据结构
  • 空间探测法,在发生冲突的位置依次往后找到第一个空的位置存放
  • 再散列法,用第二个散列函数计算得到b(假设第一个函数散列出来的a发生冲突),则依次找a+b模m,a+2b模m......(m为表长度)

put(key,value)方法

调用key的hashcode()方法,将得到的值进行扰动(高16位不变,低16位变为高16位异或低16位的值,目标是减少碰撞概率)、散列(将扰动后的值和表长度减一进行与操作)得到索引值。若索引位置为空则直接插入,不为空则遍历此处的链表或红黑树,调用key的equals()方法与每个Entry的key进行比较,若返回true则在该位置插入新节点(即key存在则覆盖原来的value),若返回false继续往后遍历。若遍历结束仍未找到相同的key,则在末尾插入新节点(jdk1.8以前是头插法,jdk1.8及以后是尾插法)。

get(key)方法

调用key的hashcode()方法,将得到的值进行扰动、散列得到索引值,若索引位置为空则返回null,不为空则遍历此处的链表或红黑树,调用key的equals()方法与每个Entry的key进行比较,若返回true则返回对应的value,若返回false继续往后遍历直到找到为止。找不到则返回null。

加载因子

决定了hash表的大小阈值,即表长(初始为16)*加载因子,默认为0.75。在put方法中插入元素时会判断表元素总个数是否大于阈值,是则执行扩容操作:新开辟大小为原来两倍的空间,重新计算每个Entry的key的索引值后放入。其中,jdk1.7是先扩容后插入,jdk1.8时先插入这个新元素后扩容。

什么时候将链表变为红黑树

在put方法中插入元素后会判断该位置链表的长度是否大于8,然后判断数组长度是否大于64,是则将链表变为红黑树,否则直接扩容。当长度又小于6时,将红黑树变为链表。

为什么容量是2的次幂

便于计算key的索引值。散列操作的本质是将扰动后的值对表长度取模,若表长为2的次幂,可以直接将扰动后的值与表长减一(换成二进制为全1)进行与操作即可,无需算数取模,从而极大提升效率。

为什么作为key的对象必须重写hashcode()和equals()方法

很明显,put和get方法中都用到了key对象的hashcode()和equals()方法,不重写必然会影响操作的正确性。可以反向思考如果不重写会发生什么?

  • 不重写hashcode()方法。则默认使用的是继承自Object类的hashcode()方法,这里返回的是对象在JVM中的地址。两个对象的地址必然是不同的,这会导致对任意两个对象执行put操作时将它们的hashcode值进行扰动散列后得到的索引值很大概率是不同的(小概率发生哈希冲突),则大概率将两个对象都存入HashMap中。这显然不是我们想要的,如两个字面值都为“abc”的String对象,我们希望在HashMap中不要存在相同的值为“abc”的key。所以必须重写hashcode()方法,确保相同内容的key调用hashcode()后返回相同的值。
  • 不重写equals()方法。则默认使用的是继承自Object类的hashcode()方法,这里比较的是对象在JVM中的地址是否相同。注意子类重写的hashcode()方法无法保证调用此方法返回的值是唯一的,如String类重写的hashcode()方法中“通话”和“重地”两个字符串的hashcode()方法返回相同的值。如果不重写equals()方法,当我put“通话”和“重地”两个字符串时计算出的索引值相同,然后进一步遍历链表或红黑树去调用equals()方法比较时会认为这两个字符串是相同的,进而无法将第二个字符串作为key放入。所以必须重写equals()方法,确保相同内容的key调用equals()后返回true。

LinkedHashMap

在HashMap的基础上新增了一条双向链表,用于维护元素的插入顺序或访问顺序。本质是修改了Entry的结构,新增了before和after引用分别指向前后结点,使得原来HashMap的结点同时也成为双向链表中的结点。当插入元素时除了放入table中的桶中还会挂到链表的尾部,此时如果整个LinkedHashMap大小达到某个值则直接删除链表首元素即可;当访问元素时会将该元素对应的Entry先删除后添加到双向链表的尾部。LinkedHashMap默认使用的是元素的插入顺序且没有大小限制,若要实现真正的LRU缓存结构,则需要调用三个参数的构造函数设置accessOrder为true,同时重写removeEldestEntry方法判断集合大小返回true或false。

TreeMap

底层使用红黑树来存储,维护了key的大小顺序,不允许key值为null。红黑树是平衡二叉树的减弱版,平衡二叉树要求任意结点的左右两棵子树的高度不超过1,故插入和删除时需要大量的旋转操作,但这是十分耗时的。红黑树在最差情况下允许从根结点到叶子结点的最长路径是最短路径的两倍,减少了旋转操作,仅保证整颗树大致是平衡的,红黑树的增删查的复杂度均为logn。

多线程下HashMap不安全性的体现

  • 更新丢失,两个线程同时put发生hash冲突的元素,则可能会有元素被覆盖,或者扩容时也可能有线程操作的结果被覆盖。
  • 死循环,jdk1.8版本以前put元素采用头插法,1.8及以后采用尾插法。采用头插法时,当两个线程同时执行扩容操作时,可能会导致某个结点的next又指向前面的结点,形成死循环。

JAVA线程

进程是计算机分配资源的最小单位,线程是CPU调度的最小单位。

创建线程的方法

  • 继承Thread类(Thread类本身就实现了Runnable接口),重写run()方法,调用start()方法。
  • 实现Runnable接口,重写run()方法,丢入Thread的构造函数,调用start()方法。
  • 实现Callable接口,重写call()方法,丢入线程池的submit()方法提交,结果用FutureTask接收,调用get()获取执行结果,这是一个异步的过程,在下面的例子中当主线程调用get()方法时如果线程还没有执行完则会阻塞,也可以在get()传时间参数实现超时阻塞。
public static void main(String[] args) throws Exception{
    ExecutorService pool = Executors.newFixedThreadPool(4);
    Future<String> result = pool.submit(new Callable<String>() {
        @Override
        public String call() throws Exception {
            return "123";
        }
    });
    System.out.println(result.get());	//123
}

线程常用方法

  • sleep(),放弃当前CPU执行权且指定时间内不参与线程调度,但不释放锁。
  • yield(),放弃当前CPU执行权但仍然参与线程调度,同样不释放锁。
  • wait(),释放锁并等待其他线程唤醒自己。
  • notify(),随机将一个等待队列中的线程(处于WAITING的线程)移动到同步队列中(处于BLOCKED的线程),处于同步队列中的线程才有资格去争抢锁。
  • notifyAll(),将所有等待队列中的线程移动到同步队列中,与notify()一样都不会立即释放锁,而是等到同步块结束后再释放。这意味着调用notify()或notifyAll()后,wait()阻塞的线程并不会立即唤醒,而是移动到同步队列等待锁释放后去争抢,抢到了才会真正被唤醒。
  • join(),比如在A线程中调用B.join()的意思是A线程被阻塞直到B线程执行结束,但不影响其他线程。
  • interrupt(),设置线程的中断标识为true,当线程中断标识为true时如果被阻塞(wait、join、sleep)则抛出InterruptedException,从而提前中断线程。如果线程因为锁竞争被阻塞或者拿到锁正常执行的话,是不会主动处理中断的,除非自己调用isInterrupted()或者interrupted()方法主动去判断中断标识并处理。

线程状态

  • 初始状态。创建完线程,还未执行start方法。
  • 就绪状态。满足运行条件,等待线程调度,运行态线程调用yield()进入就绪态。
  • 运行状态。被调度选中,开始执行run方法。
  • 阻塞状态。未获得锁,进入同步队列竞争。
  • 等待状态。需等待其他线程的某个动作,运行态线程调用wait(),join()进入等待状态。
  • 超时等待状态。过了一定等待时间再执行(恢复到就绪状态),运行态线程调用sleep,wait(time),join(time)进入超时等待状态。
  • 终止状态。run方法执行完,对象还未销毁。

其中,java将就绪状态与运行状态统称为运行状态。

守护线程

JAVA线程主要分两类,守护线程与用户线程,任意一个守护线程都服务于所有用户线程,当程序中不存在用户线程时程序就结束了。JVM比较常见的守护线程有垃圾回收线程,同时用户也可以自己设置守护线程,通过threadA.setDaemon(true)实现,但必须在start()方法前执行,否则报异常。

JAVA实现线程安全的方式

  • 阻塞同步,即给对象加锁,有synchronized关键字和Lock接口,是一种悲观锁的实现。
  • 非阻塞同步,使用JUC下面的相关原子类提供的操作,使用CAS(compare and swap)指令实现,是一种乐观锁的实现。CAS指令需要三个操作数:内存地址V,旧的预期值A,准备设置的新值B,当执行CAS指令时,判断A是否与V中的内容一致,是则更新,否则不更新,但无论是否更新都会返回V的旧值。CAS可能存在的ABA问题,即V中的内容从A变为B又变为A,那么CAS认为V是没有变过的,可以为V添加版本号来解决ABA问题。

可重入锁

当线程已经获取某个锁对象,当再次申请获取该锁对象时会将锁计数+1而不会出现死锁现象,这种情况称为可重入锁。synchronized和ReentrantLock都属于可重入锁,但ReentrantLock实现可重入时需要保证释放锁次数和获取锁次数一样。ReentrantLock更广泛的使用方式如下:

  • 可以调用tryLock()来尝试获取锁(可传参数等待一段时间),当获取失败时可以执行其他的工作。
  • 可以调用lockInterruptibly()使等待获取锁的线程响应中断,而传统的interrupt()只有被wait、sleep、join阻塞的线程才会响应中断。
  • 可以绑定多个Condition对象,调用await()和signal()可以实现多个条件的关联(synchronized中的wait和notify只能实现一个条件,多于一个则需要额外的锁)。
  • 可以实现公平锁,即当多个线程等同一个锁时,必须按照申请锁的时间顺序依次获得。默认的ReentrantLock以及synchronized都是非公平锁。

自旋锁

因为java中线程间的切换需要切换到内核态执行,消耗较大,此时如果计算机有多个处理器或者多个处理核心时(允许多个线程同时并行),可以让后请求锁的线程等待一段时间(执行忙循环即空循环),但不放弃处理器的执行时间,这种技术称为自旋锁。一般的自旋锁实现就是CAS机制,也称无锁、乐观锁或轻量级锁,伪代码如下:

while(true) {
    int oldValue = 获取当前最新值;
    int newValue = 对oldValue执行运算;
    //设置成功
    if(compareAndSwap(oldValue, newValue)) {
        break;
    }
}

分段CAS,当多个线程对某个共享变量操作时普通CAS会出现比较多的线程一直在空转,分段CAS新建了一个Cell数组,将Base基础值拷贝放入数组中,多个线程操作不同的Cell,最后求和返回。Java中有LongAdder提供相关API,Cell数组的长度也会随线程数量进行动态的扩容或缩容。

偏向锁

用于优化当没有实际锁竞争且一直是一个线程使用锁的情况。线程第一次获取锁时使用CAS指令将其ID记录在对象头的Mark Word中,当需要再次获取锁时直接看对象头的Mark Word中存储的线程ID是否是自己即可。此时若有其他线程尝试获取锁则会CAS失败,则根据目前锁是否被持有来将偏向锁指向新的线程或者直接膨胀为轻量级锁。直接修改Mark Word而不拷贝的后果是会丢失对象的hashcode,故当一个对象调用过hashcode()后,则永远无法再进入偏向状态。

轻量级锁

用于优化短时间内双线程进行锁竞争的情况。线程获取锁时先将对象头中的Mark Word部分(包括锁状态、hashcode、gc年龄和标志)拷贝到线程私有的栈中(称为锁记录),然后使用CAS指令尝试将Mark Word修改为指向自己的存储的锁记录,若成功则进入同步代码块,失败则说明有其他线程正持有该锁,此时进入自旋状态尝试获取锁,自旋期间如果其他线程释放了锁(也是CAS指令)则获取即可,如果自旋次数超过阈值则锁膨胀为重量级锁(阈值不断变化的技术称为自适应自旋)。

重量级锁

早期的synchronized实现就只有重量级锁这一种方式,适用于竞争激烈的情况,本质是操作系统级别的互斥量(mutex),通过向字节码指令中插入monitorenter和monitorexit指令实现同步。java中每个对象都对应的有一个monitor对象,也称为监视器对象,当线程执行monitorenter指令时会尝试去获取对应的monitor对象,如果该线程就是monitor对象的拥有者则计数器count加1,否则进入同步队列等待锁释放。当执行monitorexit指令时,此时执行的线程必须是monitor对象的拥有者,否则报IllegalMonitorStateException异常,如果是则将计数器count减1,当count为0时唤醒同步队列中的其他线程去竞争获取锁。由于借助了操作系统的互斥量,故当synchronized膨胀为重量级锁后,只要尝试获取锁就会由用户态切换为内核态,开销会增大。

抽象同步队列(AbstractQueuedSynchronizer,AQS)

AQS是一种提供了原子式管理同步状态、阻塞和唤醒线程功能以及队列模型的简单框架。主要思想是给共享资源设置一个同步变量state(0表示空闲,1表示占用),当线程发起请求时如果资源空闲则直接获取,使用CAS修改state值并记录当前占有资源的线程,如果资源被占用则将线程封装为Node对象添加到同步队列中阻塞(park)。JUC下聚合了AQS的常用同步器有ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch等。

ReentrantLock

支持可重入锁,可中断的获取锁,超时获取锁以及多个条件等待队列,自己分析的ReentrantLock相关API详细流程:https://blog.csdn.net/qq_40246487/article/details/121041130,需要注意的有:

  • 当并发线程都是交替执行时根本不会涉及到队列的初始化和使用,只是交替修改state值。
  • 同步队列初始化后的头结点对应的线程永远是null。
  • 当一个线程尝试CAS设置state失败后,调用tryAcquire()再次尝试CAS修改state,这里涉及到ReentrantLock的两个重要特性:1. 公平锁遇到state是自由的时候会先判断自己是否需要排队然后再去CAS,而非公平锁则是直接CAS尝试修改;2. 可重入性,当线程CAS修改时state时还会判断当前线程是否是获取锁的线程,是则state+1,否则尝试失败。
  • 当线程获取锁失败入队后并不一定直接阻塞!!!当线程对应结点的前驱结点的标识是SIGNAL时才会调用LockSupport的park()方法阻塞,而一个结点标识能被修改为SIGNAL的位置在shouldParkAfterFailedAcquire()中,该方法会把参数结点从当前位置往前挂到第一个标识为SIGNAL的结点的后面,中间的CANCELED结点会被移除,若没有遇到SIGNAL结点则修改前驱结点标识为SIGNAL,当第二次自旋时如果还没获取到锁才会被park()阻塞(因为此时已经有了SIGNAL前驱结点)。
  • 当线程从park()阻塞状态恢复时,可能有三种情况:1. 其他线程调用了unpark();2. 线程被中断;3. 阻塞超时。
  • 不管是哪种方式去获取锁,底层需要阻塞线程时使用的都是park()方法或者parkNanos()方法,它们都是会响应中断的。这就意味着即使是普通的lock()方法获取锁,当线程A因为拿不到锁被阻塞时,此时线程B调用A.interrupt()那么A也会被唤醒,但是此时并没有对A进行中断处理(比如抛异常等),而是清除A的中断标识并记录然后继续让A去尝试并争抢锁,当A拿到锁返回时再去中断A。而可中断获取锁超时获取锁都是在中断处理时抛出了中断异常,故它俩都属于可中断获取锁的类型。

CountDownLatch

普通线程join()方法的升级版,用于实现批量等待,构造函数中传入数量N,调用countDown()方法每次让N减一,调用await()方法让当前线程阻塞直到N变为0(不会小于0),这个N可以理解为是多个线程执行完毕也可以是一个线程内的多个操作,且与join()一样也提供有超时阻塞的方法。

CyclicBarrier

可循环使用的屏障,构造函数中传入数量N,只有当N个线程都调用了await()方法被阻塞后(即都到达屏障后)才会被一起放行。功能上与CountDownLatch类似,但功能更强,构造函数可以传入第二参数(Runnable接口)让被阻塞的线程去执行其他内容,且CyclicBarrier提供了重置N的方法、获取非阻塞线程数量方法等。

Semaphore

信号量,与操作系统的PV操作类似,用于控制访问共享资源的最大并发线程数,构造函数中传入数量N表示许可证的数量,acquire()获取许可证,release()释放许可证。

JAVA安全容器

CopyOnWriteArrayList

JUC包下的线程安全的ArrayList变体,对增加、修改、删除操作使用ReentrantLock对象上锁,确保并发下的安全性(读操作无需加锁)。并在执行增删改操作时,会先对原数组拷贝出一个新的数组,在新数组中操作,然后赋值给原数组引用。这样做的好处是读写冲突时不用加锁就能解决同步问题。CopyOnWriteArrayList里面的数组使用volatile关键字修辞,保证了读线程读数据的实时性。

ConcurrentHashMap

JUC包下的线程安全的HashMap,相似的线程安全的HashMap还有HashTable、Collections.synchronizedMap(map),但这二者都是通过在读写操作时对整个对象上锁来实现线程安全,故效率较低。ConcurrentHashMap通过对读操作不加锁,写操作使用锁分段技术实现高效读写,且不允许键或者值为null。ConcurrentHashMap里存放的是一个Segment数组,每个Segment又是一个桶数组,同时Segment又继承了ReentrantLock类,故可以直接用来对该片段加锁。好处是当写操作发生在一个Segment中时,锁的是这个片段,不会影响其他片段的写操作。同时对Node中的val、next和其他可能读取的数据加入volatile关键字,保证读线程对该变量的内存可见性,故读操作无需加锁,若读到值为null,则认为是指令重排序导致,会再次加锁重读。执行size()方法时先尝试两次不加锁对所有Segment的大小求和,如果不一致再加锁求和。扩容时是对Segment内部的数组进行扩容。

上面介绍的均属于jdk1.7的内容,在jdk1.8中ConcurrentHashMap与HashMap一起进行了升级,下面仅列出ConcurrentHashMap的部分:

  • 移除了Segment概念,底层与HashMap一样就是一个数组,通过给每个链表或红黑树的首个节点加锁实现线程安全。
  • 使用Sychronized和CAS操作实现线程安全,使用CAS的目的是为了减少加锁的开销。

ConcurrentLinkedQueue

JUC包下的线程安全的非阻塞队列,通过CAS加自旋实现元素的安全入队和出队,但又不是简单的死循环通过CAS设置头结点或者尾结点。在ConcurrentLinkedQueue中,头结点不总是头结点,尾结点不总是尾结点,tail结点和真正的尾结点之间可能有hops-1个结点,设置这个距离hops的好处是避免了高并发下多线程对volatile变量tail的反复修改,可以理解为延迟了hops个结点后真正修改tail结点,显然坏处是每次新增结点都要遍历0到hops次找到真正的尾结点,但即使是这样也比多线程反复修改tail的效率要高,这是因为volatile变量的读写特性导致。

阻塞队列

jdk提供了很多形式的阻塞队列:包括数组形式、链表形式的FIFO队列,优先级队列,延时队列,双端队列等,大多是通过可重入锁ReentrantLock实现阻塞。各种阻塞队列都提供了几组不同的添加/获取元素的方法:

  • 抛出异常,包括add、remove、element。
  • 返回特殊值,包括offer、poll、peek,注意返回值并不能当做是否操作成功的标准!
  • 阻塞,包括put、take。
  • 超时阻塞,包括offer、poll。

JAVA内存模型中的8种交互操作

  • lock,用于主内存变量,将其标识为某线程独占。
  • unlock,用于主内存变量,将其解锁。
  • read,用于主内存变量,将其传输到线程工作内存中。
  • load,用于工作内存变量,将read操作的变量保存到工作内存的副本中。
  • write,用于工作内存变量,将其传输到主内存中。
  • store,用于用于主内存变量,将write操作的变量保存到主内存变量中。
  • assign,用于工作内存变量,将执行引擎的值赋给工作内存变量。
  • use,用于工作内存变量,将工作内存变量传递给执行引擎。

普通变量的规则有:use前必须先load,store前必须assign,但不一定是连续紧跟的。而volatile的变量的特殊规则有:use前必须是load,assign后必须是store,故保证了变量可见性。

happens-before规则

为了让程序员不用去理解复杂的内存交互操作规则的语义,JMM定义了一些更容易理解的规则称为happens-before规则,JMM保证如果一个操作happens-before于另外一个操作,那么第一个操作一定在第二个操作前执行(具体实现时可以允许不影响结果的重排序)且第一个操作的结果对于第二个操作时可见的。总的来说:as-if-serial语义保证单线程内程序执行结果不变,而happens-before关系保证多线程程序执行结果不变。JMM定义的happens-before规则有如下几点:

  • 一个线程中的每个操作都happens-before于该线程中的任意后续操作。
  • 对一个锁的解锁happens-before于随后对这个锁的加锁。
  • 对volatile域的写操作happens-before于后续对volatile域的读操作。
  • 如果A操作happens-before于B操作,B操作happens-before于C操作,那么A操作happens-before于C操作。
  • 如果线程A执行线程B.start()操作,那么该操作happens-before于线程B中的任何操作。
  • 如果线程A执行线程B.join()操作,那么线程B中的所有操作happens-before于B.join()操作的成功返回。

JAVA线程池

JUC包下提供的线程池构造函数代码如下:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)

corePoolSize:核心线程数;maximumPoolSize:线程池大小;keepAliveTime:非核心线程存活时间;unit:时间单位;workQueue:等待队列(可限制大小);threadFactory:线程工厂;handler:拒绝策略。

工作原理

新增任务时按照以下顺序依次判断:

  • 若核心线程数还未达到上限,则新建核心线程去执行任务。核心线程运行完后不会销毁,会一直死循环尝试从阻塞队列中获取任务。注意此处即使存在空闲的核心线程也会优先创建线程执行任务
  • 若核心线程数达到上限,等待队列还有空位,则将新增任务插入等待队列。
  • 若等待队列已满,则新建非核心线程去执行新增任务,非核心线程的最大数量为线程池大小减去核心线程数。非核心线程运行完后如果没有等待队列中的任务继续执行,则会在参数规定的存活时间后销毁。
  • 若非核心线程数达到上限且都处于运行状态,此时执行拒绝策略。

拒绝策略

java线程池的拒绝策略有如下四种:

new ThreadPoolExecutor.AbortPolicy()          //丢弃新来的任务,抛异常,默认策略
new ThreadPoolExecutor.CallerRunsPolicy()     //回到调用者线程执行该任务
new ThreadPoolExecutor.DiscardOldestPolicy()  //丢弃队列最前的任务,不抛异常
new ThreadPoolExecutor.DiscardPolicy()        //丢弃新来的任务,不抛异常

提交顺序与执行顺序

任务的提交与执行并不一定按照先后顺序。假设核心线程数、非核心线程数、等待队列的大小限制均为10,此时有1-30号任务提交(假设很耗时,即暂时不会出现线程复用)。前面的1-10号任务会创建核心线程执行,11-20任务会进入等待队列,但21-30号任务会直接创建非核心线程执行,当有核心线程或非核心线程运行结束后才会去等待队列中取任务执行。这样可以省去任务入队出队的时间。

线程池的状态

  • RUNNING,线程池创建之后就处于该状态,能正常接收任务并处理。
  • SHUTDOWN,调用shutdown()方法后进入该状态,不再接收新任务,但会继续处理已经接收的任务。
  • STOP,调用shutdownNow()方法后进入该状态,不再接收新任务,不再处理已接收的任务,同时中断执行中的任务。
  • TIDYING,由SHUTDOWN和STOP转移而来,当线程数为0时进入该状态。进入该状态后会执行钩子函数terminated(),可在定义线程池时重写该方法。
  • TERMINATED,terminated()执行结束后进入该状态,表示线程池已被销毁。

注意点

  • 调用线程池的execute()只能提交实现了Runnable接口的任务,而submit()方法可以提交Runnable或Callable(重载实现)。
  • 当核心线程数量不满时,永远都是创建新的核心线程执行新提交的任务。
  • 当新增任务入队时,无论是execute()或submit(),最终调用的都是阻塞队列的offer()方法,该方法会立即返回true(成功)或false(队列已满),如果返回false则尝试创建非核心线程执行新增任务。
  • 核心线程与非核心线程在空闲时都会尝试从阻塞队列中获取任务,但核心线程调用的是阻塞队列的take()方法获取,而非核心线程调用的是阻塞队列的poll(timeout)方法获取。当阻塞队列为空时二者都会被阻塞,只不过非核心线程只会在一定时间内阻塞(如果超时仍未获取到则不再尝试,run()方法执行结束,线程结束),但核心线程会一直阻塞直到队列不为空

设计模式

单例模式

一个类保证自己只会创建一个对象,且可以让外界直接访问该对象,实现上主要有饿汉式、懒汉式(双重检查)、静态内部类和枚举:

  • 饿汉式,类加载时就创建对象并初始化。
  • 懒汉式,第一次被使用时再去创建对象,这里涉及到多线程的问题,一个解决方法是volatile修辞+双重检查锁。
  • 静态内部类,利用了类加载的机制,保证了线程安全,而且是懒加载。
  • 枚举,让JVM替我们实现最安全的单例模式,可以防止反序列化和反射攻击。

单例模式实现时需要注意的点:构造函数和成员实例变量必须是私有的,并暴露一个静态方法供外界获取单例对象,代码见volatile的禁止指令重排序小节。

工厂模式

将一种类型的对象(比如数据库连接有mysql、oracle等)的创建与使用分离开来,对调用方屏蔽对象创建细节。当一个对象非常大,创建过程很复杂时,工厂模式的优势就体现出来了:可以减少用户创建对象时可能出现的逻辑错误,当对象的创建过程发生变化时只需要修改工厂类中的代码即可。常见的工厂模式有三种:

  • 简单工厂模式,向工厂传递想要的对象类型进行获取,违反了开闭原则(对扩展开放,对修改关闭)。
interface Phone {
    void make();
}

class IPhone implements Phone {
    @Override
    public void make() {
        System.out.println("make iphone");
    }
}

class Redmi implements Phone {
    @Override
    public void make() {
        System.out.println("make redmi");
    }
}

class SimpleFactory {
    public static Phone getPhone(String type) {
        if(type.equals("IPhone"))
            return new IPhone();
        if(type.equals("Redmi"))
            return new Redmi();
        return null;
    }
}
  • 工厂方法模式,将生成具体产品的任务交给具体的产品工厂。
abstract class AbstractFactory {
    abstract Phone makePhone();
}

class IPhoneFactory extends AbstractFactory{
    @Override
    Phone makePhone() {
        return new IPhone();
    }
}

class RedmiFactory extends AbstractFactory{
    @Override
    Phone makePhone() {
        return new Redmi();
    }
}
  • 抽象工厂模式,工厂方法模式的升级版,支持新增产品族(我不理解)。

原型模式

通过拷贝实现快速创建重复对象,java中实现原型模式需要实现Cloneable接口(该接口告诉JVM该类的clone()方法可以安全执行,否则报异常)和重写Object中的clone()方法。

class Person implements Cloneable{
    public int age;
    public String name;
    public ArrayList<Integer> ids;
    public Person() {
        this.age = 18;
        this.name = new String("zhangsan");
        this.ids = new ArrayList<>();
    }
    @Override
    protected Object clone() throws CloneNotSupportedException {
        Person person = (Person) super.clone();
        person.ids = (ArrayList) this.ids.clone();
        return person;
    }
}
  • 浅拷贝,只拷贝引用实际指向同一块内存。Object的clone()方法对所有引用类型都是浅拷贝。
  • 深拷贝,新开辟内存存放相同的内容。Object的clone()方法对所有基本数据类型及其包装类都是深拷贝。

代理模式

创建具有现有对象的对象并对外暴露一样的接口,并可以在代理时进行方法增强,实现上主要有静态代理和动态代理:

  • 静态代理。由我们自己去定义一个代理类,去包含要被代理的类(以下称为目标类),并暴露和目标类一样的方法供外界调用,然后在这个方法里面调用目标类的具体方法,此时就可以在调用目标类的具体方法前后进行其他操作了。
  • 动态代理。由jvm在程序运行期动态去创建代理类,这样就解决了静态代理的致命缺点:当目标类新增了要被代理的方法时需要再次修改代理类的代码。比较知名的动态代理实现主要有jdk与cglib两种,其中,jdk实现的动态代理要求目标类必须实现了某个接口才行,而cglib主要基于继承来实现动态代理故没有此约束。

使用jdk动态代理的代码是固定的,主要是理解其原理,代码如下:

public class MyInvocationHandler implements InvocationHandler {
    private Object target;
    public MyInvocationHandler(Object target) {
        this.target = target;
    }
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("before");
        Object obj = method.invoke(target,args);
        System.out.println("after");
        return obj;
    }
}
public class proxyTest {
    public static void main(String[] args) {
        Service s = new ServiceImpl();
        MyInvocationHandler handler = new MyInvocationHandler(s);
        Service proxy = (Service)Proxy.newProxyInstance(s.getClass().getClassLoader(),s.getClass().getInterfaces(),handler);
        int res = proxy.add(1,2);
        //int res = proxy.sub(1,2);
        System.out.println(res);
        return;
    }
}

interface Service {
    int add(int a,int b);
    int sub(int a,int b);
}

class ServiceImpl implements Service {
    @Override
    public int add(int a, int b) {
        System.out.println("执行了加方法");
        return a + b;
    }
    @Override
    public int sub(int a, int b) {
        System.out.println("执行了减方法");
        return a - b;
    }
}

jdk自动生成的代理类与我们的目标类实现了同样的接口,并继承了Proxy类。这个代理类中实现了接口中所有的方法,方法里面执行h.invoke()方法,h对象继承自Proxy类,实际是我们自定义的MyInvocationHandler对象。当我们通过代理对象调用add或sub方法时,就会执行代理类中的同名方法,然后调用h.invoke()从而进入我们定义的MyInvocationHandler中的invoke方法(增强的内容都写在这里)。

装饰者模式

动态给一个对象添加一些额外的职责,当某个类需要扩展的功能非常多时,如果使用继承去挨个扩展会使系统变得特别复杂,此时可以使用装饰者模式实现对某个类的动态扩展,同时遵循开闭原则。实现时新建一个抽象装饰者类,和被装饰类继承同样的超类,内部组合一个被装饰类对象,然后创建具体的装饰者类继承抽象装饰者类,同时重写顶层超类中的方法进行扩展。装饰者模式下新增菜品(被装饰类)和新增调料(装饰者类)都不用去修改原有代码,直接扩展即可。

public class Decorator {
    public static void main(String[] args) {
        Food food = new Salt(new Sugar(new Qjrs()));
        System.out.println(food.price());		//16.8 + 0.5 + 0.4 = 17.7
        System.out.println(food.description());	//番茄鸡蛋,加糖,加盐
    }
}

interface Food {
    double price();
    String description();
}

class Qjrs implements Food {
    @Override
    public double price() {
        return 19.8;
    }
    @Override
    public String description() {
        return "青椒肉丝";
    }
}

abstract class TiaoLiao implements Food {
    Food food;
    public TiaoLiao(Food food) {
        this.food = food;
    }
    @Override
    public double price() {
        return food.price();
    }
    @Override
    public String description() {
        return food.description();
    }
}

class Sugar extends TiaoLiao {
    public Sugar(Food food) {
        super(food);
    }

    @Override
    public double price() {
        return 0.5 + super.price();
    }

    @Override
    public String description() {
        return super.description() + ",加糖";
    }
}

策略模式

使算法和对象分开来,使得算法可以独立于使用它的客户,让客户根据环境或者条件的不同选择不同的算法或者策略来完成该功能,当可选择算法或实现特别多时使用策略模式可以有效分离代码,且满足开闭原则。

public class Strategy {
    public static void main(String[] args) {
        Algorithm algorithm = new Algorithm(new QuickSort());
        algorithm.sort();   //快速排序
        algorithm.setSort(new InsertSort());
        algorithm.sort();   //插入排序
    }
}

interface Sort {
    void sort();
}

class Algorithm {
    private Sort sort;
    public Algorithm(Sort sort) {
        this.sort = sort;
    }
    //方便以后切换
    public void setSort(Sort sort) {
        this.sort = sort;
    }
    public void sort() {
        sort.sort();
    }
}

class QuickSort implements Sort {
    @Override
    public void sort() {
        System.out.println("快速排序");
    }
}

class InsertSort implements Sort {
    @Override
    public void sort() {
        System.out.println("插入排序");
    }
}

java异常

结构图如下:

|---- Throwable类,所有异常的祖宗

​ |---- Error类,编译期或系统错误

​ |---- Exception类,一般由逻辑错误或程序不严谨造成

​ |---- 受检查异常,必须捕获或抛出

​ |---- 非受检查异常,可捕获也可不捕获

  • Error指编译期或系统错误,无法也不应被捕获,常见的如OOM(内存溢出)、栈溢出(如没有出口的递归...)。
  • 受检查异常指为了保证程序的健壮性,可以提前预料到的异常(就是写代码时突然报红提示你必须处理的那些),必须捕获,如经典的ClassNotFoundException、FileNotFoundException等。
  • 非受检查异常指编译时无法预知的错误(程序跑起来才知道会不会错),这也是我们程序有bug时最常见的异常,常见的如ArithmeticException、ArrayIndexOutOfBoundsException、IllegalArgumentException、NullPointerException等。

注:RuntimeException及其子类都是非受检查异常,其他的异常都是受检查异常。

异常的处理

  • 捕获,try+catch+finally。try中包含可能有异常的代码块,如无异常则正常执行到结束,如果出现异常则跳转到catch中执行,但退出代码块前无论如何都会执行finally中的代码块。
  • 抛出,throw,throws。抛出时按方法调用顺序依次往外抛,throw用于方法内部手动抛出异常,如throw new RuntimeException(“xxx");throws用于方法外部,写在方法名的最后面即”{“之前,抛出方法中可能出现的异常。

try、catch和finally的执行顺序

当执行到try或catch中的return语句后(如果后面跟的是函数调用则先执行该调用)跳转到finally中执行代码块,如果finally中有return则直接返回,否则最后仍然回到try或catch中返回。如果在finally中没有return但修改了返回值,此时返回值如果是基本类型则会影响返回结果;返回值如果是引用类型则分两种情况:1. 修改引用指向不会影响返回结果;2. 修改引用类型内部的成员属性则会影响返回结果。

java运行时数据区的内存结构

主要分为5大块,如下所示:

  • 堆区。线程共享,所有new出来的对象都放在这里,故大小占比最大,也是垃圾回收的重点区域。堆内部又细分为新生代、老年代,新生代:老年代=1:2。其中,新生代又分为一个伊甸园区和两个幸存者区(8:1:1),主要是为了更方便实现垃圾回收(这里采用复制算法,原因是新生代中的对象大多属于朝生夕死类型)。可以通过JVM的-Xms参数设置堆内存初始大小,-Xmx参数设置堆内存最大大小,一般将二者设置为相同大小, 以此避免每次垃圾回收的堆内存重新分配,例-Xms600m -Xmx600m。
  • java栈(也叫虚拟机栈)。线程私有,栈中存放的是一个个栈帧,对应程序执行时的一个个方法。每个栈帧中包括局部变量表、操作数栈、动态链接、方法返回地址。动态链接的目的是在程序运行时将方法中对运行时常量池的符号引用转化为直接引用。相对应的静态链接指方法中对static、final等成员变量或方法的引用在类加载或第一次使用的时候就可以确定,无需在每一次运行时再转化。可以通过JVM的-Xss参数设置java栈初始大小。
  • 方法区。线程共享,方法区主要存放类信息、方法信息、属性信息以及运行时常量池(每个类都有),前三项统称为类元信息。在字节码文件中就存在常量池的内容,当对应的类被加载进jvm的方法区后就称为运行时常量池。常量池中存放的是字面量(数字和字符串)、类引用、属性引用、方法引用。方法区在jdk8以前的实现称为永久代,jdk8及以后称为元空间。永久代期间方法区仍然占jvm内存, 但元空间期间将静态变量和运行时常量池中的字符串常量池移到了堆空间(和类的Class实例放在一起),此时方法区独立在jvm内存之外占的是本地内存。
  • 程序计数器。线程私有,指向当前线程执行的字节码行号。
  • 本地方法栈。线程私有,类似java栈,不过里面的内容是一个个本地方法(由c/c++编写的)。

注:所有线程私有的部分都不需要垃圾回收,因为会随着线程销毁而销毁。

垃圾回收算法

  • 标记-清除算法。分为两个过程,先标记后清除。标记的算法又有引用计数法和可达性分析法,但引用计数法存在循环引用问题,可能会造成内存泄露,故一般的都是用可达性分析法。可达性分析法指从根节点GCRoots向下遍历,将可以到达的地方标记(即标记的是非垃圾对象),然后清除时线性遍历所有非空闲堆空间回收掉那些没有标记的地方。显然标记-清除算法效率不高(因为要遍历...)且会造成内存碎片(即空闲内存不连续),故还需要维护空闲链表来记录哪些地方是空闲的从而更好的进行内存分配。
  • 标记-复制算法,通常简称复制算法。将内存分为相同大小的两大块,并保持总有一块是空的。回收时,直接从GCRoots向下遍历,将所有可达的对象直接复制到另一块空的内存,然后清除这一块的所有内存。显然这样的效率很高且不会造成内存碎片,但缺点是需要浪费很多空间,因为始终要保持一块是空的。jvm对堆中新生代的MinorGC就是复制算法的改进版,始终有一块幸存者区是空的,回收时将伊甸园区和非空的幸存者区的所有存活对象一起复制到空的幸存者区中,并将原来的地方清除。
  • 标记-整理算法。与标记-清除算法类似,第一阶段也是使用可达性分析进行标记,第二阶段是将所有标记的存活对象压缩到内存的一边,从而清除了内存碎片,但效率会更低。jvm对老年代的MajorGC就是标记-整理算法,因为老年代存放的是存活久的和大的对象,且回收频率低,所以不能用复制算法(频率低会造成浪费)和标记-清除算法(内存碎片会导致老年代更不容易存放大的对象)。

注:GCRoots包括虚拟机栈中引用的对象、本地方法栈中引用的对象、方法区中静态成员变量引用的对象以及字符串常量池中引用的对象、被作为锁持有的对象等。

四种类型的引用

  • 强引用,最常见的引用方式,new出来的对象以及引用赋值都属于强引用,只要存在强引用对象就不会被回收。
  • 软引用,通过new SoftReference(new Object())创建,当JVM认为内存紧张时才会被回收(即一次垃圾回收后仍然内存不足),从而有机会避免发生OOM,这种特性使得软引用适合用作缓存。
  • 弱引用,通过new WeakReference(new Object())创建,遇见即回收。
  • 虚引用,通过new PhantomReference(new Object(), new ReferenceQueue<>())创建,不影响对象的回收,必须传递一个队列进行初始化(不然虚引用就没有任何意义了),当对象被回收时该虚引用会被放进参数队列中,从而可以实现对象生命周期的跟踪。

软引用和弱引用也可以选择性传队列参数进行记录,且都可以通过get()去获取实际对象,但虚引用不能获取到实际对象。

垃圾收集器

垃圾收集器名称 作用区域 回收算法 备注
Serial收集器 新生代 复制算法 jdk1.3之前的唯一选择,单线程,需要STW(暂停用户线程)
ParNew收集器 新生代 复制算法 Serial收集器的并发版本,垃圾收集线程在STW期间并发执行
Parallel Scavenge收集器 新生代 复制算法 并发执行,追求的是吞吐量,提供两个参数控制最大STW时间与期望吞吐量
Serial Old收集器 老年代 标记-整理算法 可与Serial和Parallel Scavenge搭配使用
Parallel Old收集器 老年代 标记-整理算法 并发执行,Parallel Scavenge收集器的老年代版本
CMS收集器 老年代 标记-清除算法 追求的是尽可能短的STW时间,用户线程可与垃圾回收线程并发执行
Garbage First收集器 整个堆区 标记-整理算法 使用分区收集理论

注:对新生代进行垃圾回收称为Minor GC,对老年代进行垃圾回收称为Major GC(仅CMS属于这种类型),对整个堆区进行垃圾回收称为Full GC。运行在客户端的程序一般追求的是响应速度,故STW时间越短越好;运行在服务端的程序一般追求的是高效利用处理器资源,故吞吐量越大越好。

CMS收集器

全称concurrent mark sweep,是一款专注于减少STW时间的收集器,采用的标记-清除算法相比于标准的标记-清除算法更复杂,包括:

  1. 初始标记,需要STW,但仅需要标记GC Roots直接关联的对象,故STW时间很短;
  2. 并发标记,与用户线程并发执行,从GC Roots直接关联的对象遍历整个对象图,故耗时较长但不需要STW;
  3. 重新标记,需要STW,修正并发标记阶段中因用户线程操作导致标记发生变化的对象;
  4. 并发清除,与用户线程并发执行,清除所有已标记的死亡对象。

缺点:1、对处理器资源敏感;2、无法处理“浮动垃圾”,即在并发标记过程中由用户线程新产生的垃圾;3、存在内存碎片。

G1收集器

面向整个java堆的收集器,将整个堆内存划分为一个个大小相等且连续的region,每个region扮演不同的角色,如伊甸园区,幸存者区,老年代等,且角色是可以切换的。每次垃圾收集不再面向固定的新生代或者老年代,而是基于哪块内存垃圾最多且回收效率最大来考虑。整体上看使用的是标记-整理算法,局部上看使用的是复制算法,具体步骤包括:1、初始标记;2、并发标记;3、最终标记;4、筛选回收,将决定回收的region中的存活对象复制到空的region中,再清空旧的region。其中,步骤2可与用户线程并发执行,步骤1、3、4需要STW,而步骤1是单线程,3和4是并发执行。

不同版本jdk的默认垃圾收集器组合

  • jdk7,jdk8使用Parallel Scavenge + Parallel Old。
  • jdk9使用G1收集器。

Minor GC时使对象从新生代进入老年代的几种情况

  • 对象太大,幸存者区剩余容量不够(Minor GC后仍不够)。
  • 对象在幸存者区的GC年龄大于15(默认值为15,可设置)。
  • 幸存者区中小于等于某个年龄的对象占的内存总量超过幸存者区的一半空间,则将大于该年龄的对象移入老年代。

java注解

官方提供的两个最重要也是最常用的元注解(可以用于其他注解的定义之上):

  • @Target。标记该注解可以在哪里使用,如类(包括接口)、方法、属性等。
  • @Retention。标记该注解的存活时间,包括源码期、字节码期、运行期。

单看注解的话会发现它啥用没有,就算在运行期仍然存在也没啥意义,但是加上反射机制就大不相同了。通过反射,我们可以获取到Class、Field、Method对象等,它们都是java反射包下的类且都提供了一系列与注解相关的API,如获取元素上的所有注解、获取指定注解、判断某注解是否存在等。当我们获取到注解对象后(注解实际也是个类),就可以进一步获取注解内部的具体内容,也就是打注解时括号里面的东西,然后就可以做很多事情了,如Sql语句拼接、生成单例对象等。简单的自定义注解如下:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Table {

    String value() default "";

}

java网络编程BIO与NIO

  • BIO即blocking IO,顾名思义是一种阻塞模型。当没有客户端连接时,服务端会一直阻塞,当有客户端新建连接时,服务端会新开一个线程去响应(不用多线程的话服务端同一时刻最多只能接收一个连接)。但不断的新开线程对服务器的压力是巨大的,为了缓解压力可以采用线程池技术实现线程复用,但这种做法治标不治本,本质还是一个连接一个线程。
  • NIO即non-blocking IO,顾名思义是一种非阻塞模型。目的是实现一个线程处理多个连接。三个核心概念:1. Channel,管道。Channel可以理解为连接,与BIO中Sokcet类似,一个连接对应一个Channel,但Channel中仍内置了一个Socket,可以调用socket()获取。2. Selector,选择器。Selector类似一个调度中心,所有Channel都需要注册到选择器中,并绑定一个SelectionKey,绑定时还会指定要监听的事件,如:连接就绪、读就绪、写就绪等。可以调用Selector提供的API实现对发生监听事件的连接进行处理。3. Buffer,缓冲区。Buffer底层是一个数组,供Channel实现对数据的读写。Buffer的position、limit、capacity分别指当前索引、读/写上限索引、数组容量。

详细代码及NIO实现多人聊天室见:https://blog.csdn.net/qq_40246487/article/details/120469073?spm=1001.2014.3001.5501

Netty模型理解

基本模型

Netty是对Reactor(分发者)模型的改进,底层仍是JAVA NIO。

  • 单Reactor单线程模型。由一个主Reactor线程负责所有操作,包含:监听连接请求和读就绪请求、建立新连接、读数据、业务处理、写数据等。上面的NIO多人聊天室代码就是这种模型。
  • 单Reactor多线程模型。将一般而言耗时最长的业务处理环节脱离出来,开子Worker线程去处理业务逻辑。原来的主线程仍然负责监听各种请求和读写数据,但将读取到的数据转发给子Worker线程去处理,业务处理完后返回结果给主线程再写回管道(客户端)。
  • 多Reactor多线程模型。进一步将读写数据脱离出来,主Reactor线程只负责监听连接请求,然后建立新连接交给子Reactor线程去监听读就绪请求,子Reactor线程读取数据并转发给子Worker线程去处理业务,业务处理完后返回结果给子Reactor线程再写回管道。

Netty模型主要包括一个BossEventLoopGroup和WorkerEventLoopGroup,二者本质都是NioEventLoopGroup。Group类似线程池,里面的EventLoop是一个个线程,每个EventLoop线程包含一个Selector,每个Selector包含多个SocketChannel(监听事件并处理)。一般BossEventLoopGroup大小为1,即一个线程负责监听连接请求,创建连接后丢给WorkerEventLoopGroup。WorkerEventLoopGroup会按顺序轮询将连接绑定到EventLoop线程的Selector中,每个SocketChannel对应一个Pipeline。Pipeline是一个双向链表(客户端流入服务端+服务端流出客户端),链表中每个元素是一个ChannelHandler(Netty提供或自定义),负责对数据流进行处理,包括编解码、序列化与反序列化、过滤以及业务处理等。自定义的ChannelHandler通常需要继承Netty提供的ChannelInboundHandlerAdaptor或者ChannelOutboundHandlerAdaptor,分别对应入站数据流(read)和出站数据流(write),通过重写特定方法以在不同时机处理数据。

异步任务提交

  • 当ChannelHandler中有耗时长的任务时,可异步提交到EventLoop的任务队列中,包括TaskQueue(普通任务)和ScheduledQueue(定时任务),但本质还是使用EventLoop对应的线程去执行,属于单线程。当处理耗时长的任务时,该线程不能再去处理绑定在该线程上的其他SockerChannel的读写事件了,这会导致Netty性能急剧下降,故一般不用这种方式。
  • 创建线程池EventExecutorGroup,将耗时长的任务丢给线程池处理,此时不再是使用EventLoop对应的线程。该线程池又可以绑定在ChannelHandlerContext中,或者ChannelHandler中。前者是Netty标准方式,但会将其对应的ChannelHandler都丢进线程池执行;而后者可以根据此时的数据是否需要执行耗时操作来判断是否丢进线程池,相对而言更加灵活。

解决粘包拆包问题

自定义协议(长度+内容)+ 编解码器。服务端每次仅读取协议规定的固定长度的字节,从而保证解决粘包拆包问题。