知识体系总结(五)java基础、集合、并发、JVM

文章目录

  • 接口、类与继承
    • java中除了new还有哪些方式创建对象?
    • Object类有哪些方法?
    • == 和equal区别是什么?
    • hashCode()
    • 为什么重写equals方法必须重写hashcode方法?
    • String为什么设计成不可变的?
    • String,StringBuffer,StringBuilder的区别是什么?
    • 静态内部类和非静态内部类的区别
    • java面向对象的三大特性
    • 访问权限关键字Public、protected、default和private
    • 多态的理解
      • 多态的转型
      • 理解多态的案例
      • 多态方法调用的优先级
    • java中接口和类的区别
    • 泛型与泛型擦除
  • 关键字
    • super
      • super和this的区别
    • static
      • static应用场景
    • volatile
      • volatile的两层语义
      • volatile实现的原理
      • 内存屏障
    • synchronized
      • synchronized同步代码块和同步方法的区别
  • 集合
    • 线程安全的集合有哪些?线程不安全的呢?
    • List
      • ArrayList与LinkedList异同点
      • ArrayList与Vector的区别
      • Array和ArrayList的区别
      • CopyOnWriteArrayList
      • CopyOnWriteArraySet
    • Map
      • HashMap默认的负载因子是多少?为什么是0.75
    • HashMap中key的存储索引是怎么计算的?
    • 为什么链表长度大于阈值8时才将链表转为红黑树?当长度减小的时候红黑树还会转换为链表吗?
    • HashMap的put方法流程
    • HashMap的扩容方法?
      • 红黑树的为啥能保证增删改查效率都是O(logN)?它有哪些性质?
      • 一般用什么作为HashMap的key?
      • HashMap为什么线程不安全?
      • ConcurrentHashMap
        • JDK1.7的concurrentHashMap
        • JDK1.8的concurrentHashMap
    • Collection框架实现比较要怎么做?
  • 线程并发
    • Java并发安全三大特性是什么?如何保证?
    • 多线程基础
      • 线程和进程的区别
      • 什么是线程死锁?死锁产生条件?
      • 常见的对比
        • runnable vs callable
        • shutdown() vs shutdownNow()
        • isTerminated() vs isShutdown()
        • sleep() vs wait
    • 锁的种类
      • 偏向锁/轻量级锁/重量级锁
      • 可重入锁/非可重入锁
      • 共享锁/独占锁
      • 公平锁/非公平锁
    • CAS(compare and swap)
      • CAS定义
      • CAS的缺陷
    • synchronize
      • synchronize和volatile的区别是什么
      • synchronized和Lock有什么区别?
      • synchronized和reentrantLock区别是什么
      • synchronize底层实现原理
      • 多线程中 synchronized 锁升级的原理是什么?
    • ThreadLocal
      • ThreadLocal原理
      • ThreadLocal内存泄露问题
    • AQS
      • AQS的实现原理
    • 线程池
      • 使用线程池的好处
      • 线程池大小确定
      • 线程池执行任务的流程
      • 线程池常用的阻塞队列有哪些
      • 源码中线程池是怎么复用线程的?
  • JVM
    • JMM(java memory model,java内存模型)
      • 主内存与工作内存
      • 工作内存与主内存的交互的8种方法
    • JVM内存结构
      • JVM内存结构是怎样的?
      • 谈谈对OOM的认识,如何排查OOM问题
      • 谈谈JVM中的常量池
      • 如何判断一个对象是否存活?GC Root通常是什么?
      • java的四种引用是什么?
    • GC(Garbage Collection,垃圾收集)
      • JVM的垃圾回收算法有哪些?
      • 常用的垃圾回收器有哪些?各自的优缺点是什么?
    • final与static关键字
      • final static注意事项
    • 类加载
      • 什么是类加载?类加载的过程?
      • 什么是类加载器,常见的类加载器有哪些?
      • 什么是双亲委派机制?
      • 如何打破双亲委派机制?列举一些你知道的打破双亲委派机制的例子?为什么要打破?

接口、类与继承

java中除了new还有哪些方式创建对象?

  • 反射:通过Class对象的newInstance()方法,也可以通过反射获取对象的构建器,执行构造器的newInstance()。
  • object的clone()方法,创建对象的副本,默认是浅拷贝,如果要实现深拷贝,需要重写clone()方法的逻辑。
  • 反序列化:对象输入流的readObject()方法。

Object类有哪些方法?

知识体系总结(五)java基础、集合、并发、JVM_第1张图片

== 和equal区别是什么?

  • 如果==比较的是基本数据类型,那么比较值是否相等
  • 如果==是比较两个对象,那么比较的是对象的引用,也就是判断两个对象是否指向了同一块内存区域。

equal方法用于两个对象之间,检测一个对象是否等于另一个对象。

Object类中equal()的源码等价于通过“==”比较两个对象
但一般而言,我们使用equal的目的是为了比较两个对象的内容是否相同,因此,一般会重写equals()方法,来比较它们的内容是否相等。

hashCode()

hashCode()的作用是获取哈希码,也称为散列码,它实际上返回一个int整数,这个哈希吗的作用是确定该对象在哈希表中的索引位置。hashCode()定义在JDK的Object.java,每个对象都包含有hashCode()函数。

为什么重写equals方法必须重写hashcode方法?

  • 根据java对象相等的规范性,两个对象通过equal()方法判断相等,那么它们的hashCode()方法应该返回相同的值,如果只重写equals()方法而没有重写hashCode方法就会在使用哈希几重例如(HashSet或者HashMap)时,相等的对象被错误的认为是不相等的,因为哈希集合使用HashCode来决定对象在内部存储的位置。
  • 提高不同对象判重的速度:当判断两个对象是否相等时,会首先比较两个对象的hashCode,如果hashCode不同,那么可以确定它们不相等。如果hashCode相同,两个对象也可能不同,因为有哈希碰撞发生的可能性,但哈希碰撞发生的概率较低,使用hashCode
    判重能够解决大多数情况下的不同对象的判重使用,避免了对对象的逐个比较,从而提高了性能。

String为什么设计成不可变的?

  1. 便于实现字符串池(String pool):在java中由于会大量使用String常量,如果每一次声明一个String都创建一个String对象,那将会造成极大的空间浪费。。Java提出了String pool的概念,在堆中开辟一块存储空间String pool,当初始化一个String变量时,如果该字符串已经存在了,就不会去创建一个新的字符串变量,而是会返回已经存在了的字符串的引用。
  2. 使线程安全。不变的字符串对象,保证了线程安全。
  3. 避免网络安全。网络连接地址URL、文件路径path,都需要String参数,其不可变性,防止了黑客的篡改。
  4. 加快字符串处理速度。

String,StringBuffer,StringBuilder的区别是什么?

  1. 可变与不可变。String类中使用字符数组保存字符串,因为有“final”修饰符,所以String对象是不可变的对于已经存在的String对象的修改都是重新创建一个新的对象,然后把新值保存进去。StringBuffer与StringBuilder都继承自AbstractStringBuilder类,在AbstractStringBuilder中也是使用字符数组保存字符串的,这两种对象都是可变的。
private final char value[];  // String
char[] value;  // StringBuffer、StringBuilder
  1. 是否线程安全。
  • String中的对象是不可变的,也就可以理解为常量,线程安全。
  • StringBuilder是非线程安全的。
  • StringBuffer对方法加了同步锁或者对调用的方法加了同步锁,所以说线程安全的。
@Override
public synchronize StringBuffer append(String str) {
	toStringCache = null;
	super.append(str);
	return this;
}
  1. 如果只是在单线程使用字符串缓冲区,那么StringBuilder的效率会更高些。需要考虑线程安全的,用StringBuffer。

静态内部类和非静态内部类的区别

