JAVA面试题

1、String、StringBuilder、StringBuffer区别

  • 可变不可变

  • String:是字符串常量,在修改时不会改变自身,若修改,等于重新生成新的字符串对象。

  • StringBuffer:在修改时会改变对象自身,每次操作都是对StringBuffer对象自身进行修改,不是生成新的对象。使用场景:用于对字符串经常改变的情况下。主要方法有: append(),insert(),delete(),replace(),reverse()等。

  • 线程是否安全

  • String:对象定义后不可变,线程安全。

  • StringBuffer:是线程安全的(对调用方法加入同步锁),执行效率较慢,适用于多线程下操作字符串缓冲区大量数据。

  • StringBuilder:是线程不安全的,适用于单线程下操作字符串缓冲区大量数据。

  • 共同点:

    • StringBuilder和StringBuffer有共同的父类AbstractStringBuilder(抽象类)。

    • StringBuilder和StringBuffer的方法都会调用AbstractStringBuilder中的公共方法,如super.append(…),只是StringBuffer会在方法上加上synchronized关键字进行同步。

2、内部类如何持有外部类对象

内部类通过this$0持有外部对象,如果有重名,那就会增加一个$号。

如果是静态内部类,则不会引用外部类对象,因为静态内部类只能访问静态变量,而静态变量可以直接访问。

内部类需要依赖外部类来初始化,静态内部类不需要依赖外部类。

内部类可以访问外部类的所有成员,静态内部类只能访问静态成员。

3、HashMap和HashTable区别

  • 继承父类不同
    • Hashtable是基于陈旧的Dictionary类
    • HashMap继承的抽象类AbstractMap
  • 线程安全不一样
    • Hashtable通过synchronized实现线程安全
    • HashMap不安全
  • 允不允许null值
    • Hashtable:key和value都不允许出现null值
    • HasMap:允许是null,null的key只有一个
  • 哈希值的使用不同
    • HashTable直接使用对象的hashCode。而HashMap重新计算hash值(右移16位异或)。
  • 内部实现方式的数组的初始大小和扩容的方式不一样
    • HashTable中的hash数组初始大小是11,增加的方式是 old*2+1。
    • HashMap中hash数组的默认大小是16,而且一定是2的指数。
  • 内部结构不同
    • HashTable不会树化

4、final和abstract不能一起使用

final详解

  • 被final修饰的类不能被继承。

  • final修饰的类方法不能被重写,子类只有调用final方法的权利,没有修改final方法 的权利

  • 被final修饰的类属性只可在初始化赋值,不可被重新赋值

abstract详解

  • 抽象类不能被实例化。抽象类可以包含属性;方法;构造方法,但是构造方法不能用来new实例,只能被子类调用

  • 有抽象方法的类,一定是抽象类,但是抽象类可以没有抽象方法。

  • 当一个类继承的父类是抽象类的话,需要我们把抽象类中的所有抽象方法全部实现,除非子类也为抽象类。

  • 抽象方法不能有方法体。

  • 抽象类不能用final声明,因为抽象类只有被继承才有存在的意义,final修饰的类不可以被继承

5、安全失败和快速失败

快速失败

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

在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器遍历下一个元素之前,都会检测modCount是否更改。

这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出

java.util包下的集合类都是快速失败的

安全失败

在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。

6、动态语言、静态语言;动态类型语言、静态类型语言

动态语言是指运行期间可以改变其结构的语言:例如增加或者删除函数、对象、甚至代码。比如JavaScript、Python等。静态语言与动态语言相反,在运行时不能改变其结构。

动态类型语言是指在运行期间才去做数据类型检查的语言。在用动态语言编程时,不用给变量指定数据类型,第一次赋值给变量时,在内部将数据类型记录下来。JavaScript、Python是典型的动态类型语言。静态类型语言与动态类型语言刚好相反,它的数据类型检查发生在在编译阶段,也就是说在写程序时要声明变量的数据类型。C/C++、C#、Java都是静态类型语言的典型代表。

7、异常

  • 所有异常类的父类为Throwable类(表示可抛出),直接继承为Error和Exception两个子类,Error也是异常的一种。
  • Error是程序无法处理的错误,它是由JVM产生和抛出的,比如OutOfMemoryError、ThreadDeath等。这些异常发生时,Java虚拟机(JVM)一般会选择线程终止。Exception是程序本身可以处理的异常,这种异常分两大类运行时异常和非运行时异常。程序中应当尽可能去处理这些异常。
  • RuntimeException(也称不检查异常):即可以编译通过,一般由程序的逻辑错误引起,开发过程中应尽量避免。例如:NullPointerException,IndexOutOfBoundsException等。自定义异常一般继承此类
  • RuntimeException以外的异常(IOException):编译器在编译阶段进行处理,程序必须处理此类异常否则无法通过编译。