静态内部类只能方法外部类的静态成员和静态方法
非静态内部类:不管是静态方法还是非静态方法都可以在非静态内部类中访问。

静态内部类和非静态内部类的主要不同:

  • 静态内部类不依赖外部类的实例化而被实例化,但非静态内部类需要在外部类实例化后才可以被实例化。
  • 静态内部类不需要持有外部类的引用,但非静态内部类需要持有外部类的引用
  • 静态内部类不能访问外部类的非静态成员和非静态方法,非静态内部类则没有此限制,可以访问外部类的静态和非静态成员和方法

java面向对象的三大特性

  • 封装:根据单一职责原则,把能够代表某个事或物品,或者能够完成某项功能和职责的一系列属性和方法,抽象地、高内聚地封装为一个类对象,把对象的状态信息隐藏在对象内部,并且设置访问权限,外部对象根据访问权限来访问对象内部信息。
  • 继承:不同类型的对象,相互之间可能经常有许多共同点,继承允许我们在已经构建的类(也就是父类)的基础上,增加或者修改内容,复用父类对象功能的同时,实现我们新增的功能需求,提高开发效率。
  • 多态:多态的含义是同一对象表现出不同的性质,方法重载overload实现了编译时多态,同名函数根据不同的参数列表,表现出不同的形式。子类对象可以绑定到父类对象,并且通过override重写父类的方法,实现运行时多态。

访问权限关键字Public、protected、default和private

知识体系总结(五)java基础、集合、并发、JVM_第2张图片

多态的理解

方法重载overload实现了编译时多态,同名函数根据不同的参数列表,表现出不同的形式。

子类方法对于父类方法的重写override实现运行时多态,java运行 时系统根据该方法的实例的类型来决定选择调用哪个方法被称为运行时多态。
运行时多态存在的必要条件:

  • 子类继承父类
  • 子类重写父类方法
  • 父类引用指向子类对象

多态的转型

多态的转型分为向上转型和向下转型两种。

  • 向上转型:多态本身就是向上转型的过程
    使用格式: 父类类型 变量名 = new 子类类型();

  • 向下转型:一个已经向上转型的子类对象,可以使用强制类型转换的格式,将父类引用类型,转为子类引用类型
    使用格式:子类类型 变量名 = (子类类型) 父类类型的变量;

理解多态的案例

案例一:

class People{
	public void eat(){
		System.out.println("吃饭");
	}
}
class Stu extends People{
	@Override
	public void eat(){
		System.out.println("吃水煮肉片");
	}
	public void study(){
		System.out.println("好好学习");
	}
}
class Teachers extends People{
	@Override
	public void eat(){
		System.out.println("吃樱桃");
	}
	public void teach(){
		System.out.println("认真授课");
	}
}

public class demo04 {
public static void main(String[] args) {
	People p=new Stu();
	p.eat(); // 吃水煮肉片
	//调用特有的方法
	Stu s=(Stu)p;
	s.study();
	s.eat(); // 吃水煮肉片
	}
}

案例二
知识体系总结(五)java基础、集合、并发、JVM_第3张图片
在这里插入图片描述
这个案例的第三个输出可能与我们所想不一致。
当超类对象引用变量引用子类对象时,被引用对象的类型而不是引用变量的类型决定了调用谁的成员方法,但是这个被调用的方法必须是在超类中定义过的,也就是被子类覆盖的方法但是如果强制把超类转为子类,就可以调用子类中新添加而超类中没有的方法

上边的第三条:a2.show(b),a2是按照多态方式得到的一个B类,入参b是B类,B继承自A,而A中并没有入参为B类的show(),所有B中的两个show()函数,入参为A类的是对父类的重写,入参为B的show函数是自己新有的方法。

那么根据上边的观点,a2只能调用在A中定义过的方法,那么B中这个入参为B的show函数是不会被调用的,因为它是子类新有的方法。因此最终调用的是入参为A类的show函数,因为它可以由多态的方式来接收参数b,最终的输出为 B and A

多态方法调用的优先级

上边这个案例实际上还设计到方法调用的优先级问题,优先级由高到低依次为:

  • this.show(O)
  • super.show(O)
  • this.show((super)O)
  • super.show((super)O)

再看下边这个案例:
知识体系总结(五)java基础、集合、并发、JVM_第4张图片
输出是ai ni

java中接口和类的区别

  • 在java中不允许类的多继承,但是支持接口的多继承。
  • 在接口中只能定义全局常量和抽象方法
  • 某个接口被类实现时,在类中一定要实现接口中的抽象方法,而类的继承则可以直接使用父类的方法。

泛型与泛型擦除

  • 泛型:泛型的本质是参数化类型,泛型提供了编译时安全检测机制,允许在编译时检测到非法类型。
  • 泛型擦除:泛型只存在于代码编译阶段,在进入JVM之前,所有的泛型信息都会被擦除,因此通过反射机制得到的List可以add非A类型的对象。

关键字

super

super可以理解是指向自己父类对象的一个指针,它主要有以下三种用法:

  • super.xxx,引用父类成员
  • 当子类成员变量或者方法与父类重名时,用super加以区别,表示是父类的成员或者方法
  • 引用父类的构造函数

super和this的区别

  • this是指向对象的指针,而super是指向父类对象的指针。
  • super在子类中调用父类的构造方法,this在本类内调用本类的其他构造方法。

static

  • 被static修饰的变量或者方法属于类本身,而非属于实例对象,仅在内存中有一份副本,即所有的实例对象共享被static修饰的成员变量或者成员方法。
  • static变量值是在类加载时分配空间的,存在于运行时常量池中。静态只能访问静态,非静态可以访问静态和非静态,访问static成员不需要创建类实例,可以直接通过类名调用。

static应用场景

  • 静态成员变量
  • 静态成员方法
  • 静态代码块
  • 静态内部类
  • 静态导包

volatile

volatile的两层语义

  1. volatile保证了变量对所有线程的可见性
    • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中
    • 当读一个volatile变量时,JMM会把该线程本地内存中的共享变量值设置为无效,重新回到主内存中读取最新的共享变量。
  2. volatile修饰的变量在底层实现时会加上lock前缀,相当于一个内存屏障,避免了指令重排优化,实现了有序性

volatile实现的原理

  • volatile修饰的变量在底层会加上lock前缀,相当于一个内存屏障,防止了指令重排。
  • lock前缀的另一层意义是使得班线程工作内存的volatile变量值立即写入到主内存,并且使得其他线程共享的该volatile变量无效化,使得其他线程必须重新从主内存中读取最新的变量值。

内存屏障:memory barrier 能够让CPU或编译器在内存访问上有效。

内存屏障

内存屏障(也称内存棚栏,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。

  • 内存屏障之前的所有的写操作都要写回到主内存。
  • 内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果。

在重排序时,不允许把内存屏障之后的指令重排到内存屏障之前,对于一个volatile变量的写,先行发生于任意后续对于这个volatile变量的读,也叫写后读

synchronized

synchronize解决的是执行控制的问题,保证同一时间只有一个线程可以执行被synchronized修饰的代码。

java中synchronized底层主要依赖于对象监控器(也称为锁)和线程同步机制实现的:

  1. 对象监控器:每个java对象都有一个与之关联的对象监控器,也可以理解为锁,当一个线程想要执行一个被synchronized修饰的方法或者代码块时,它需要先获取该对象的监视器。
  2. 进入监视器获取阶段(Entry Set):当一个线程尝试进入一个被 synchronized 修饰的方法或代码块时,它会首先尝试获取对象的监视器。如果监视器当前没有被其他线程占用,则该线程成功获取监视器,并进入临界区。
  3. 互斥访问阶段(Mutual Exclusion):一旦线程成功获取到对象的监视器,它就可以执行临界区内的代码。在执行期间,其他线程无法进入该对象的其他被 synchronized 修饰的方法或代码块,它们将被阻塞。
  4. 退出监视器释放阶段(Exit Set):当线程执行完临界区内的代码后,它会释放对象的监视器,使得其他线程可以继续竞争获取该监视器。

synchronized同步代码块和同步方法的区别

  1. 锁的粒度不同:同步代码块:可以控制锁的粒度,只对需要同步的代码块进行加锁,其他代码不受影响。同步方法:锁的粒度较大,对整个方法进行加锁,无法对方法内的某个代码块进行单独的同步。
  2. 锁对象不同:同步代码块:需要指定一个对象作为锁,可以是任意对象,通常是共享资源对象或专门为同步目的创建的对象。同步方法:锁的对象是方法所属的对象实例(非静态方法)或类(静态方法)。
  3. 底层实现不同:synchronized 同步代码块的实现是通过 monitor enter 和 monitor exit 指令和锁计数器实现的,每进入一次 synchronized,锁计数器加1,每次退出,锁计数器减一,只有当锁计数器归零时,锁才会真正释放,其他线程才可以获取到该锁。synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是
    ACC_SYNCHRONIZED 标识,JVM通过该标志辨别该方法为同步方法。

集合

线程安全的集合有哪些?线程不安全的呢?

线程安全的:

  • HashTable:比HashMap多了个线程安全
  • ConcurrentHashMap:是一种高效但是线程安全的集合
  • CopyOnWriteArrayList
  • CopyOnWriteArraySet
  • Vector:比ArrayList多了一个同步化机制
  • Stack:栈也是线程安全的,它继承自Vector

线程不安全的:

  • HashMap
  • ArrayList
  • LinkedList
  • HashSet
  • TreeSet
  • TreeMap

List

ArrayList与LinkedList异同点

  • 底层实现:ArrayList底层使用的是Object数组,LinkedList底层使用的是双向循环链表数据结构。
  • 插入和删除是否受元素位置的影响:ArrayList采用数组存储,所有插入和删除元素的时间复杂度受元素位置的影响,而LinkedList采用链表存储,插入和删除元素的时间复杂度为O(n)
  • 是否支持随机访问:ArrayList支持随机访问,LinkedList不支持随机访问。
  • 内存空间占用:ArrayList的空间浪费体现在list列表的结尾会预留一定的容量空间,而LinkedList的空间花费则体现在每个元素都需要存放直接前驱和直接后继以及数据。

ArrayList与Vector的区别

  • Vector是线程安全的,ArrayList不是线程安全的,其中Vector在关键性的方法前面都加了synchronize关键字,来保证线程的安全。
  • ArrayList在底层数组不够用时,在原来的基础上扩展0.5倍,Vector扩展1倍,使用ArrayList有利于节约内存空间。

Array和ArrayList的区别

  • Array可以包含基本数据类型和对象类型,ArrayList只能包含对象类型,换言之,Array可以直接存放原始数据类型,而ArrayList只能存放基本数据类型的包装类。
  • Array大小是固定的,ArrayList的大小是动态扩展的。
  • ArrayList提供了更多的方法和特性,比如allAll(),removeAll(),iterator()等等。

CopyOnWriteArrayList

CopyOnWriteArrayList是开发过程中常用的一种并发容器,多用于读多写少的并发场景。

原理:当我们向容器添加或删除元素的时候,不直接往当前容器添加删除,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加删除元素,添加删除完元素之后,再将原容器的引用指向新的容器,整个过程加锁,保证了写的线程安全。

    public boolean add(E e) {
        synchronized (lock) {
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        }
    }

    public E remove(int index) {
        synchronized (lock) {
            Object[] elements = getArray();
            int len = elements.length;
            E oldValue = get(elements, index);
            int numMoved = len - index - 1;
            if (numMoved == 0)
                setArray(Arrays.copyOf(elements, len - 1));
            else {
                Object[] newElements = new Object[len - 1];
                System.arraycopy(elements, 0, newElements, 0, index);
                System.arraycopy(elements, index + 1, newElements, index,
                                 numMoved);
                setArray(newElements);
            }
            return oldValue;
        }
    }

因为读操作不会对当前容器做任何处理,所以我们可以对容器进行并发的读,而不需要加锁,即读写分离:

public E get(int index) {
   return get(getArray(), index);
}

一般来讲,我们使用时,会用一个线程向容器中添加元素,一个线程来读取元素,而读取的操作往往更加频繁,写操作加锁保证了线程安全,读写分离保证了读操作的效率。

CopyOnWriteArrayList的缺点:

  1. 内存占用问题,在进行写操作时,内存中会同时驻扎两个对象的内存,如果频繁写操作,造成内存抖动。
  2. 数据一致性问题,CopyOnWriteArrayList并不是严格意义上的线程安全,它只能操作最终数据的一致性,不能保证数据的实时一致性。比如有2个线程,第一个线程读取最后一个元素,第二个线程删除第一个元素,在修改前能够读取到最后一个元素,在删除后,再次读取最后一个元素则会越界。

测试一下,首先向CopyOnWriteArrayList里面塞10000个测试数据,启动两个线程,一个不断的删除元素,一个不断的读取容器中最后一个数据。

    public void test(){
        for(int i = 0; i<10000; i++){
            list.add("string" + i);
        }

        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    if (list.size() > 0) {
                        String content = list.get(list.size() - 1);
                    }else {
                        break;
                    }
                }
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    if(list.size() <= 0){
                        break;
                    }
                    list.remove(0);
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();
    }

运行,可以看出删除到第7个元素的时候就发生了数组越界。
知识体系总结(五)java基础、集合、并发、JVM_第5张图片

CopyOnWriteArraySet

CopyOnWriteArraySet的实现是借助CopyOnWriteArrayList实现的,只不过CopyOnWriteArraySet是在CopyOnWriteArrayList上使用indexOf不允许存入重复元素。

Map

HashMap默认的负载因子是多少?为什么是0.75

HashMap的默认构造函数:

int threshold;  // 容纳键值对的最大值
final float loadFactor;  // 负载因子
int modCount;
int size;

Node[] table 的初始化长度length(默认是16),loadFactor默认值为0.75,threshold是HashMap所能容纳的键值对的最大值,threshold = length × load factor,也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。

比如说当前的容器容量是16,负载因子是0.75,16*0.75=12,也就是说,当容量达到了12的时候就会进行扩容操作。

0.75是对空间和时间效率的一个平衡选择,一般不需要修改,除非在时间和空间比较特殊的情况下:

  • 如果内存空间很多,时间效率要求比较高,可以较低负载因子loadFactor。
  • 相反如果内存紧张,而时间效率要求不高,可以增大loadFactor,这个值也可以大于1.

HashMap中key的存储索引是怎么计算的?

首先根据key值计算出hashcode,然后根据hashcode计算出hash值,最后通过hash&(length - 1)计算得到实际存储的位置。
这里虽然用到的是&运算,但实际上还是在取模,因为length是2的幂,length - 1就是二进制位全部为1,与运算就能选择需要的低位数据

  1. 根据key计算hashcode
  2. 根据hashcode求hash
  3. 由hash取模(代码实现上是求&运算)得到实际存储位置索引。
    知识体系总结(五)java基础、集合、并发、JVM_第6张图片

为什么链表长度大于阈值8时才将链表转为红黑树?当长度减小的时候红黑树还会转换为链表吗?

当链表元素个数大于等于8时,链表换成树结构;若桶中链表元素个数小于等于6时,树结构还原成链表。

  • 从空间复杂度上来讲:
    树结点占用的存储空间是普通结点的两倍。因此红黑树比链表更加耗费空间。结点较少的时候,时间复杂度上链表不会比红黑树高太多,但是能大大减少空间。
  • 从时间复杂度上来讲:红黑树的平均查找长度为log(N),而链表的平均查找长度为N/2,当N为8时,红黑树平均查找长度为3,而链表查找长度为4,提高了查找效率,这时才有转为树的必要,选择在2的幂次长度时转,能更加充分利用红黑树的存储性能。