8、jdk8新特性

  • 函数式接口

    如果一个接口中,只声明了一个抽象方法,则此接口就称为函数式接口。为了避免后来的人在这个接口中增加多个函数,我们可以在这个上面加上一个声明@FunctionalInterface检查是否是一个函数式接口。

  • Lambda表达式

    允许把函数作为一个方法的参数,所有Lambda的类型都是一个接口,Lambda本身即是一个函数式接口的实现。

  • 方法引用

    可以直接通过方法引用来简写Lambda表达式中已经存在的方法或者构造方法。

  • Stream API

    声明式的方式处理数据集合(通过查询语句来表达,而不是临时编写一个实现)。这有点像是我们操作数据库一样。

    • 内部迭代

      采用内部迭代,项目可以透明地并行处理,或者用优化的顺序进行处理,要是使用Java过去的外部迭代方法,这些优化都是很困难的。

    • 只能遍历一次

    • 方便地并行处理

  • 扩展注解的支持

    现在几乎可以为任何东西添加注解∶局部变量、泛型类、父类与接口的实现,就连方法的异常也能添加注解。

【JDK】常见类

Integer

IntegerCache

我们可以通过设置java.lang.Integer.IntegerCache.high来设置缓存最大值,最终取值为Maths.max(high, 127),即范围肯定会覆盖[-128,127]

private static class IntegerCache {
    static final int low = -128;
    static final int high;
    static final Integer cache[];

    static {
        int h = 127;
        //最大值可以配置
        String integerCacheHighPropValue = //-XX:AutoBoxCacheMax=<size>
            sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
        if (integerCacheHighPropValue != null) {
            try {
                int i = parseInt(integerCacheHighPropValue);
                i = Math.max(i, 127);
                // Maximum array size is Integer.MAX_VALUE
                h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
            } catch( NumberFormatException nfe) {
                // ignore
            }
        }
        high = h;

        cache = new Integer[(high - low) + 1];
        int j = low;
        for(int k = 0; k < cache.length; k++)
            cache[k] = new Integer(j++);

        // range [-128, 127] 肯定会被覆盖
        assert IntegerCache.high >= 127;
    }

    private IntegerCache() {}
}

发生自动装箱或者调用valueOf(int i)方法时,若int值在IntegerCache范围内,则会返回IntegerCache中的对象。

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

String

intern()方法

最初为空的字符串池 由String类私有维护。
调用intern方法时,如果池已经包含 通过equals(Object)方法确定 的字符串,则返回池中的字符串否则,将此String对象添加到池中,并返回对此String对象的引用
由此可见,对于任何两个字符串s和t , s.intern() == t.intern()是true当且仅当s.equals(t)是true 。
所有文字字符串字符串值常量表达式均已在字符串池中。

Class

获得class对象的三种方法

  • Java程序在运行时,Java运行时系统一直对所有的对象进行所谓的运行时类型标识。这项信息纪录了每个对象所属的类。虚拟机通常使用运行时类型信息选准正确方法去执行,用来保存这些类型信息的类是Class类。Class类封装一个对象和接口运行时的状态,当装载类时,Class类型的对象自动创建。
  • Class 没有公共构造方法。Class 对象是在加载类时由 Java 虚拟机以及通过调用类加载器中的 defineClass 方法自动构造的,因此不能显式地声明一个Class对象。
  • 虚拟机为每种类型管理一个独一无二的Class对象。也就是说,每个类(型)都有一个Class对象。
  • 基本的 Java 类型(boolean、byte、char、short、int、long、float 和 double)和关键字 void 也都对应一个 Class 对象。
  • 每个数组属于被映射为 Class 对象的一个类,所有具有相同元素类型和维数的数组都共享该 Class 对象。
  • 所有的类都是在对其第一次使用时,动态加载到JVM中的。当程序创建第一个对类的静态成员的引用时,就会加载这个类。这个证明构造器也是类的静态方法,即使在构造器之前并没有使用 static关键字。因此,使用new操作符创建类的新对象也会被当作对类的静态成员的引用。
  • 运行程序时,Java虚拟机(JVM)首先检查是否所要加载的类对应的Class对象是否已经加载。如果没有加载,JVM就会根据类名查找.class文件,并将其Class对象载入。
  • 一般某个类的Class对象被载入内存,它就用来创建这个类的所有对象。
  • Object类中的getClass()方法

    Bean b;
    ...
    Class c = b.getClass();
    
  • Class类的中静态forName()方法获得与字符串对应的Class对象

    Class c = Class.forName("com.sky.Bean")
    
  • 如果T是一个Java类型,那么T.class就代表了匹配的类对象

    Class cl1 = Manager.class;
    Class cl2 = int.class;
    Class cl3 = Double[].class;
    

Class类的常用方法

1、getName()
以 String 的形式返回此 Class 对象所表示的实体(类、接口、数组类、基本类型或 void)名称。

2、newInstance()
为类创建一个实例。newInstance()方法调用默认构造器(无参数构造器)初始化新建对象。

3、getClassLoader()
返回该类的类加载器。

4、getComponentType()
返回表示数组组件类型的 Class。

5、getSuperclass()
返回表示此 Class 所表示的实体(类、接口、基本类型或 void)的超类的 Class。

6、isArray()
判定此 Class 对象是否表示一个数组类。

Class.forName 和 ClassLoader的区别

Class.forName得到的class是已经初始化完成的
Classloder.loaderClass得到的class是还没有链接的

threadlocal