HashMap的put方法流程

以JDK1.8为例:

  1. 根据key计算hashcode,再得到hash值,取模,找到元素在数组中的存储的下标。
  2. 如果数组为空,调用resize进行初始化。
  3. 如果没有哈希冲突,直接放在对应的数组下标里。
  4. 如果冲突了,且key已经存在,就覆盖掉value;
  5. 如果冲突了,发现该结点已经是红黑树,就将节点添加到这个树上;
  6. 如果不是treenode,开始遍历链表,判断链表长度是否大于8,如果大于8且当前hashmap的长度大于64就转成红黑树,在树中执行插入操作,如果链表长度大于8且hashmap长度小于64,则执行扩容,如果不是大于8,就在链表中执行插入;在遍历过程中判断key是否存在,存在就直接覆盖对应的value值。在这里插入代码片
  7. 插入成功后,就需要判断实际存在的键值对数量size是否超过了最大容量threshold,如果超过了,执行resize方法进行扩容。

HashMap的扩容方法?

HashMap在容量超过了负载因子所定义的容量之后,就会扩容,java里的数组是无法自动扩容的,方法是将hashmap的大小扩大到原来的两倍,并将原来的对象放入到新的数组中。
先来看jdk1.7的源码:

void resize(int newCapacity) {
	Entry[] oldtable = table;
	int oldcapacity = oldtable.length;
	if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
		threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
		return;
	}
	Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
	transfer(newTable); //!!将数据转移到新的Entry数组里
	table = newTable; //HashMap的table属性引用新的Entry数组
	threshold = (int)(newCapacity * loadFactor);//修改阈值
}

transfer()方法将原有的Entry数组里的元素拷贝到新的Entry数组里。

void transfer(Entry[] newTable) {
	Entry[] src = table; //src引用了旧的Entry数组
	int newCapacity = newTable.length;
	for (int j = 0; j < src.length; j++) { //遍历的Entry数组
	Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
	if (e != null) {
		src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
		do {
			Entry<K,V> next = e.next;
			int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
			e.next = newTable[i]; //标记[1]
			newTable[i] = e; //将元素放在数组上
			e = next; //访问下一个Entry链上的元素
			} while (e != null);
		}
	}
}

newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头位置,这样先放在一个索引上的元素,最终会被放到Entry链的尾部。

jdk1.8做了两处优化

  1. resize之后,元素的位置在原来的位置,或者原来的位置 + oldCap(原来哈希表的长度),不需要向jdk1.7那样,重新计算hash,只需要看看原来的hash值新增的那个bit是0还是1就好,是0的话索引没变,是1的话,索引变成了原索引+oldCap。因为n变为了两边,索引会增加1个bit位,这个设计非常巧妙,省去了重新计算hash值的时间。
    知识体系总结(五)java基础、集合、并发、JVM_第7张图片

  2. JDK1.7中rehash的时候,旧链表迁移到新链表,如果新表的数组索引位置相同,则链表会倒置,因为采用的是头插法,JDK1.8不会倒置,使用的是尾插法。

红黑树的为啥能保证增删改查效率都是O(logN)?它有哪些性质?

红黑树是一种非严格平衡二叉搜索树。

  1. 二叉搜索树的增删改查效率是O(logN),但在子树极端不平衡时会退化O(N),比如节点集中在树的某一侧,构成类似链表的结构。
  2. 为了避免这种情况,提出来一种自平衡二叉搜索树,通过树的左旋和右旋,来保证每个节点的左右子树高度差不超过1,从而控制树的层数增长,保证了增删改查的效率。
  3. 但是严格平衡的二叉搜索树为保障严格平衡性,会频繁触发树的左旋、右旋操作,从而影响操作的性能。为了减小维持树的生长平衡所付出的代价,提出了红黑树这种非严格平衡的二叉搜索树,它具备以下特征:
  • 每个节点都会被染成红色或者黑色。
  • 根节点和叶子节点必定是黑色,从根节点到叶子节点的每条路径上必须包含数量相同的黑色节点。
  • 红色节点不可连续存在,即红色节点的父子节点必须是黑色。

上边的性质保证了在红黑树的相对平衡性,从根节点到叶子节点的最长路径(黑红相间),不超过最短路径(全黑)的2倍,这就保证了树生长的一种相对平衡性,因此它的增删改查效率是O(logN)。

一般用什么作为HashMap的key?

  • 一般用Integer、String这种不可变类当做HashMap的key,而且String最为常用,因为字符串是不可变的,所以它在创建的时候hashcode就被缓存了,不需要重新计算。
  • 因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。

HashMap为什么线程不安全?

  • 多线程下扩容死循环,JDK1.7中的HashMap使用头插法,在多线程的环境下,扩容的时候可能导致环形链表的出现,造成死循环,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
  • 多线程的put可能导致元素的丢失,多线程同时执行put操作,如果算出来的索引位置相同,可能导致前一个key被后一个key覆盖。
  • put和get并发时,可能导致get为null。

ConcurrentHashMap

  • HashMap是线程不安全的,因为HashMap中的操作没有加锁。HashTable是线程安全的,但是HashTable只是单纯的在put()方法上加上synchronized保证插入时阻塞其他线程的插入操作,效率低下。
  • ConcurrentMap是线程安全的,ConcurrentMap并非锁住整个方法,而是通过原子操作和局部加锁的方法,保证了多线程的线程安全,尽可能减少了性能的损耗。
JDK1.7的concurrentHashMap

JDK1.7的ConcurrentHashMap,把哈希桶分成小数组(Segment),每个小组有n个HashEntry。其中Segment继承了ReentrantLock,所以Segment是一种可重入锁,扮演锁的角色,而HashEntry用于存储键值对数据。
知识体系总结(五)java基础、集合、并发、JVM_第8张图片

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

JDK1.8的concurrentHashMap

JDK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的数组+链表+红黑树结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用CAS + synchronized 实现更加低粒度的锁。

将锁的级别控制在了更细粒度的哈希桶元素级别,也就是说只需要锁住这个链表头结点(红黑树的根节点),就不会影响其他的哈希桶元素的读写,大大提高了并发度。
知识体系总结(五)java基础、集合、并发、JVM_第9张图片

Collection框架实现比较要怎么做?

  • 第一种:实体类中实现Comparable接口,并实现compareTo方法,称为内部比较器。
  • 第二种,在创建集合时,指定一个比较器,实现Comparator接口的compare(T t1, T t2)方法。

线程并发

Java并发安全三大特性是什么?如何保证?

java保证并发安全的三大特性:

  • 原子性:一组操作要么全部执行,要么全部不执行,执行过程中不能受其他线程影响中断。

如何保证原子性:

通过synchronized关键字保证原子性
通过Lock保证原子性
通过CAS保证原子性

  • 可见性:一个线程修改了某个共享变量,其他线程立即可以“感知到”
    如何保证可见性:

volatile关键字
内存屏障
synchronized关键性
Lock
final关键字

  • 有序性

如果在本线程内观察,所有的操作都是有序的,如果在一个线程中观察另一个线程,所有操作无序。

程序执行的顺序按照代码的先后顺序执行,JVM存在指令重排,所以存在有序性问题。

如何保证有序性?

通过volatile关键字保证有序性
内存屏障
synchronized关键字
Lock

多线程基础