每一个 Thread 都有一个 ThreadLocalMap 类型的静态变量,会在 ThreadLocalMap 里面的 Entry 中去维护该 ThreadLocal 变量与具体实例的映射。key->ThreadLocal对象,value->具体实例,其中key被弱引用引用。

key的泄露

可能会在业务代码中执行了 ThreadLocal instance = null 操作,想清理掉这个 ThreadLocal 实例,如果不使用弱引用,那么在ThreadLocalMap里还会有强引用链接着key。

value的泄露

正常情况下,当线程终止,value 是可以被正常垃圾回收的,因为没有任何强引用存在了。但是有时线程的生命周期是很长的,如果线程迟迟不会终止,导致value不会回收。

在执行 ThreadLocal 的 set、remove、rehash 等方法时,它都会扫描 key 为 null 的 Entry,如果发现某个 Entry 的 key 为 null,则代表它所对应的 value 也没有作用了,所以它就会把对应的 value 置为 null,这样,value 对象就可以被正常回收了。

简单集合

对集合的理解

1.Collection中,有三个子接口:Set,List,Queue
			a. Set接口  :  (不可重复)
				1>HashSet:无序(事实上,内部是根据元素的hashcode进行排序的)
				2>TreeSet:按照比较结果的升序进行进行排序
				3>LinkedHashSet:按照添加顺序保存对象
			b.List接口下主要有:  
				1>ArrayList:
						1)有序,可以重复
						2)查询速度快,增删改慢
				2>LinkedList:
						1)增删改速度快
						2)查询速度慢
			c.Queue  :队列的特点是 先进先出 
			d.Vector :内部采用数组来存放元素,
			支持随机访问、查找快、增删慢;有序、可重复、可排序;可以包含 null 元素
2.Map中
			a.HashMap:
				1>通过键值对的方式来存储
				2>其中key值不可以重复,value可以重复
				3>扩容:当元素装满容器的75%时(默认大小16),扩容2倍
			b.LinkedHashMap:如果需要按照插入顺序查询,可以使用
			c.Hashtable:和HashMap类似,不同的是HashTable不允许键或值为空
			d.TreeMap:需要有排序功能的集合(默认升序,也可以指定)

HashMap

HashMap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,Java中HashMap采用了链地址法。链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被Hash后,得到数组下标,把数据放在对应下标元素的链表上。

重要属性

int threshold;             // 所能容纳的key-value对极限 
final float loadFactor;    // 负载因子
int modCount;  
int size;  