线程和进程的区别

  • 根本区别:进程是操作系统资源分配的基本单位,而线程是处理任务调度和执行的基本单位。
  • 资源分配:进程是操作系统中一个独立的执行单位,拥有独立的地址空间、文件描述符、内存空间等系统资源,而线程是进程的子任务,同一进程中的线程共享进程的资源,线程之间可以直接访问同一进程的数据。
  • 切换开销:由于进程拥有独立的地址空间和系统资源,因此进程切换时,需要保存和恢复进程的上下文信息,会涉及到地址空间的页表切换、缓存失效处理,而线程的开销较少,因为线程共享相同的地址空间和系统资源,它的切换只需要保存和恢复自己的栈指针、程序计数器等。
  • 并发性和多核利用:由于线程共享进程的资源,多个线程在同一时间内可以并发执行,提高了系统的并发性,而进程的并发性,进程间通信需要额外的机制,此外在多核系统上,多个线程可以并行执行。

什么是线程死锁?死锁产生条件?

  • 多个线程同时被阻塞,它们中的一个或全部都在等待某个资源释放。

死锁必须具备以下四个条件:

  • 互斥条件,存在互斥资源,任意时刻只能被一个线程占用。
  • 请求与保持条件,一个线程因请求资源被阻塞时,对已经获取的资源保持,不释放。
  • 不剥夺条件,进程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  • 循环等待条件:若干的线程之间形成一种头尾相接的循环等待资源关系。

常见的对比

runnable vs callable
  • callable可以返回结果或者抛出检查异常
  • 如果任务不需要返回结果或者抛出异常,推荐使用runnable,可以使得代码更加简洁。
shutdown() vs shutdownNow()
  • shutdown():关闭线程池,线程池的状态变为SHUTDOWN,线程池不再接受新任务,但是队列中的任务需要执行完毕。
  • shutdownNow():关闭线程池,此时线程池的状态变为STOP,线程池会终止正在运行的任务,并停止处理排队的任务,并返回正在等待执行的List
  • shutdownNow()的工作原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能无法终止。
isTerminated() vs isShutdown()
  • isShutDown 当调用了showdown后,返回true
  • isTerminated当调用shutdown()方法,并且所有提交的任务完成后返回true
sleep() vs wait
  • sleep是Thread的方法,目的是让程序暂停执行一会儿,而wait()是Object的方法,是一种线程之间的同步机制,只能在synchronize同步代码块或者同步方法中使用。
  • sleep方法不会释放锁,wait方法会释放锁。

锁的种类

偏向锁/轻量级锁/重量级锁

  • 偏向锁
    如果自始至终,对于这把锁都不存在竞争,那么其实就没必要上锁,只需要打个标记就行了,这就是偏向锁的思想。一个对象被初始化后,还没有任何线程来获取它的锁时,那么它就是可偏向的,当有第一个线程来访问它并尝试获取锁的时候,它就将这个线程记录下来,以后如果尝试获取锁的线程正是偏向锁的拥有者,就可以直接获得锁,开销很小,性能最好。
  • 轻量级锁(自旋锁)
    JVM 开发者发现在很多情况下,synchronized 中的代码是被多个线程交替执行的,而不是同时执行的,也就是说并不存在实际的竞争,或者是只有短时间的锁竞争,用 CAS 就可以解决,这种情况下,用完全互斥的重量级锁是没必要的。轻量级锁是指当锁原来是偏向锁的时候,被另一个线程访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的形式尝试获取锁,而不会陷入阻塞。
  • 重量级锁(互斥锁)
    重量级锁是互斥锁,它是利用操作系统的同步机制实现的,所以开销相对比较大。当多个线程直接有实际竞争,且锁竞争时间长的时候,轻量级锁不能满足需求,锁就会膨胀为重量级锁。重量级锁会让其他申请却拿不到锁的线程进入阻塞状态。

锁升级的路径:无锁→偏向锁→轻量级锁→重量级锁。
综上所述,偏向锁性能最好,可以避免执行 CAS 操作。而轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒,性能中等。重量级锁则会把获取不到锁的线程阻塞,性能最差。

可重入锁/非可重入锁

可重入锁指的是线程当前已经持有这把锁了,能在不释放这把锁的情况下,再次获取这把锁。同理,不可重入锁指的是虽然线程当前持有了这把锁,但是如果想再次获取这把锁,也必须要先释放锁后才能再次尝试获取。

对于可重入锁而言,最典型的就是 ReentrantLock 了,正如它的名字一样,reentrant 的意思就是可重入,它也是 Lock 接口最主要的一个实现类。

共享锁/独占锁

共享锁指的是我们同一把锁可以被多个线程同时获得,而独占锁指的就是,这把锁只能同时被一个线程获得。我们的读写锁,就最好地诠释了共享锁和独占锁的理念。读写锁中的读锁,是共享锁,而写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。

公平锁/非公平锁

公平锁的公平的含义在于如果线程现在拿不到这把锁,那么线程就都会进入等待,开始排队,在等待队列里等待时间长的线程会优先拿到这把锁,有先来先得的意思。而非公平锁就不那么“完美”了,它会在一定情况下,忽略掉已经在排队的线程,发生插队现象。

CAS(compare and swap)

CAS定义

CAS:全称compare and swap,它是一条CPU同步原语,是一种硬件对并发的支持,针对多处理器操作而设计的一种特殊指令,用于管理对共享数据的并发访问。

  • CAS是一种无锁的非阻塞算法的实现,它包含了3个操作数:
    • 需要读写的内存值V
    • 旧的预期值A
    • 要修改的更新值B
  • 当且仅当V的值等于A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(他的功能是判断内存某个位置是否为预期值,如果是则更改为新的值,这个过程是原子的)

CAS的缺陷

  1. ABA问题:并发环境下,假设初始条件是A,去修改数据时发现是A就会执行修改,但是看到的虽然是A,中间可能发生了A变B,B又变回A的情况,此A已经非彼A,数据即便修改成功,也可能有问题。可以通过atomic stamped reference解决ABA问题,它是一个带有标记的原子引用类,通过控制变量值的版本来保证CAS的正确性。
  2. 循环时间长:自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销,CAS是自旋锁的实现基础,通常自旋锁用于资源占用较短的场景,就是为了避免这个耗时问题。
  3. 只能保证一个变量的原子操作。CAS目前只能保证一个变量的执行操作的原子性。

synchronize

synchronize和volatile的区别是什么

  • volatile解决的是内存可见性问题,会使得所有对volatile变量的读写都直接写入到主存,即保证了变量的可见性。
  • synchronize解决的事件控制的问题,它会阻止其他线程获取当前对象的监控锁,这样一来就让当前对象中被synchronize关键字保护的代码段无法被其他线程访问,也就无法并发执行。而且synchronize还会创建一个内存屏障,内存屏障指令保证了所有CPU的操作结果直接刷到主存中,从而保证了操作的可见性。

两者的区别主要有如下:

  1. volatile本质是在告诉JVM当前变量在寄存器中的值是不确定的,需要从主存中读取;synchronize则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  2. volatile仅能使用在变量级别,synchronize则可以使用在变量、方法和类的级别。
  3. volatile仅能实现变量修改的可见性,不能保证原子性,而synchronize保证了变量的修改可见性和原子性。
  4. volatile不会造成线程阻塞,synchronize可能会造成线程阻塞
  5. volatile标记的变量不会被编译器优化,synchronize标记的变量可以被编译器优化

synchronized和Lock有什么区别?

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

synchronized和reentrantLock区别是什么

  1. 两者都是可重入锁
    可重入锁:也叫做递归锁,可重入锁指的是在一个线程中可以多次获取同一把锁,比如:一个线程在执行一个带锁的方法,该方法又调用了另一个需要相同锁的方法,则线程可以直接执行调用的方法,而无需重新获得锁。
    两者都是同一个线程每进入一次,锁计数器自增1,等待锁计数器降为0,释放锁。

  2. synchronized依赖于JVM,而reentrantlock依赖于API

    • synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的
    • ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally语句块来完成)
  3. ReentrantLock 比 synchronized 增加了一些高级功能

synchronize底层实现原理

synchronized 同步代码块的实现是通过 monitorenter 和 monitorexit 指令,其中monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。其内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED,访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

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

synchronized 锁升级原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候
threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断
threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级 锁通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时
就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。

锁的升级的目的:锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized 的实现方
式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。

ThreadLocal

ThreadLocal:直译为线程本地变量。
如果想实现每个线程都拥有自己的专属本地变量就用到了ThreadLocal类。ThreadLocal类主要解决的是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成一个存放数据的盒子,盒子里面可以存储每一个线程的私有数据。

ThreadLocal原理

  • Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,即每个线程都有一个属于自己的ThreadLocalMap。
  • ThreadLocalMap内部维护着Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型值。
  • 每个线程在往ThreadLocal里设置值的时候,都是往自己的ThreadLocalMap里存,读也是以某个
    ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。

ThreadLocal内存结构图:
知识体系总结(五)java基础、集合、并发、JVM_第10张图片
由结构图可以看出:
每个Thread对象持有一个ThreadLocalMap类型的成员变量,ThreadLocalMap中每个Entry的key是TreadLocal本身,value是ThreadLocal的泛型值。

ThreadLocal内存泄露问题

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

解决方案:ThreadLocalMap实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后最好手动调用remove()方法

AQS

AQS 的全称为(AbstractQueuedSynchronizer,抽象队列同步器),是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器, 比如我们提到的 ReentrantLock(可重入锁),Semaphore(信号量),CountDownLatch(倒计时锁)都是基于AQS实现的。

AQS的实现原理

AQS中维护了一个用volatile修饰的state变量和一个现代等待队列(多线程争用资源被阻塞时会加入到此队列)

state操作是通过CAS来保证其修改的线程安全性,根据state的字段来决定是否能够获得锁。

知识体系总结(五)java基础、集合、并发、JVM_第11张图片

  • 当前线程获取同步状态失败后,同步器将当前线程等待状态等信息构造成一个Node节点加入到队列,放在队尾,同步器重新设置尾节点。
  • 加入队列后,会阻塞当前进程
  • 同步状态被释放并且同步器重新设置首节点,同步器唤醒等待队列中的第一个节点,让其再次获取同步状态。

线程池

使用线程池的好处

池化技术:线程池、数据库连接池、http连接池
池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。

线程池提供了一种限制、管理资源的策略。每个线程池还维护一些基本统计信息,例如已经完成任务的数量。

使用线程池的好处:

  • 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗
  • 提高响应速度:当任务到达时,可以不需要等待线程创建就能立即执行。
  • 提高线程的可管理性,线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配、监控和调优。

线程池大小确定

  • CPU密集型任务(N + 1):这种任务消耗的主要是CPU资源,可以将线程数设置为N(CPU核心数)+ 1,比CPU多出来一个线程是为了防止线程偶发的缺页中断,或者其他原因导致的任务暂停而带来的影响,一旦任务停止,CPU就会处于空闲状态,这种情况下,多出来的这一个线程就可以充分利用CPU的空闲状态。
  • I/O密集型(2N):这种任务应用起来,系统大部分时间用来处理I/O交互,而线程在处理I/O的时间段内不会占用CPU来处理,这时就可以将CPU交给其他线程使用,因此在I/O密接型任务,可以配置多一些线程,比如2N。

线程池执行任务的流程

  1. 线程池执行execute/submit方法向线程池添加任务,当任务小于核心线程数corePoolSize,线程池中可以创建新的线程。
  2. 当任务大于核心线程数corePoolSize,向阻塞队列添加任务。
  3. 如果阻塞队列已经满,需要通过比较参数maximumPoolSize,在线程池创建新的线程,当线程数据大于maximumPoolSize,说明当前设置的线程池中线程已经处理不了,就会执行饱和策略。

线程池常用的阻塞队列有哪些

知识体系总结(五)java基础、集合、并发、JVM_第12张图片

  • 固定线程池和单例线程池,默认使用的阻塞队列是容量为Interger.MAX_VALUE的LinkedBlockingQueue,可以认为是无界队列。由于FixedThread的线程数固定,所有当任务特别多时,需要一个没有容量限制的阻塞队列存放任务。

  • SynchronusQueue
    缓存线程池使用的阻塞队列为SynchronousQueue,缓存线程池的最大线程数是Interger的最大值,可以认为线程数是无限扩展的,所以缓存线程池的情况与上边正好相反,因为一旦有任务就可以创建新的线程,而不需要额外保存。

  • DelayedWorkQueue
    第三种阻塞队列是DelayedWorkQueue,它对应的线程池分别是 ScheduledThreadPool 和SingleThreadScheduledExecutor,这两种线程池的最大特点就是可以延迟执行任务,比如说一定时间后执行任务或是每隔一定的时间执行一次任务。

DelayedWorkQueue 的特点是内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构。之所以线程池 ScheduledThreadPool 和SingleThreadScheduledExecutor 选择DelayedWorkQueue,是因为它们本身正是基于时间执行任务的,而延迟队列正好可以把任务按时间进行排序,方便任务的执行。

源码中线程池是怎么复用线程的?

源码中ThreadPoolExecutor中有个内置对象Worker,每个worker都是一个线程,worker线程数量和参
数有关,每个worker会while死循环从阻塞队列中取数据,通过置换worker中Runnable对象,运行
其run方法起到线程置换的效果,这样做的好处是避免多线程频繁线程切换,提高程序运行性能。

JVM

JMM(java memory model,java内存模型)

  • java的并发采用“共享内存”模型,线程之间通过读写内存的公共状态进行通讯,多个线程之间是不能通过直接传递数据交互的,他们之间交互只能通过共享变量来实现。

  • ==JMM的主要目的是定义程序中各个变量的访问规则。==java线程之间的通信由JMM控制。JMM定义了JVM在计算机内存(RAM)中的工作方式,如果想深入理解java并发编程,就要先理解好java内存模型。

主内存与工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量与Java编程中的变量不是完全等同的。这里的变量指的是实例字段、静态字段、构成数组对象的元素,但不包括局部变量与方法参数,因为它们是线程私有的,不会被共享,自然就不会存在竞争的问题。

  • 主内存:java内存模型规定了所有的变量都存储在主内存中。
  • 工作内存:每条线程有自己的工作内存(可与物理硬件处理器的高速缓存类比),线程的工作内存中保存了该线程所使用的变量的主内存拷贝副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程间也无法访问对方工作内存中的变量,线程间的变量传递需要通过主内存完成。

在这里插入图片描述

工作内存与主内存的交互的8种方法

Java内存模型定义了8种方法来完成主内存与工作内存之间的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存。虚拟机实现的时候,必须每一种操作都是原子的、不可再分的。

  • lock(锁定):作用于主内存的变量,一个变量在同一时间只能一个线程锁定,该操作表示这条线成独占这个变量
  • unlock(解锁):作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定
  • read(读取):作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用。
  • load(载入):作用于线程的工作内存的变量,表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)
  • use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作
  • assign(赋值):作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作
  • store(存储):作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的write操作使用
  • write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中

如果要把一个变量从主内存传输到工作内存,那就要顺序的执行read和load操作,如果要把一个变量从工作内存回写到主内存,就要顺序的执行store和write操作。只需要保证相对顺序,不要求连续,下边两种执行结果是一样的:

  • read a; read b; load b; load a;
  • read a; load a; read b; load b;