Node[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * Load factor。默认的负载因子0.75是对空间和时间效率的一个平衡选择。

size超过threshold就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。

当链表长度太长(默认超过8)时,链表就转换为红黑树。

modCount字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败(在使用迭代器的过程中有线程修改了map,那么将抛出异常)。内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。

问题一:为什么数组table的长度是2的n次方

这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数,Hashtable初始化桶大小为11,就是桶大小设计为素数的应用(Hashtable扩容后不能保证还是素数)。

HashMap初始化时若不传参,则默认大小为16;若传入参数,则大小为向上找第一个2^n的值。table为懒加载,在传入第一个参数时才会初始化。

HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。

问题二:红黑树与链表的转换

链表->红黑树:链表长度大于8时,且桶数目小于64。桶数目小于64时不会树化,只会扩容。

红黑树->链表:链表长度小于6时。但并不是小于6就会转化,而是在resize时才会判断一下。

大于8扩容是因为根据概率统计,大于8的概率非常小,使用红黑树的时间复杂度为log8=3,链表为8/2=4。

问题三:抖动算法(hash算法)

1-取key的hashCode值

2-高位运算

3-取模运算,因为桶数目为2的n次方,通过&运算等同于取模运算,&运算效率更高。

static final int hash(Object key) {
     int h;
     // h = key.hashCode() 为第一步 取hashCode值
     // h ^ (h >>> 16)  为第二步 高位参与运算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

static int indexFor(int h, int length) {
     return h & (length-1);  //第三步 取模运算
}

问题四:什么时候会出现死循环

https://juejin.cn/post/6844904149616705543

  • jdk7
    • 扩容造成死循环
    • 扩容造成数据丢失
  • jdk8
    • 并发put数据覆盖

put(key, value)

put的节点如果 equals 需要满足 hashcode相同。

判断相同时,先判断hash是否相同,若相同则再判断是否equal,equal才是判断两个key是否相等的关键。

(1)计算key的hash值,并通过(hash & (n - 1))计算桶的位置

(2)如果桶(数组)数量为0,则初始化桶;

(3)如果key所在的桶没有元素,则直接插入;

(4)如果key所在的桶中的第一个元素的key与待插入的key相同,说明找到了元素,转后续流程(9)处理;

(5)如果第一个元素是树节点,则调用树节点的putTreeVal()寻找元素或插入树节点;

(6)如果不是以上三种情况,则遍历桶对应的链表查找key是否存在于链表中;

(7)如果找到了对应key的元素,则转后续流程(9)处理;

(8)如果没找到对应key的元素,则在链表最后插入一个新节点并判断是否需要树化;

(9)如果找到了对应key的元素,则判断是否需要替换旧值,并直接返回旧值

(10)如果插入了元素,则数量加1并判断是否需要扩容;

putTreeVal()

插入元素到红黑树中的方法。先通过hash比较,若相同再比较equal

(1)寻找根节点;

(2)从根节点开始查找;

(3)比较hash值及key值,如果都相同,直接返回,在putVal()方法中决定是否要替换value值;

(4)根据hash值及key值确定在树的左子树还是右子树查找,找到了直接返回;

(5)如果最后没有找到则在树的相应位置插入元素,并做平衡;

get(key)

查找方式同put

ArrayList

ArrayList(int initialCapacity)构造方法

如果大于0,则初始化容量为initialCapacity的数组;

如果等于0,则初始化为空数组,并在add第一个元素时,扩容为10。

add(E e)方法

(1)检查是否需要扩容;

(2)如果elementData等于DEFAULTCAPACITY_EMPTY_ELEMENTDATA则初始化容量大小为DEFAULT_CAPACITY;

(3)新容量是老容量的1.5倍(oldCapacity + (oldCapacity >> 1)),如果加了这么多容量发现比需要的容量还小,则以需要的容量为准;

(4)创建新容量的数组并把老数组拷贝到新数组;

add(int index, E element)方法

添加元素到指定位置,平均时间复杂度为O(n)。

(1)检查索引是否越界;

(2)检查是否需要扩容;

(3)把插入索引位置后的元素都往后挪一位;

(4)在插入索引位置放置插入的元素;

(5)大小加1;

LinkedHashMap

(1)HashMap使用(数组 + 单链表 + 红黑树)的存储结构;LinkedHashMapLinkedHashMap继承自HashMap,具有HashMap的所有特性,还额外添加了一种“双向链表”的结构存储所有元素的顺序。添加删除元素的时候需要同时维护在HashMap中的存储,也要维护在LinkedList中的存储,所以性能上来说会比HashMap稍慢。

(2)如果accessOrder为false,则可以按插入元素的顺序遍历元素;如果accessOrder为true,则可以按访问元素的顺序遍历元素,访问后会将访问的元素移到链表最后。

(3)LinkedHashMap的实现非常精妙,很多方法都是在HashMap中留的钩子(Hook),直接实现这些Hook就可以实现对应的功能了,并不需要再重写put()等方法;

(4)默认的LinkedHashMap并不会移除旧元素,如果需要移除旧元素,则需要重写removeEldestEntry()方法设定移除策略;

(5)LinkedHashMap可以用来实现LRU缓存淘汰策略;

class LRU<K, V> extends LinkedHashMap<K, V> {
    // 保存缓存的容量
    private int capacity;

    public LRU(int capacity, float loadFactor) {
        super(capacity, loadFactor, true);
        this.capacity = capacity;
    }

    /**
    * 重写removeEldestEntry()方法设置何时移除旧元素
    * @param eldest
    * @return 
    */
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        // 当元素个数大于了缓存的容量, 就移除元素
        return size() > this.capacity;
    }
}

WeakHashMap

(1)WeakHashMap使用(数组 + 链表)存储结构;

(2)WeakHashMap中的key是弱引用,gc的时候会被清除;

(3)每次对map的操作都会剔除失效key对应的Entry;

(4)使用String作为key时,一定要使用new String()这样的方式声明key,才会失效,其它的基本类型的包装类型是一样的;

(5)WeakHashMap常用来作为缓存使用;

TreeMap

实现了NavigableMap,NavigableMap实现了SortedMap,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器

底层使用红黑树

红黑树的五大性质

(1)每个节点或者是黑色,或者是红色。

(2)根节点是黑色。

(3)每个叶子节点(NIL)是黑色。

(4)每个红色结点的两个子结点一定都是黑色。

(5)从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。

HashSet

(1)HashSet内部使用HashMap的key存储元素,以此来保证元素不重复;

(2)HashSet的add方法是使用的HashMap的put(key, PRESENT),PRESENT是HashSet内部的一个final对象

(3)HashSet是无序的,因为HashMap的key是无序的;

LinkedHashSet

(1)LinkedHashSet继承自HashSet,底层使用LinkedHashMap存储元素。

(2)LinkedHashSet是有序的,它是按照插入的顺序排序的。它把accessOrder写死为false了,所以不支持按访问顺序对元素排序的,只能按插入顺序排序。

所以,LinkedHashSet是不支持按访问顺序对元素排序的,只能按插入顺序排序

TreeSet

(1)TreeSet底层使用NavigableMap存储元素,通过构造方法设置,默认设置为TreeMap;

(2)TreeSet是有序的;

(3)TreeSet是非线程安全的;

(4)TreeSet实现了NavigableSet接口,而NavigableSet继承自SortedSet接口;

(5)TreeSet实现了SortedSet接口;

(6)TreeSet按照key的自然排序保证的有序性,LinkedHashSet是按照插入顺序保证的有序性。

PriorityQueue

优先级队列,使用堆数组实现,每次出队都弹出优先级最大或最小的元素。

PriorityQueue不是有序的,只有堆顶存储着最小的元素;

入队

(1)容量不够就先扩容

  • 当数组比较小(小于64)的时候每次扩容容量翻倍;
  • 当数组比较大的时候每次扩容只增加一半的容量;

(2)自下而上堆化,一直往上跟父节点比较

出队

(1)将队列首元素弹出;

(2)将队列末元素移到队列首;

(3)自上而下堆化;

ArrayDeque

双端队列是一种特殊的队列,它的两端都可以进出元素,故而得名双端队列。

ArrayDeque是一种以数组方式实现的双端队列,它是非线程安全的。

入队

使用head和tail双指针来使用循环数组,若head和tail指针重叠,则扩容,每次扩容增加一倍。

扩容是把指针后面的移到前面,再次形成一个顺序排序的数组。

LinkedList

双端队列,链表。

线程

JAVA线程有几种状态

1、初始状态(NEW)

创建后尚未启动的线程。

2-1、可运行状态(RUNNABLE之READY)

只是说有资格运行,需要等待操作系统调度。

  • 调用线程的start()方法。
  • sleep()结束,join()结束
  • 获取到锁
  • 调用当前线程的yield()方法,让出时间片。

2-2、可运行状态(RUNNABLE之RUNNING)

线程正在执行。

3、阻塞状态(BLOCKED)

等待获取到一个互斥锁。

  • synchronized
  • Object.wait()方法且被notify()之后

4、等待(WAITING)

不会被分配CPU执行时间,它们要等待被其他线程显式地唤醒,否则会处于无限期等待的状态。

  • 不带时限的 Object.wait 方法
  • 不带时限的 Thread.join 方法
  • LockSupport.park()

会等其它线程执行一个特别的动作,比如:

  • 一个调用了某个对象的 Object.wait 方法的线程会等待另一个线程调用此对象的 Object.notify() 或 Object.notifyAll()。
  • 一个调用了 Thread.join 方法的线程会等待指定的线程结束。

5、超时等待(TIMED_WAITING)

不会被分配CPU执行时间,不过无须无限期等待被其他线程唤醒,在达到一定时间后它们会自动唤醒。

  • Thread.sleep,不会释放锁
  • 带时限(timeout)的 Object.wait,会释放锁
  • 带时限(timeout)的 Thread.join
  • LockSupport.parkNanos
  • LockSupport.parkUntil

6、终止状态(TERMINATED)

线程已经执行完毕。

当线程的run()方法完成时,或者主线程的main()方法完成时,我们就认为它终止了。

这个线程对象也许是活的,但是它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。
在一个终止的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。

为什么将ready和running看作一个runnable?

现在的时分多任务操作系统架构通常都是用所谓的“时间分片”方式进行抢占式轮转调度,更复杂的可能还会加入优先级的机制,这个时间分片通常是很小的,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。

Java的线程状态是服务于监控的,如果线程切换得是如此之快,那么区分 ready 与 running 就没什么太大意义了。并且大多jvm都把调度交委托给了操作系统, 本身没有做什么实质的调度,把底层的 ready 及 running 状态映射上来也没多大意义。

为什么将waiting细化成blocked、waiting、timed_waiting?

将阻塞状态细化更好的区分了线程的状态,丰富了线程在应用层的使用场景

操作系统线程状态

线程创建方式

  • 继承Thread类,重写run()方法,start()开启线程
  • 实现Runnable接口,重写run()方法,start()开启线程
  • 实现Callable接口,重写call()方法,start()开启线程
  • new Thread()对象,传入Runable对象
  • 线程池创建

为什么 Thread 类的 sleep()和 yield ()方法是静态的

如果sleep和yield是静态方法,那么不管哪个线程,只要一调用就把自己给sleep、yield了。

如果sleep和yield是实例方法,那就热闹了。一个线程可以获取其他线程对象的引用,然后通过引用调要其他线程的sleep和yield方法,让其他线程让出CPU使用权。试想,如果每个线程都可以通过sleep、yield其他线程的方式来让自己获得CPU使用权,那不是世界大乱了。线程之间可以互相sleep,互相yield。

来啊,互相伤害啊,TMD谁都别想用CPU!

你是如何调用 wait() 方法的?使用 if 块还是循环?

处于等待状态的线程可能会收到错误警报和伪唤醒。

synchronized (monitor) {
    //  判断条件谓词是否得到满足
    while(!locked) {
        //  等待唤醒
        monitor.wait();
    }
    //  处理其他的业务逻辑
}

thread.join()

join方法会释放thread对象锁,底层是wait方法,调用wait方法后会阻塞住当前线程,等到thread执行完毕后,会调用notifyAll方法唤醒所有阻塞在其对象锁上的线程,这样当前方法就可以继续执行了。

    public final synchronized void join(long millis)
    throws InterruptedException {
        long base = System.currentTimeMillis();
        long now = 0;

        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (millis == 0) {
            while (isAlive()) {
                wait(0);
            }
        } else {
            while (isAlive()) {
                long delay = millis - now;
                if (delay <= 0) {
                    break;
                }
                wait(delay);
                now = System.currentTimeMillis() - base;
            }
        }
    }

进程、线程、协程区别

1、进程

进程是资源分配和调度的一个独立单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销比较大,但相对比较稳定安全。

进程间通信

  • 管道
  • 消息队列
  • 共享内存
  • 信号
  • 信号量
  • 套接字,可用于远程进程通信

2、线程

线程是CPU调度的基本单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。

线程间通信主要通过共享内存,上下文切换很快,资源开销较少

线程间通信

  • 临界区,通过多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问;
  • 互斥量Synchronized/Lock
  • 信号量Semphare
  • 信号,Wait/Notify

3、协程

协程是一种用户态的轻量级线程,协程的调度完全由用户控制,协程并没有增加线程数量,只是在线程的基础上通过分时复用的方式运行多个协程。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

几个线程方法的区别

Object.wait()

  • 需要在synchronized块中执行
  • 抛出了中断异常
  • 会释放锁
  • 不带超时参数的,需要另一个线程执行notify()来唤醒,唤醒之后需要重新获取锁
  • 如果在wait()之前执行了notify()会怎样?抛出IllegalMonitorStateException异常

LockSupport.park()

  • 可以在任意地方执行

  • 不需要捕获中断异常

  • 不会释放锁。它只负责阻塞当前线程,释放锁需要其他实现。例如Condition的await()方法会先释放锁,再阻塞。

  • 不带超时参数的,需要另一个线程执行unpark()来唤醒,唤醒后会直接继续执行后续内容

  • 如果在park()之前执行了unpark()会怎样?线程不会被阻塞,直接跳过park(),继续执行后续内容

  • 底层实现为设置permit许可,unpark()加一,park()减一,但不会重复加一。

Object.join()

  • 实际就是调用wait(0)

Thread.sleep()

  • Thread.sleep()和LockSupport.park()方法类似,都是阻塞当前线程的执行
  • 不会释放锁
  • 没法从外部唤醒,只能自己醒过来
  • 方法声明上抛出了InterruptedException中断异常

线程模型

1、Future模型

该模型通常在使用的时候需要结合Callable接口配合使用,线程将结果放入Future。

2、fork&join模型

把一个大任务逐级拆分为多个子任务,然后分别在子线程中执行,当每个子线程执行结束之后返回结果进行汇总合并。

3、生产者消费者模型

使用一个缓存来保存任务,开启一个/多个线程来生产任务,然后再开启一个/多个来从缓存中取出任务进行处理。

4、master-worker模型

master-worker模型类似于任务分发策略,开启一个master线程接收任务,然后在master中根据任务的具体情况进行分发给其它worker子线程,然后由子线程处理任务。如需返回结果,则worker处理结束之后把处理结果返回给master。

一对一(内核级线程模型)

内核负责每个线程的调度,可以调度到其他处理器上面。

优点:

  • 实现简单

缺点:

  • 对用户线程的大部分操作都会映射到内核线程上,引起用户态和内核态的频繁切换;
  • 内核为每个线程都映射调度实体,如果系统出现大量线程,会对系统性能有影响;

多对一(用户级线程模型)

多个用户线程对应到同一个内核线程上,线程的创建、调度、同步的所有细节全部由进程的用户空间线程库来处理。

优点:

  • 用户线程的很多操作对内核来说都是透明的,不需要用户态和内核态的频繁切换,使线程的创建、调度、同步等非常快;

缺点:

  • 由于多个用户线程对应到同一个内核线程,如果其中一个用户线程阻塞,那么该其他用户线程也无法执行;
  • 内核并不知道用户态有哪些线程,无法像内核线程一样实现较完整的调度、优先级等;

多对多(两级线程模型)

可以动态绑定内核线程, 当某个内核线程因为其绑定的用户线程的阻塞操作被内核调度让出CPU时,其关联的进程中其余用户线程可以重新与其他内核线程绑定运行。

优点:

  • 兼具多对一模型的轻量;
  • 由于对应了多个内核线程,则一个用户线程阻塞时,其他用户线程仍然可以执行;
  • 由于对应了多个内核线程,则可以实现较完整的调度、优先级等;

缺点:

  • 实现复杂;

Volatile

语义一:内存可见性

1、修改数据后,立即将缓存中数据写回到内存中

2、其他处理器通过嗅探总线上传播过来了数据监测自己缓存的值是不是过期了,如果过期了,就会对应的缓存中的数据置为无效。而当处理器对这个数据进行读写时,会重新从内存中把数据读取到缓存中进行处理。

语义二:禁止重排序

Java中的有序性可以概括为一句话:如果在本线程中观察,所有的操作都是有序的;如果在另一个线程中观察,所有的操作都是无序的。

前半句是指线程内表现为串行的语义,后半句是指“指令重排序”现象和“工作内存和主内存同步延迟”现象。

重排序是站在另一个线程的视角的,因为在本线程中,是无法感知到重排序的影响的。

而volatile变量是禁止重排序的,它能保证程序实际运行是按代码顺序执行的。

volatile内存语义的实现

通过内存屏障

内存屏障有两个作用:

(1)阻止屏障两侧的指令重排序;

(2)强制把写缓冲区/高速缓存中的数据回写到主内存,让缓存中相应的数据失效;

保守策略下的内存屏障插入策略(不同处理器下,JMM的插入策略不同):

  • volatile写操作前 插入StoreStore
  • volatile写操作后 插入StoreLoad
  • volatile读操作后 插入LoadLoad
  • volatile读操作后 插入LoadStore

总结

(1)volatile关键字可以保证可见性;

(2)volatile关键字可以保证有序性;

(3)volatile关键字不可以保证原子性;

(4)volatile关键字的底层主要是通过内存屏障来实现的;

(5)volatile关键字的使用场景必须是场景本身就是原子的;

Synchronized

synchronized关键字是Java里面最基本的同步手段,它经过编译之后,会在同步块的前后分别生成 monitorentermonitorexit 字节码指令,这两个字节码指令都需要一个引用类型的参数来指明要锁定和解锁的对象。

lock,锁定,作用于主内存的变量,它把主内存中的变量标识为一条线程独占状态。

unlock,解锁,作用于主内存的变量,它把锁定的变量释放出来,释放出来的变量才可以被其它线程锁定。

但是这两个指令并没有直接提供给用户使用,而是提供了两个更高层次的指令 monitorenter 和 monitorexit 来隐式地使用 lock 和 unlock 指令。

原子性、可见性、有序性

前面讲解Java内存模型的时候我们说过内存模型主要就是用来解决缓存一致性的问题的,而缓存一致性主要包括原子性、可见性、有序性。

那么,synchronized关键字能否保证这三个特性呢?

还是回到Java内存模型上来,synchronized关键字底层是通过monitorenter和monitorexit实现的,而这两个指令又是通过lock和unlock来实现的。

而lock和unlock在Java内存模型中是必须满足下面四条规则的:

(1)一个变量同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一个线程执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才能被解锁。

(2)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值;

(3)如果一个变量没有被lock操作锁定,则不允许对其执行unlock操作,也不允许unlock一个其它线程锁定的变量;

(4)对一个变量执行unlock操作之前,必须先把此变量同步回主内存中,即执行store和write操作;

通过规则(1),我们知道对于lock和unlock之间的代码,同一时刻只允许一个线程访问,所以,synchronized是具有原子性的。

通过规则(1)(2)和(4),我们知道每次lock和unlock时都会从主内存加载变量或把变量刷新回主内存,而lock和unlock之间的变量(这里是指锁定的变量)是不会被其它线程修改的,所以,synchronized是具有可见性的。

通过规则(1)和(3),我们知道所有对变量的加锁都要排队进行,且其它线程不允许解锁当前线程锁定的对象,所以,synchronized是具有有序性的。

综上所述,synchronized是可以保证原子性、可见性和有序性的。

锁优化

synchronized有哪些进化中的状态呢?

我们这里稍做一些简单地介绍:

(1)偏向锁,是指一段同步代码一直被一个线程访问,那么这个线程会自动获取锁,降低获取锁的代价。

(2)轻量级锁,是指当锁是偏向锁时,被另一个线程所访问,偏向锁会升级为轻量级锁,这个线程会通过自旋的方式尝试获取锁,不会阻塞,提高性能。

(3)重量级锁,是指当锁是轻量级锁时,当自旋的线程自旋了一定的次数后,还没有获取到锁,就会进入阻塞状态,该锁升级为重量级锁,重量级锁会使其他线程阻塞,性能降低。

线程池

1、线程池的参数有哪些?

corePoolSize:核心线程数。

maximumPoolSize:最大线程数。

keepAliveTime + unit:线程保持空闲时间及单位。此两参数针对非核心线程;如果allowCoreThreadTimeOut被设置成了true,针对核心线程也有效。

workQueue:任务队列。

threadFactory:线程工厂。默认使用的是Executors工具类中的DefaultThreadFactory类,这个类有个缺点,创建的线程的名称是自动生成的,无法自定义以区分不同的线程池,且它们都是非守护线程。

handler:拒绝策略。

2、线程池都有哪些状态

线程池的状态和工作线程的数量共同保存在控制变量ctl(AtomicInteger)中。

ctl的高三位保存运行状态,低29位保存工作线程的数量,也就是说线程的数量最多只能有(2^29-1)个,也就是上面的CAPACITY。

线程池各个状态切换框架图:

1.RUNNING:可接受新任务,且可执行队列中的任务;

2.SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务。

3.STOP:不接受新任务,不再执行队列中的任务,尝试停止(中断)正在执行的任务;

4.TIDYING:所有任务已经中止,且工作线程数量为0,最后执行terminated()钩子方法;

5.TERMINATED:中止状态,已经执行完terminated()钩子方法;

3、核心线程和非核心线程有什么区别?

实际上并没有什么区别,主要是根据corePoolSize来判断任务该去哪里,两者在执行任务的过程中并没有任何区别。有可能新建的时候是核心线程,而keepAliveTime时间到了结束了的也可能是刚开始创建的核心线程。Work是一个实现了Runnable的类,可以循环阻塞在阻塞队列处,如果线程数超过了核心数,则会将keepalivedTime作为阻塞超时时间,阻塞等待没有任务的话就直接跳出循环,该线程执行完毕。

4、Worker继承自AQS有何意义?

interruptIdleWorkers()方法的意思是中断空闲线程的意思,它只会中断BlockingQueue的poll()或take()方法,而不会中断正在执行的任务。

一般来说,interruptIdleWorkers()方法的调用不是在本工作线程,而是在主线程中调用的。

观察shutdown()和shutdownNow()方法中中断线程的方法,shutdown()中就是调用了interruptIdleWorkers()方法,这里tryLock()获取到锁了再中断,如果没有获取到锁则不中断,没获取到锁只有一种情况,也就是lock()所在的地方,也就是有任务正在执行。

而shutdownNow()中中断线程则很暴力,并没有tryLock(),而是直接中断了线程,所以调用shutdownNow()可能会中断正在执行的任务。

所以,Worker继承自AQS实际是要使用其锁的能力,这个锁主要是用来控制shutdown()时不要中断正在执行任务的线程。

5、线程池中的普通任务是怎么执行的?

(1)execute(),提交任务的方法,根据核心数量、任务队列大小、最大数量,分成四种情况判断任务应该往哪去;

  • 工作线程数量小于核心数量,创建核心线程;
  • 达到核心数量,进入任务队列;
  • 任务队列满了,创建非核心线程;
  • 达到最大数量,执行拒绝策略;

(2)addWorker(),添加工作线程的方法,通过Worker内部类封装一个Thread实例维护工作线程的执行;

(3)runWorker(),真正执行任务的地方,先执行第一个任务,再源源不断从任务队列中取任务来执行;

(4)getTask(),真正从队列取任务的地方,默认情况下,根据工作线程数量与核心数量的关系判断使用队列的poll()还是take()方法,keepAliveTime参数也是在这里使用的。

6、线程池中的未来任务是怎么执行的?

MyCallableThread t = new MyCallableThread();
FutureTask<String> futureTask = new FutureTask<>(t);
Thread thread = new Thread(futureTask);
thread.start();

futureTask.get();

(1)未来任务是通过把普通任务包装成FutureTask来实现的

(2)其中有不同的状态(NEW、COMPLETING、EXCEPTIONAL…),get()方法: 若执行完毕,则返回结果;若未执行完,则park阻塞。线程执行完时会unpark释放锁。

(3)通过FutureTask不仅能够获取任务执行的结果,还有感知到任务执行的异常,甚至还可以取消任务;

RPC框架常用的调用方式有同步调用、异步调用,其实它们本质上都是异步调用,它们就是用FutureTask的方式来实现的。

一般地,通过一个线程(我们叫作远程线程)去调用远程接口,如果是同步调用,则直接让调用者线程阻塞着等待远程线程调用的结果,待结果返回了再返回;如果是异步调用,则先返回一个未来可以获取到远程结果的东西FutureXxx,当然,如果这个FutureXxx在远程结果返回之前调用了get()方法一样会阻塞着调用者线程。

7、线程池中的定时任务是怎么执行的?

实现定时任务有两个问题要解决,分别是指定未来某个时刻执行任务、重复执行。

(1)指定某个时刻执行任务,是通过延时队列的特性来解决的;

(2)重复执行,是通过在任务执行后再次把任务加入到队列中来解决的。

8、ForkJoinPool

ForkJoinPool特别适合于“分而治之”算法的实现,适合计算密集型任务。

ForkJoinPool内部使用的是“工作窃取”算法实现的。

(1)每个工作线程都有自己的工作队列WorkQueue;

(2)这是一个双端队列,它是线程私有的;

(3)ForkJoinTask中fork的子任务,将放入运行该任务的工作线程的队头,工作线程将以LIFO的顺序来处理工作队列中的任务;

(4)为了最大化地利用CPU,空闲的线程将从其它线程的队列中“窃取”任务来执行;

(5)从工作队列的尾部窃取任务,以减少竞争;

(6)双端队列的操作:push()/pop()仅在其所有者工作线程中调用,poll()是由其它线程窃取任务时调用的;

(7)当只剩下最后一个任务时,还是会存在竞争,是通过CAS来实现的;

9、线程池大小如何设置

  • CPU 密集型应用,线程池大小设置为 N + 1(N表示CPU数量)
  • IO 密集型应用,线程池大小设置为 2N
  • 混合型,分为两个线程池

无论是CPU密集型应用的N+1还是IO密集型应用的2N都是一个经验值。

Ncpu 表示CPU的数量 ,Ucpu 表示目标CPU的使用率,其中0 <= Ucpu <= 1,W/C =表示等待时间与计算时间的比率,为保持处理器达到期望的使用率,最优的池的大小等于:Nthreads = Ncpu x Ucpu x (1 + W/C)

对于IO密集型应用,等待时间一般都会比计算时间长,如果假设等待时间等于计算时间,那么Nthreads = Ncpu x Ucpu x 2,当CPU使用率达到100%,Nthreads = Ncpu x2

10、4种线程池

Java通过Executors提供四种线程池,分别为:

  • newCachedThreadPool创建一个可缓存线程池,线程池为无限大,如果线程池长度超过处理需要,可灵活回收空闲线程,若线程均在执行任务,则新建线程。
  • newFixedThreadPool 创建一个定长线程池,核心线程数与最大线程数相同。
  • newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
  • newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

11、线程池的三种队列

  • SynchronousQueue:没有容量,是无缓冲等待队列,是一个不存储元素的阻塞队列,会直接将任务交给消费者,必须等队列中的添加元素被消费后才能继续添加新的元素。
  • LinkedBlockingQueue:无界缓存等待队列
  • ArrayBlockingQueue:有界缓存等待队列,可以指定缓存队列的大小

hhhhh