在执行这8中操作的时候必须遵循如下的规则

  1. 不允许read和load、store和write操作必须成对出现,也就是不允许从主内存读取了变量的值但是工作内存不接收的情况,或者不允许从工作内存将变量的值回写到主内存但是主内存不接收的情况
  2. 不允许一个线程丢弃最近的assign操作,也就是不允许线程在自己的工作线程中修改了变量的值却不同步/回写到主内存
  3. 不允许一个线程回写没有修改的变量到主内存,也就是如果线程工作内存中变量没有发生过任何assign操作,是不允许将该变量的值回写到主内存
  4. 一个新的变量只能在主内存中产生,不允许在工作内存中直接使用一个未被初始化的变量,也就是没有执行load或者assign操作。也就是说在执行use、store之前必须对相同的变量执行了load、assign操作
  5. 一个变量在同一时刻只能被一个线程对其进行lock操作,也就是说一个线程一旦对一个变量加锁后,在该线程没有释放掉锁之前,其他线程是不能对其加锁的,但是同一个线程对一个变量加锁后,可以继续加锁,同时在释放锁的时候释放锁次数必须和加锁次数相同。
  6. 对变量执行lock操作,就会清空工作空间该变量的值,执行引擎使用这个变量之前,需要重新load或者assign操作初始化变量的值
  7. 不允许对没有lock的变量执行unlock操作,如果一个变量没有被lock操作,那也不能对其执行unlock操作,当然一个线程也不能对被其他线程lock的变量执行unlock操作
  8. 对一个变量执行unlock之前,必须先把变量同步回主内存中,也就是执行store和write操作

JVM内存结构

JVM内存结构是怎样的?

知识体系总结(五)java基础、集合、并发、JVM_第13张图片
JDK1.7中,将1.6中永久代的字符串常量池和静态变量等移动到了堆中。
知识体系总结(五)java基础、集合、并发、JVM_第14张图片
JDK1.8完全放弃了“永久代”,改用了在本地内存中实现的“元空间”,将1.7中永久代剩下的部分(主要是类型信息)移动到了元空间(放在内存中)。
知识体系总结(五)java基础、集合、并发、JVM_第15张图片
jvm将虚拟机分为5大区域,程序计数器、虚拟机栈、本地方法栈、java堆、方法区;

  • 程序计数器:线程私有,是一块很小的内存空间,作为当前线程的执行的代码行号指示器,用于记录当前虚拟机正在执行的线程指令地址。
  • 虚拟机栈(Java栈):线程私有,每个方法执行的时候创建一个栈帧,用于存储局部变量表,操作数、动态链接和方法返回等信息,当栈深度超过了虚拟机允许的最大深度,就会抛出StackOverFlowError
  • 本地方法栈:线程私有,保存的是native方法的信息,当一个JVM创建的线程调用native方法后,jvm不会在虚拟机栈中为该线程创建栈帧,而是简单的动态链接并直接调用该方法。
  • 堆:所有线程共享的一块内存,几乎所有对象的实例和数组都在堆上分配内存,因此堆区经常发生垃圾回收操作。
  • 方法区:存放已经加载的类信息,常量、静态变量、即时编译器编译后的代码数据。jdk1.8中不存在方法区了,被元数据区替代了,原方法区被分成两部分:1、加载类信息。2、运行时常量池。加载类信息保存在元数据区,运行时常量池保存在堆中。

谈谈对OOM的认识,如何排查OOM问题

除了程序计数器,其他内存区域都有OOM(out of memory, 内存溢出)的风险。

  • 栈一般经常发生StackOverFlowError,比如32位系统,单进程限制2G内存,无限创建线程就发生栈的OOM
  • 堆内存溢出:GC之后无法在堆中申请内存创建对象。
  • 方法区OOM:经常会遇到的是动态生成大量的类,jsp等
  • 直接内存 OOM,涉及到 -XX:MaxDirectMemorySize 参数和 Unsafe 对象对内存的申请。

排查OOM的方法:

  • 增加两个参数 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof,当 OOM 发生时自动 dump 堆内存信息到指定目录;
  • jstat查看监控JVM的内存和GC情况,先观察问题大概出现在什么区域。
  • 使用MAT工具载入到dump文件,分析大对象的占用情况,比如HashMap做缓存未清理,时间长了就会内存溢出,可以改为弱引用。

谈谈JVM中的常量池

JVM常量池主要分为Class文件常量池、运行时常量池、全局字符串常量池、以及基本数据类包装类对象常量池。

Java6和6之前,常量池是存放在方法区(永久代)中的。

Java7,将常量池是存放到了堆中。

Java8之后,取消了整个永久代区域,取而代之的是元空间运行时常量池和静态常量池存放在元空间中,而字符串常量池依然存放在堆中。

  • class文件常量池:lass文件是一组以字节为单位的二进制数据流,在java代码的编译期间,我们编写的java文件就被编译为.class文件格式的二进制数据存放在磁盘中,其中就包括class文件常量池。(jdk1.8前处于方法区,1.8处于元空间)
  • 运行时常量池:在运行时可以通过代码生成常量并将其放入运行时常量池中。这种特性被用的最多的就是String.intern(),
  • 全局字符串常量池:JVM所维护的一个字符串实例的引用表
  • 基本类型包装类对象常量池:java中基本类型的包装类的大部分都实现了常量池技术,这些类是Byte,Short,Integer,Long,Character,Boolean,另外两种浮点数类型的包装类则没有实现。另外上面这5种整型的包装类也只是在对应值小于等于127时才可使用对象池,也即对象不负责创建和管理大于127的这些类的对象。

如何判断一个对象是否存活?GC Root通常是什么?

分为两种算法:

  1. 引用计数法:给每一个对象设置一个引用计数器,当有一个地方引用该对象的时候,引用计数器就+1,引用失效时,引用计数器就-1;当引用计数器为0的时候,就说明这个对象没有被引用,也就是垃圾对象,等待回收;缺点:无法解决循环引用的问题,当A引用B,B也引用A的时候,此时AB对象的引用都不为0,此时也就无法垃圾回收,所以一般主流虚拟机都不采用这个方法;
  2. 可达性分析法:
    从一个被称为GC Roots的对象向下搜索,如果一个对象到GC Roots没有任何引用链相连接,说明此对象不可用,在java中可以作为GC Roots的对象有以下几种。
    • 虚拟机栈中引用的对象、 本地方法栈JNI引用的对象
    • 方法区中静态属性引用的变量比如字符串常量池、静态变量。

java的四种引用是什么?

  • 强引用,普通对象引用关系,如String s = new String(“ConstXiong”)
  • ==软引用(SoftReference),用于维护一些可有可无的对象,比如缓存。==在内存不足时,系统会回收软引用对象,如果回收了软引用对象之后依旧没有足够内存,才会抛出内存溢出异常。
  • 弱引用(WeakReference),相比软引用的对象,要更加无用一些,它拥有更短的生命周期,当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。
  • 虚引用(PhantomReference),主要是用来跟踪对象被垃圾回收的活动,虚引用必须与引用队列配合使用。

GC(Garbage Collection,垃圾收集)

JVM的垃圾回收算法有哪些?

  1. 标记清除(mark sweep):分为两步,第一步:利用可达性去遍历内存,把存活对象和垃圾对象进行标记,第二步:在遍历一遍,将所有标记的对象回收掉;标记清除的缺点在于会产生大量内存碎片
  2. 分块拷贝算法(copying):将内存按照容量分为大小相等的两块,每次只使用其中一块,当一块内存不足时,将还存活的对象移动到另外一块,然后把使用过的那块整体清除回收。分块拷贝算法的缺点在于浪费空间
  3. 标记压缩(mark compact)类似于标记清除,但是标记清除的过程中对存活对象和垃圾对象集中整理,标记压缩不会产生没有内存碎片,但是效率极低
  4. 分代收集算法:
    如下图,将内存空间分为 2 / 3 的老年代(old),1 / 3 的新生代(young),其中young区又分出 8 / 10的 伊甸区(Eden),1 / 10的from幸存区和1 / 10 的to幸存区。

在这里插入图片描述

  • 对象优先在Eden区分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次 Minor GC(标记清除)。
    • 在 Eden 区执行了第一次 GC 之后,存活的对象会被移动到其中一个 Survivor 分区,假定为from区;
    • Eden 区再次 GC时会采用复制算法,将 Eden 和 from 区一起清理,存活的对象会被复制到 to 区;
    • 对象每移动一次,年龄加1,当对象年龄大于一定阀值会直接移动到老年代,这个阈值默认是15。
    • 幸存区内存不足时,触发分配担保机制,超过指定大小的对象会直接进入老年代。
  • 大对象如字符串、数组等需要大量连续内存空间的对象会直接进入老年代,避免为大对象分配内存时由于分配担保机制带来的复制导致的效率的降低。
  • 当老年代容量不足时,会进行Full GC,对所有的线程STW(stop the world),对所有的区域执行标记清除。
  1. 分区收集算法:将整个堆空间划分为多个连续的小区间,在每个小区间里,独立使用,独立回收,这样可以减少一次GC所产生的停顿。

常用的垃圾回收器有哪些?各自的优缺点是什么?

    1. Serial收集器(单线程+复制算法):“单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。对于限定单个CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此Serial垃圾收集器依然是java 虚拟机运行在Client 模式下默认的新生代垃圾收集器。
    1. ParNew收集器(Serial+多线程+复制算法): ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。ParNew 垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。ParNew垃圾收集器是很多java虚拟机运行在Server 模式下新生代的默认垃圾收集器。
    1. Parallel Scavenge:新生代收集器,复制算法的收集器,并发的多线程收集器,目标是达到一个可控的吞吐量,和ParNew的最大区别是GC自动调节策略;虚拟机会根据系统的运行状态收集性能监控信息,动态设置这些参数,以提供最优停顿时间和最高的吞吐量;
    1. Serial Old(单线程 + 标记整理) Serial收集器的老年代版本,单线程收集器。
    1. Parallel Old(多线程 + 标记整理) Parallel收集器的老年代版本.
    1. CMS(Concurrent Mark Sweep) 是一种年老代垃圾收集器,以获取最短回收停顿时间为目标,基于标记清除算法。CMS收集器是第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
      CMS回收过程分为以下四步:
      ① 、 初始标记:暂停所有的其他用户线程(STW),并记录下直接与 root 相连的对象,由于跟GC Root直接关联的下级对象不会很多,因此这个过程很快。
      ②、并发标记:同时开启 GC 和用户线程,标记所有可达的对象。这个阶段不能保证在结束的时候能标记完所有的可达对象,因为应用线程在运行,可能会导致部分引用的变更,导致一些活对象不可达。为了解决这个问题,这个算法里会跟踪记录这些发生引用更新的地方。
      ③、重新标记阶段:再次STW,修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。
      ④、并发清理:同时开启用户线程和GC 线程,对标记阶段判断已经死亡的对象进行清理。

final与static关键字

final关键字:

  1. final修饰的类不能被继承,final类中所有的成员方法都会被隐式的指定为final方法;
  2. final修饰的方法不能被重写;
  3. final修饰的变量是常量,如果是基本数据类型的变量,则数值一旦被初始化之后就不能再次被更改,如果是引用类型的变量,则在初始化之后就不能指向另一个对象。
/**
 * @author yangwuyi
 */
public class FinalTest {
    public static final List TEMPEXM = new ArrayList();

    public static void main(String[] args) {
        FinalTest.TEMPEXM.add("hello ");
        FinalTest.TEMPEXM.add("world !");
        System.out.println(FinalTest.TEMPEXM);
    }
}

static关键字:

  1. 静态内部类(static修饰类的话只能修饰内部类):静态内部类与非静态内部类之间存在一个最大的区别: 非静态内部类在编译完成之后会隐含地保存着一个引用,该引用是指向创建它的外围类,但是静态内部类却没有。没有这个引用就意味着:1. 它的创建是不需要依赖外围类的创建。2. 它不能使用任何外围类的非static成员变量和方法。
  2. 静态成员变量和成员方法:被static修饰的成员属于类,不属于单个这个类的某个对象,被类中所有对象共享,可以并且建议通过类名调用。被static 声明的成员变量属于静态成员变量,静态变量 存放在 Java 内存区域的方法区。调用格式:类名.静态变量名 类名.静态方法名()

final static注意事项

  1. final变量不可变的是句柄的地址,其实里面的值可以改变
    例如final修饰的类,成员变量是可修改的。
    final修饰的集合,是可以添加变量的。
  2. 一个类中有声明为static final的变量,jvm会对类的加载进行优化,先来看一个对比的例子:

知识体系总结(五)java基础、集合、并发、JVM_第16张图片
因为s是static final变量,且它等于helloWorld,在编译的时候就可以知道它的值,所以直接访问s的值不会引起Final类的初始化。作为表现,也就是static静态代码块不会被加载。

  1. final关键字声明的static变量(属于类)必须在声明、类初始化,因此final修饰变量,所有它不可被更改,而它又被static修饰,因此实际上它是常量指针,必须在赋值时初始化。

类加载

什么是类加载?类加载的过程?

虚拟机把描述类的数据加载到内存里,并对数据进行校验、解析和初始化,最终变成可以被虚拟机直接使用的class对象。
类的整个生命周期包括:
知识体系总结(五)java基础、集合、并发、JVM_第17张图片
加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)

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

类加载器是指:通过一个类的全限定性类名获取该类的二进制字节流叫做类加载器;类加载器分为以下四种:

  • 启动类加载器(BootStrapClassLoader):用来加载java核心类库,无法被java程序直接引用;
  • 扩展类加载器(Extension ClassLoader):用来加载java的扩展库,java的虚拟机实现会提供一个扩展库目录,该类加载器在扩展库目录里面查找并加载java类;
  • 应用程序类加载器(AppClassLoader):它根据java的类路径来加载类,一般来说,java应用的类都是通过它来加载的;
  • 自定义类加载器:由java语言实现,继承ClassLoader;

知识体系总结(五)java基础、集合、并发、JVM_第18张图片

什么是双亲委派机制?

双亲委派机制,是按照加载器的层级关系,逐层进行委派。例如要加载一个类MyClass.class,从低层级到高层级一级一级委派,先由应用层加载器委派给扩展类加载器,再由扩展类委派给启动类加载器;启动类加载器载入失败,再由扩展类加载器载入,扩展类加载器载入失败,最后由应用类加载器载入,如果应用类加载器也找不到那就报ClassNotFound异常了。
知识体系总结(五)java基础、集合、并发、JVM_第19张图片
双亲委派机制的优点:

  1. 保证安全性,层级关系代表了优先级,也就是所有类的加载,优先给启动类加载器,这样就保证了核心库类运行的安全性,防止核心API库被篡改,避免类的重复加载。
  2. 避免重复,如果父类加载器加载过了,子类加载器就没有必要再去加载了。

如何打破双亲委派机制?列举一些你知道的打破双亲委派机制的例子?为什么要打破?

打破双亲委派机制的方式:

通过构建自定义类加载器重写loadClass()方法来破坏委托加载的逻辑。在loadClass()方法中,默认的委派规则是将加载请求先委托给自己的父类,我们可以根据自己的需求决定是否由父类加载器加载还是自己直接加载类。

以下是一些常见的打破双亲委派机制的例子:

  • Tomcat热部署,通过自定义类加载器来加载应用程序的类,热部署过程中,新的类或者已经修改的类会被重新加载到自定义类加载器中,而不会受到父类加载器的影响,这样就实现了在应用程序运行时更新类定义的目的,而不需要停止和重新启动整个应用服务器。
  • Android中的插件化框架开发,插件化框架可以实现动态加载和运行插件,插件化框架会创建自己的类加载器,并直接加载插件中的类,而不是委派给系统加载器,这样可以实现插件级别的类隔离和动态扩展。

你可能感兴趣的:(Java,知识体系总结,java,jvm,开发语言)