Java高频面试题笔记(更新...)

Java基础

1.Java 语言有哪些特点

1.面向对象(封装,继承,多态);
2.平台无关性( Java 虚拟机实现平台无关性);
3.支持多线程
4.支持网络编程并且很方便( Java 语言诞生本身就是为简化网络编程设计的,因此 Java 语言不仅支持网络编程而且很方便);

2.JVM、JDK 、JRE

在这里插入图片描述
JVM:
Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。

JDK:
JDK 是 Java Development Kit 缩写,它是功能齐全的 Java SDK。它拥有 JRE 所拥有的一切,还有编译器(javac)和工具(如 javadoc 和 jdb)。它能够创建和编译程序。

JRE:
JRE 是 Java 运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,包括 Java 虚拟机(JVM),Java 类库,java 命令和其他的一些基础构件。但是,它不能用于创建新程序。

3.Java 和 C++的区别

1.都是面向对象的语言,都支持封装、继承和多态Java 不提供指针来直接访问内存,程序内存更加安全
2.Java 的类是单继承的,C++ 支持多重继承;虽然 Java 的类不可以多继承,但是接口可以多继承。
3.Java 有自动内存管理垃圾回收机制(GC),不需要程序员手动释放无用内存。
4.C ++同时支持方法重载和操作符重载,但是 Java 只支持方法重载(操作符重载增加了复杂性,这与 Java 最初的设计思想不符)。

4.字符型常量和字符串常量的区别

形式 : 字符常量是单引号引起的一个字符,字符串常量是双引号引起的 0 个或若干个字符

含义 : 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)

占内存大小 : 字符常量只占 2 个字节; 字符串常量占若干个字节 (注意: char 在 Java 中占两个字节

5.标识符和关键字的区别

标识符就是一个名字。但是有一些标识符,Java 语言已经赋予了其特殊的含义,只能用于特定的地方,这种特定的地方就称为关键字。

6.==和 equals 的区别

1.对于基本数据类型来说,==比较的是值。对于引用数据类型来说,==比较的是对象的内存地址。
2.equals() 作用不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。

7.在一个静态方法内调用一个非静态成员为什么是非法的

结合 JVM 的相关知识,静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,然后通过类的实例对象去访问。在类的非静态成员不存在的时候静态成员就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。

8.静态方法和实例方法有何不同

1.调用方式
在外部调用静态方法时,可以使用 类名.方法名 的方式,也可以使用 对象.方法名 的方式,而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象 。

2.访问类成员是否存在限制
静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),不允许访问实例成员(即实例成员变量和实例方法),而实例方法不存在这个限制。

9.为什么 Java 中只有值传递

值传递(pass by value)是指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。

引用传递(pass by reference)是指在调用函数时将实际参数的地址直接传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数。

首先明确两点
1.java中不管是值对象还是引用对象都是值传递,
2.在其他方法里面改变引用类型的值肯定是通过引用改变的
当传递引用对象的时候传递的是复制过的对象句柄(引用),注意这个引用是复制过的,也就是说又在内存中复制了一份句柄,这时候有两个句柄是指向同一个对象的,所以你改变这个句柄对应空间的数据会影响外部的变量的,虽然是复制的但是引用指向的是同一个地址,当你把这个句柄指向其他对象的引用时并不会改变原对象,因为你拿到的句柄是复制过的引用。总结java中的句柄(引用)是复制过的,所以说java只有值传递

10.重载和重写的区别

重载:
发生在同一个类中(或者父类和子类之间),方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。

重写
重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写。

1.返回值类型、方法名、参数列表必须相同,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。
2.如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明。
3.构造方法无法被重写

11.深拷贝与浅拷贝

浅拷贝:对基本数据类型进行值传递,对引用数据类型进行引用传递般的拷贝,此为浅拷贝。
深拷贝:对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容,此为深拷贝。

12.整型包装类值的比较

Integer num= ? 在-128~127之间赋值,直接使用的是常量池中的对象,可以使用==进行判断,但是在这个区间以外的数据,会产生新的对象。

13.使用 BigDecimal 来定义浮点数

浮点数之间的等值判断,基本数据类型不能用==来比较,包装数据类型不能用 equals 来判断。

14.final 关键字

final 关键字,意思是最终的、不可修改的,最见不得变化 ,用来修饰类、方法和变量,具有以下特点:

final 修饰的类不能被继承,final 类中的所有成员方法都会被隐式的指定为 final 方法;

final 修饰的方法不能被重写;

final 修饰的变量是常量,如果是基本数据类型的变量,则其数值一旦在初始化之后便不能更改;如果是引用类型的变量,则在对其初始化之后便不能让其指向另一个对象。

15.super 关键字

super 关键字用于从子类访问父类的变量和方法。
在构造器中使用 super() 调用父类中的其他构造方法时,该语句必须处于构造器的首行,否则编译器会报错。另外,this 调用本类中的其他构造方法时,也要放在首行。

16.static 关键字

static 关键字主要有以下四种使用场景
1.修饰成员变量和成员方法
2.静态代码块
3.修饰类(只能修饰内部类)
4.静态导包(用来导入类中的静态资源,1.5 之后的新特性)

修饰成员变量和成员方法:
被 static 修饰的成员属于类,不属于单个这个类的某个对象,被类中所有对象共享,可以并且建议通过类名调用。被 static 声明的成员变量属于静态成员变量,静态变量存放在 Java 内存区域的方法区。

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

静态代码块:
静态代码块定义在类中方法外, 静态代码块在非静态代码块之前执行(静态代码块 —> 非静态代码块 —> 构造方法)。 该类不管创建多少对象,静态代码块只执行一次.

静态内部类:
静态内部类与非静态内部类之间存在一个最大的区别,我们知道非静态内部类在编译完成之后会隐含地保存着一个引用,该引用是指向创建它的外围类,但是静态内部类却没有。没有这个引用就意味着:

它的创建是不需要依赖外围类的创建。
它不能使用任何外围类的非 static 成员变量和方法。

静态导包:

格式为:import static

这两个关键字连用可以指定导入某个类中的指定静态资源,并且不需要使用类名调用类中静态成员,可以直接使用类中静态成员变量和成员方法

17.静态代码块与非静态代码块(构造代码块)

相同点: 都是在 JVM 加载类时且在构造方法执行之前执行,在类中都可以定义多个,定义多个时按定义的顺序执行,一般在代码块中对一些 static 变量进行赋值。

不同点: 静态代码块在非静态代码块之前执行(静态代码块 -> 非静态代码块 -> 构造方法)。静态代码块只在第一次 new 执行一次,之后不再执行,而非静态代码块在每 new 一次就执行一次。 非静态代码块可在普通方法中定义(不过作用不大);而静态代码块不行。

18.JDK 动态代理机制

在 Java 动态代理机制中 InvocationHandler 接口和 Proxy 类是核心。
Proxy 类中使用频率最高的方法是:newProxyInstance() ,这个方法主要用来生成一个代理对象。

public static Object newProxyInstance(ClassLoader loader,
                                          Class<?>[] interfaces,
                                          InvocationHandler h)
    throws IllegalArgumentException
{
    
    
    ......
}

这个方法一共有 3 个参数:

1.loader :类加载器,用于加载代理对象。
2.interfaces : 被代理类实现的一些接口;
3.h : 实现了 InvocationHandler 接口的对象;
要实现动态代理的话,还必须需要实现InvocationHandler 来自定义处理逻辑。 当我们的动态代理对象调用一个方法时,这个方法的调用就会被转发到实现InvocationHandler 接口类的 invoke 方法来调用。

public interface InvocationHandler {
    
    

    /**
     * 当你使用代理对象调用方法的时候实际会调用到这个方法
     */
    public Object invoke(Object proxy, Method method, Object[] args)
        throws Throwable;
}

invoke() 方法有下面三个参数:

1.proxy :动态生成的代理类
2.method : 与代理类对象调用的方法相对应
3.args : 当前 method 方法的参数
也就是说:你通过Proxy 类的 newProxyInstance() 创建的代理对象在调用方法的时候,实际会调用到实现InvocationHandler 接口的类的 invoke()方法。 你可以在 invoke() 方法中自定义处理逻辑,比如在方法执行前后做什么事情。

示例:
1.定义发送短信的接口

public interface SmsService {
    
    
    String send(String message);
}

2.实现发送短信的接口

public class SmsServiceImpl implements SmsService {
    
    
    public String send(String message) {
    
    
        System.out.println("send message:" + message);
        return message;
    }
}

3.定义一个 JDK 动态代理类

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

/**
 * @author shuang.kou
 * @createTime 2020年05月11日 11:23:00
 */
public class DebugInvocationHandler implements InvocationHandler {
    
    
    /**
     * 代理类中的真实对象
     */
    private final Object target;

    public DebugInvocationHandler(Object target) {
    
    
        this.target = target;
    }


    public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException {
    
    
        //调用方法之前,我们可以添加自己的操作
        System.out.println("before method " + method.getName());
        Object result = method.invoke(target, args);
        //调用方法之后,我们同样可以添加自己的操作
        System.out.println("after method " + method.getName());
        return result;
    }
}

invoke() 方法: 当我们的动态代理对象调用原生方法的时候,最终实际上调用到的是 invoke() 方法,然后 invoke() 方法代替我们去调用了被代理对象的原生方法。

4.获取代理对象的工厂类

public class JdkProxyFactory {
    
    
    public static Object getProxy(Object target) {
    
    
        return Proxy.newProxyInstance(
                target.getClass().getClassLoader(), // 目标类的类加载
                target.getClass().getInterfaces(),  // 代理需要实现的接口,可指定多个
                new DebugInvocationHandler(target)   // 代理对象对应的自定义 InvocationHandler
        );
    }
}

getProxy() :主要通过Proxy.newProxyInstance()方法获取某个类的代理对象

5.使用

SmsService smsService = (SmsService) JdkProxyFactory.getProxy(new SmsServiceImpl());
smsService.send("java");


Java面向对象

1.面向对象和面向过程的区别

面向过程 :
面向过程性能比面向对象高。 因为类调用时需要实例化,开销比较大,比较消耗资源,所以当性能是最重要的考量因素的时候,比如单片机、嵌入式开发、Linux/Unix 等一般采用面向过程开发。但是,面向过程没有面向对象易维护、易复用、易扩展。

面向对象 :
面向对象易维护、易复用、易扩展。 因为面向对象有封装、继承、多态性的特性,所以可以设计出低耦合的系统,使系统更加灵活、更加易于维护。但是,面向对象性能比面向过程低。

2.成员变量与局部变量的区别

1。从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;成员变量可以被 public,private,static 等修饰符所修饰,而局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。
2.从变量在内存中的存储方式来看,如果成员变量是使用 static 修饰的,那么这个成员变量是属于类的,如果没有使用 static 修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。
3.从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动消失。
4.从变量是否有默认值来看,成员变量如果没有被赋初,则会自动以类型的默认值而赋值(一种情况例外:被 final 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。

3.对象实体与对象引用有何不同

对象实例在堆内存中,对象引用指向对象实例(对象引用存放在栈内存中)。
一个对象引用可以指向 0 个或 1 个对象;一个对象可以有 n 个引用指向它。

4.对象的相等与指向他们的引用相等,两者有什么不同

对象的相等,比的是内存中存放的内容是否相等。而引用相等,比较的是他们指向的内存地址是否相等。

5.String StringBuffer 和 StringBuilder 的区别是什么

可变性:
String 类中使用 final 关键字修饰字符数组来保存字符串,private final char value[],所以String 对象是不可变的。而 StringBuilder 与 StringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串char[]value 但是没有用 final 关键字修饰,所以这两种对象都是可变的。

线程安全性:

String 中的对象是不可变的,也就可以理解为常量,线程安全。AbstractStringBuilder 是 StringBuilder 与 StringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。

三者使用的总结:

操作少量的数据: 适用 String
单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer


集合类

1.List,Set,Map三者的区别

List: 有序集合(有序指存入的顺序和取出的顺序相同,不是按照元素的某些特性排序),可存储重复元素,可存储多个null。
Set: 无序集合(元素存入和取出顺序不一定相同),不可存储重复元素,只能存储一个null。
Map: 使用键值对的方式对元素进行存储,key是无序的,且是唯一的。value值不唯一。不同的key值可以对应相同的value值。

2.常用集合框架底层数据结构

List:
ArrayList:数组
LinkedList:双线链表

Set:
HashSet:底层基于HashMap实现,HashSet存入读取元素的方式和HashMap中的Key是一致的。
TreeSet:红黑树

Map:
HashMap: JDK1.8之前HashMap由数组+链表组成的, JDK1.8之后有数组+链表/红黑树组成,当链表长度大于8时,链表转化为红黑树,当长度小于6时,从红黑树转化为链表。这样做的目的是能提高HashMap的性能,因为红黑树的查找元素的时间复杂度远小于链表。
LinkedHashMap: LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。
HashTable:数组+链表
TreeMap:红黑树

Queue:
PriorityQueue: Object[] 数组来实现二叉堆
ArrayQueue: Object[] 数组 + 双指针

3.哪些集合类是线程安全的

Vector:相当于有同步机制的ArrayList
Stack:栈
HashTable
enumeration:枚举

4.Java集合的快速失败机制 “fail-fast”和安全失败机制“fail-safe”是什么

快速失败:
Java的快速失败机制是Java集合框架中的一种错误检测机制,当多个线程同时对集合中的内容进行修改时可能就会抛出ConcurrentModificationException异常。其实不仅仅是在多线程状态下,在单线程中用增强for循环中一边遍历集合一边修改集合的元素也会抛出ConcurrentModificationException异常。看下面代码

public class Main{
    
    
    public static void main(String[] args) {
    
    
    List<integer> list = new ArrayList&lt;&gt;();
        for(Integer i : list){
    
    
            list.remove(i);  //运行时抛出ConcurrentModificationException异常
        }
    }
}

正确的做法是用迭代器的remove()方法,便可正常运行。

public class Main{
    
    
    public static void main(String[] args) {
    
    
    List<integer> list = new ArrayList&lt;&gt;();
    Iterator<integer> it = list.iterator();
        while(it.hasNext()){
    
    
            it.remove();
        }
    }
}

造成这种情况的原因是什么?可以发现两次调用的remove()方法不同,一个带参数据,一个不带参数,这个后面再说,经过查看ArrayList源码,找到了抛出异常的代码

final void checkForComodification() {
    
    
      if (modCount != expectedModCount)
              throw new ConcurrentModificationException();
}

从上面代码中可以看到如果modCount和expectedModCount这两个变量不相等就会抛出ConcurrentModificationException异常。那这两个变量又是什么呢?继续看源码

protected transient int modCount = 0; //在AbstractList中定义的变量

从上面代码可以看到,modCount初始值为0,而expectedModCount初始值等于modCount。也就是说在遍历的时候直接调用集合的remove()方***导致modCount不等于expectedModCount进而抛出ConcurrentModificationException异常,而使用迭代器的remove()方法则不会出现这种问题。那么只能在看看remove()方法的源码找找原因了

public E remove(int index) {
    
    
    rangeCheck(index);
 
    modCount++;
    E oldValue = elementData(index);
 
    int numMoved = size - index - 1;
    if (numMoved &gt; 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work
 
    return oldValue;
}

从上面代码中可以看到只有modCount++了,而expectedModCount没有操作,当每一次迭代时,迭代器会比较expectedModCount和modCount的值是否相等,所以在调用remove()方法后,modCount不等于expectedModCount了,这时就了报ConcurrentModificationException异常。但用迭代器中remove()的方法为什么不抛异常呢?原来迭代器调用的remove()方法和上面的remove()方法不是同一个!迭代器调用的remove()方法长这样:


public void remove() {
    
    
    if (lastRet &lt; 0)
        throw new IllegalStateException();
    checkForComodification();
 
    try {
    
    
        ArrayList.this.remove(lastRet);
        cursor = lastRet;
        lastRet = -1;
        expectedModCount = modCount;    //这行代码保证了expectedModCount和modCount是相等的
    } catch (IndexOutOfBoundsException ex) {
    
    
        throw new ConcurrentModificationException();
    }
}

从上面代码可以看到expectedModCount = modCount,所以迭代器的remove()方法保证了expectedModCount和modCount是相等的,进而保证了在增强for循环中修改集合内容不会报ConcurrentModificationException异常。

上面介绍的只是单线程的情况,用迭代器调用remove()方法即可正常运行,但如果是多线程会怎么样呢?

答案是在多线程的情况下即使用了迭代器调用remove()方法,还是会报ConcurrentModificationException异常。这又是为什么呢?还是要从expectedModCount和modCount这两个变量入手分析,刚刚说了modCount在AbstractList类中定义,而expectedModCount在ArrayList内部类中定义,所以modCount是个共享变量而expectedModCount是属于线程各自的。简单说,线程1更新了modCount和属于自己的expectedModCount,而在线程2看来只有modCount更新了,expectedModCount并未更新,所以会抛出ConcurrentModificationException异常。

安全失败:
采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会抛出ConcurrentModificationException异常。缺点是迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生了修改,迭代器是无法访问到修改后的内容。java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用。

5.Array 和 ArrayList 有何区别

Array可以包含基本类型和对象类型,ArrayList只能包含对象类型。
Array大小是固定的,ArrayList的大小是动态变化的。(ArrayList的扩容是个常见面试题)
相比于Array,ArrayList有着更多的内置方法,如addAll(),removeAll()。
对于基本类型数据,ArrayList 使用自动装箱来减少编码工作量;而当处理固定大小的基本数据类型的时候,这种方式相对比较慢,这时候应该使用Array。

6.comparable 和 comparator的区别

comparable接口出自java.lang包,可以理解为一个内比较器,因为实现了Comparable接口的类可以和自己比较,要和其他实现了Comparable接口类比较,可以使用compareTo(Object obj)方法。compareTo方法的返回值是int,有三种情况:
返回正整数(比较者大于被比较者)
返回0(比较者等于被比较者)
返回负整数(比较者小于被比较者)

comparator接口出自java.util 包,它有一个compare(Object obj1, Object obj2)方法用来排序,返回值同样是int,有三种情况,和compareTo类似。

// 定制排序的用法
Collections.sort(arrayList, new Comparator<Integer>() {
    
    
    @Override
    public int compare(Integer o1, Integer o2) {
    
    
         return o2.compareTo(o1);
    }
});

它们之间的区别:
很多包装类都实现了comparable接口,像Integer、String等,所以直接调用Collections.sort()直接可以使用。如果对类里面自带的自然排序不满意,而又不能修改其源代码的情况下,使用Comparator就比较合适。此外使用Comparator可以避免添加额外的代码与我们的目标类耦合,同时可以定义多种排序规则,这一点是Comparable接口没法做到的,从灵活性和扩展性讲Comparator更优,故在面对自定义排序的需求时,可以优先考虑使用Comparator接口。

一般我们需要对一个集合使用自定义排序时,我们就要重写compareTo()方法或compare()方法,当我们需要对某一个集合实现两种排序方式,比如一个 song 对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写compareTo()方法和使用自制的Comparator方法或者以两个 Comparator 来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的 Collections.sort().

7.遍历一个 List 有哪些不同的方式

先说一下常见的元素在内存中的存储方式,主要有两种:

顺序存储(Random Access):相邻的数据元素在内存中的位置也是相邻的,可以根据元素的位置(如ArrayList中的下表)读取元素。
链式存储(Sequential Access):每个数据元素包含它下一个元素的内存地址,在内存中不要求相邻。例如LinkedList。

主要的遍历方式主要有三种:

for循环遍历:遍历者自己在集合外部维护一个计数器,依次读取每一个位置的元素。
Iterator遍历:基于顺序存储集合的Iterator可以直接按位置访问数据。基于链式存储集合的Iterator,需要保存当前遍历的位置,然后根据当前位置来向前或者向后移动指针。
foreach遍历:foreach 内部也是采用了Iterator的方式实现,但使用时不需要显示地声明Iterator。

那么对于以上三种遍历方式应该如何选取呢?

在Java集合框架中,提供了一个RandomAccess接口,该接口没有方法,只是一个标记。通常用来标记List的实现是否支持RandomAccess。所以在遍历时,可以先判断是否支持RandomAccess(list instanceof RandomAccess),如果支持可用 for循环遍历,否则建议用Iterator或 foreach遍历。

8.ArrayList的扩容机制

ArrayList的初始容量为10,扩容时对是旧的容量值加上旧的容量数值进行右移一位(位运算,相当于除以2,位运算的效率更高),所以每次扩容都是旧的容量的1.5倍。

9.ArrayList 和 LinkedList 的区别是什么

1.是否线程安全:ArrayList和LinkedList都是不保证线程安全的
2.底层实现:ArrayList的底层实现是数组,LinkedList的底层是双向链表。
3.内存占用:ArrayList会存在一定的空间浪费,因为每次扩容都是之前的1.5倍,而LinkedList中的每个元素要存放直接后继和直接前驱以及数据,所以对于每个元素的存储都要比ArrayList花费更多的空间。
4.应用场景:ArrayList的底层数据结构是数组,所以在插入和删除元素时的时间复杂度都会收到位置的影响,平均时间复杂度为o(n),在读取元素的时候可以根据下标直接查找到元素,不受位置的影响,平均时间复杂度为o(1),所以ArrayList更加适用于多读,少增删的场景。LinkedList的底层数据结构是双向链表,所以插入和删除元素不受位置的影响,平均时间复杂度为o(1),如果是在指定位置插入则是o(n),因为在插入之前需要先找到该位置,读取元素的平均时间复杂度为o(n)。所以LinkedList更加适用于多增删,少读写的场景。

10.ArrayList 和 Vector 的区别是什么

相同点:
都实现了List接口
底层数据结构都是数组

不同点:
线程安全:Vector使用了Synchronized来实现线程同步,所以是线程安全的,而ArrayList是线程不安全的。
性能:由于Vector使用了Synchronized进行加锁,所以性能不如ArrayList。
扩容:ArrayList和Vector都会根据需要动态的调整容量,但是ArrayList每次扩容为旧容量的1.5倍,而Vector每次扩容为旧容量的2倍。

11.简述 ArrayList、Vector、LinkedList 的存储性能和特性

ArrayList底层数据结构为数组,对元素的读取速度快,而增删数据慢,线程不安全。
LinkedList底层为双向链表,对元素的增删数据快,读取慢,线程不安全。
Vector的底层数据结构为数组,用Synchronized来保证线程安全,性能较差,但线程安全。

12.HashSet 的实现原理

HashSet的底层是HashMap,默认构造函数是构建一个初始容量为16,负载因子为0.75 的HashMap。HashSet的值存放于HashMap的key上,HashMap的value统一为PRESENT。

13.HashSet如何检查重复

这里面涉及到了HasCode()和equals()两个方法。
String类中重写的equals方法。


String类中重写的equals方法。

public boolean equals(Object anObject) {
    
    
    if (this == anObject) {
    
    
        return true;
    }
    if (anObject instanceof String) {
    
    
        String anotherString = (String)anObject;
        int n = value.length;
        if (n == anotherString.value.length) {
    
    
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
    
    
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

从源码中可以看到:
equals方法首先比较的是内存地址,如果内存地址相同,直接返回true;如果内存地址不同,再比较对象的类型,类型不同直接返回false;类型相同,再比较值是否相同;值相同返回true,值不同返回false。
总结一下,equals会比较内存地址、对象类型、以及值,内存地址相同,equals一定返回true;对象类型和值相同,equals方法一定返回true。
如果没有重写equals方法,那么equals和==的作用相同,比较的是对象的地址值。

hashCode:

hashCode方法返回对象的散列码,返回值是int类型的散列码。散列码的作用是确定该对象在哈希表中的索引位置。

关于hashCode有一些约定:
1.两个对象相等,则hashCode一定相同。
2.两个对象有相同的hashCode值,它们不一定相等。
3.hashCode()方法默认是对堆上的对象产生独特值,如果没有重写hashCode()方法,则该类的两个对象的hashCode值肯定不同

介绍完equals()方法和hashCode()方法,继续说下HashSet是如何检查重复的。

HashSet的特点是存储元素时无序且唯一,在向HashSet中添加对象时,首先会计算对象的HashCode值来确定对象的存储位置,如果该位置没有其他对象,直接将该对象添加到该位置;如果该存储位置有存储其他对象(新添加的对象和该存储位置的对象的HashCode值相同),调用equals方法判断两个对象是否相同,如果相同,则添加对象失败,如果不相同,则会将该对象重新散列到其他位置。

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

因为HashMap是通过key的hash值来确定存储的位置,但Hash值的范围是-2147483648到2147483647,不可能建立一个这么大的数组来覆盖所有hash值。所以在计算完hash值后会对数组的长度进行取余操作,如果数组的长度是2的幂次方,(length - 1)&hash等同于hash%length,可以用(length - 1)&hash这种位运算来代替%取余的操作进而提高性能。

(length - 1)&hash解释:使用01111111…与hashcode取交,提高性能。

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

15.HashMap的扩容操作是怎么实现的

初始值为16,负载因子为0.75,阈值为负载因子*容量

resize()方法是在hashmap中的键值对大于阀值时或者初始化时,就调用resize()方法进行扩容。

每次扩容,容量都是之前的两倍

扩容时有个判断e.hash & oldCap是否为零,也就是相当于hash值对数组长度的取余操作,若等于0,则位置不变,若等于1,位置变为原位置加旧容量。

16.HashMap默认加载因子为什么选择0.75

这个主要是考虑空间利用率和查询成本的一个折中。如果加载因子过高,空间利用率提高,但是会使得哈希冲突的概率增加;如果加载因子过低,会频繁扩容,哈希冲突概率降低,但是会使得空间利用率变低。具体为什么是0.75,不是0.74或0.76,这是一个基于数学分析(泊松分布)和行业规定一起得到的一个结论。

17.为什么要将链表中转红黑树的阈值设为8?为什么不一开始直接使用红黑树

因为红黑树的节点所占的空间是普通链表节点的两倍,但查找的时间复杂度低,所以只有当节点特别多时,红黑树的优点才能体现出来。至于为什么是8,是通过数据分析统计出来的一个结果,链表长度到达8的概率是很低的,综合链表和红黑树的性能优缺点考虑将大于8的链表转化为红黑树。
链表转化为红黑树除了链表长度大于8,还要HashMap中的数组长度大于64。也就是如果HashMap长度小于64,链表长度大于8是不会转化为红黑树的,而是直接扩容。

18.HashMap是怎么解决哈希冲突的

哈希冲突:hashMap在存储元素时会先计算key的hash值来确定存储位置,因为key的hash值计算最后有个对数组长度取余的操作,所以即使不同的key也可能计算出相同的hash值,这样就引起了hash冲突。hashMap的底层结构中的链表/红黑树就是用来解决这个问题的。

HashMap中的哈希冲突解决方式可以主要从三方面考虑(以JDK1.8为背景)

拉链法:
HasMap中的数据结构为数组+链表/红黑树,当不同的key计算出的hash值相同时,就用链表的形式将Node结点(冲突的key及key对应的value)挂在数组后面。

hash函数:
key的hash值经过两次扰动,key的hashCode值与key的hashCode值的右移16位进行异或,然后对数组的长度取余(实际为了提高性能用的是位运算,但目的和取余一样),这样做可以让hashCode取值出的高位也参与运算,进一步降低hash冲突的概率,使得数据分布更平均。

JDK 1.8 HashMap 的 hash 方法源码:

    static final int hash(Object key) {
    
    
      int h;
      // key.hashCode():返回散列值也就是hashcode
      // ^ :按位异或
      // >>>:无符号右移,忽略符号位,空位都以0补齐
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  }

原因:由于和(length-1)与运算,length 绝大多数情况小于2的16次方。所以始终是hashcode 的低16位(甚至更低)参与运算。要是高16位也参与运算,会让得到的下标更加散列。

红黑树:
在拉链法中,如果hash冲突特别严重,则会导致数组上挂的链表长度过长,性能变差,因此在链表长度大于8时,将链表转化为红黑树,可以提高遍历链表的速度(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)。

19.HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标

hashCode()处理后的哈希值范围太大,不可能在内存建立这么大的数组。

20.能否使用任何类作为 Map 的 key

需要重写equals()方法
而且重写了 equals()方法,也应该重写hashCode()方法。
最好定义key类是不可变的,这样key对应的hashCode()值可以被缓存起来,性能更好,这也是为什么String特别适合作为HashMap的key。

21.为什么HashMap中String、Integer这样的包装类适合作为Key

这些包装类都是final修饰,是不可变性的, 保证了key的不可更改性,不会出现放入和获取时哈希值不同的情况。
它们内部已经重写过hashcode(),equal()等方法。

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

重写hashCode()方法,因为需要计算hash值确定存储位置
重写equals()方法,因为需要保证key的唯一性。

23.HashMap 多线程导致死循环问题

由于JDK1.7的hashMap遇到hash冲突采用的是头插法,在多线程情况下会存在死循环问题,但JDK1.8已经改成了尾插法,不存在这个问题了。但需要注意的是JDK1.8中的HashMap仍然是不安全的,在多线程情况下使用仍然会出现线程安全问题。

24.HashTable的底层实现

HashTable的底层数据结构是数组+链表,链表主要是为了解决哈希冲突,并且整个数组都是synchronized修饰的,所以HashTable是线程安全的,但锁的粒度太大,锁的竞争非常激烈,效率很低。

25.PriorityQueue

PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。

这里列举其相关的一些要点:

PriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据
PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。
PriorityQueue 是非线程安全的,且不支持存储 NULL 和 non-comparable 的对象。
PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。
PriorityQueue 在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序、求第K大的数、带权图的遍历等,所以需要会熟练使用才行。

优先级队列实现小顶堆:(采用匿名Lambda方法)

//源码:
public PriorityQueue(Comparator<? super E> comparator) {
    
    
        this(DEFAULT_INITIAL_CAPACITY, comparator);
    }
//测试:
 PriorityQueue heap=new PriorityQueue<Integer>((n1,n2)->n1-n2);

优先级队列实现大顶堆:

 PriorityQueue heap=new PriorityQueue<Integer>((n1,n2)->n2-n1);

26.HashMap 和 Hashtable 的区别

1.线程是否安全:
HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);
2.效率:
因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,不要在代码中使用它;
3.对 Null key 和 Null value 的支持:
HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException。
4.初始容量大小和每次扩充容量大小的不同 :
① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。
5.底层数据结构:
JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。

27.HashMap 和 HashSet 区别

HashMap
实现了 Map 接口
存储键值对
调用 put()向 map 中添加元素
HashMap 使用键(Key)计算 hashcode

HashSet
实现 Set 接口
仅存储对象
调用 add()方法向 Set 中添加元素
HashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以equals()方法用来判断对象的相等性

28.HashMap 和 TreeMap 区别

TreeMap 和HashMap 都继承自AbstractMap ,但是需要注意的是TreeMap它还实现了NavigableMap接口和SortedMap 接口。实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。

实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。

TreeMap<Person, String> treeMap = new TreeMap<>(new Comparator<Person>() {
    
    
            @Override
    public int compare(Person person1, Person person2) {
    
    
         int num = person1.getAge() - person2.getAge();
         return Integer.compare(num, 0);
    }
});

29.currenthashmap怎么保证线程安全?

在JDK1.7中ConcurrentHashMap采用了数组+Segment+分段锁的方式实现。在数组基础上增加了一层Segment,一个Segment对应数组的一段,这样对某段进行的操作只需要锁住对应段,不影响其他段的操作。其中,Segment继承了ReentrantLock并实现了序列化接口,说明Segment的锁是可重入的。

在jdk1.8中取消了Segment分段锁的数据结构,取而代之的是Node,Node的value和next都是由volatile关键字进行修饰,可以保证可见性。将细化的粒度从段进一步降低到节点。线程安全实现上,采用CAS+Synchronized替代Segment分段锁。

并发

1.什么是进程

进程是程序的一次执行过程,是系统运行的基本单位,系统运行一个程序就是一个进程从创建到,运行到消亡的过程。

2.什么是线程

线程与进程类似,但是线程是比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是,同类的多个线程共享栈和方法区资源,但每个线程有自己的程序计数器,虚拟机栈和本地方法栈,所以系统在产生一个进程,或是在各个线程之间切换工作时,负担比进程小得多,正因为如此,线程也被称为轻量级进程。

3.程序计数器为什么是私有的

程序计数器主要有下面两个作用:

1.字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
2.在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。

所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。

4.虚拟机栈和本地方法栈为什么是私有的

虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

5.堆和方法区

堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

6. 并发与并行的区别

并发: 同一时间段,多个任务都在执行 (单位时间内不一定同时执行);
并行: 单位时间内,多个任务同时执行。

7. 为什么要使用多线程呢

从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销
从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

8. 使用多线程可能带来什么问题

并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。

9.线程的生命周期和状态

线程的一个完整的生命周期中通常要经历如下的五种状态:
1.新建:当一个Thread类或其子类的对象被声明并创建时,新生的线程对象处于新建
状态;
2.就绪:处于新建状态的线程被start()后,将进入线程队列等待CPU时间片,此时它已
具备了运行的条件,只是没分配到CPU资源;
3.运行:当就绪的线程被调度并获得CPU资源时,便进入运行状态, run()方法定义了线
程的操作和功能;
4.阻塞:在某种特殊情况下,被人为挂起或执行输入输出操作时,让出 CPU 并临时中
止自己的执行,进入阻塞状态;
5.死亡:线程完成了它的全部工作或线程被提前强制性地中止或出现异常导致结束。

线程状态的转换如下图所示:
在这里插入图片描述

10. 什么是上下文切换

线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。

1.主动让出 CPU,比如调用了 sleep(), wait() 等。
2.时间片用完,因为操作系统要防止一个线程或者进程长时间占用CPU导致其他线程或者进程饿死。
3.调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
4.被终止或结束运行

这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。

上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。

11.线程死锁

线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
在这里插入图片描述
产生死锁必须具备以下四个条件:

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

12. 如何预防和避免线程死锁

如何预防死锁? 破坏死锁的产生的必要条件即可:

破坏请求与保持条件 :一次性申请所有的资源。
破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

如何避免死锁?

避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
安全状态 指的是系统能够按照某种进程推进顺序(P1、P2、P3…Pn)来为每个进程分配所需资源,直到满足每个进程对资源的最大需求,使每个进程都可顺利完成。称<P1、P2、P3…Pn>序列为安全序列。

13.谈谈对synchronized的理解

synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

14. sleep() 方法和 wait() 方法区别和共同点

两者最主要的区别在于:sleep() 方法没有释放锁,而 wait() 方法释放了锁
两者都可以暂停线程的执行。
wait() 通常被用于线程间交互/通信,sleep() 通常被用于暂停执行。
wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒。

15. 为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

这是另一个非常经典的 Java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结: 调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

16.synchronized关键字的使用场景

1.修饰实例方法
修饰实例方法,作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。

2.修饰静态方法
给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份)。所以,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

3.修饰代码块
指定加锁对象,对给定对象/类加锁。synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁。synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁。

17.volatile 关键字

先要从 CPU 缓存模型 说起
类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题

我们甚至可以把 内存可以看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。

总结:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。

CPU Cache 的工作方式:

先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !比如我执行一个 i++操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 1++运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。

在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。

要解决这个问题,就需要把变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

所以,volatile 关键字 除了防止 JVM 的指令重排 ,还有一个重要的作用就是保证变量的可见性。
18.并发编程的三个重要特性

原子性 : 一个的操作或者多次操作,要么所有的操作全部都得到执行并且不会收到任何因素的干扰而中断,要么所有的操作都执行,要么都不执行。synchronized 可以保证代码片段的原子性。
可见性 : 当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。volatile 关键字可以保证共享变量的可见性。
有序性 : 代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。volatile 关键字可以禁止指令进行重排序优化。

19.synchronized 关键字和 volatile 关键字的区别

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

20.i++的线程安全问题

在多并发条件下i++不是线程安全的, 主要原因是i++并非原子操作, 其底层可以分为

  1. 获取变量的值
  2. 将值进行加一
  3. 将值赋给变量三个步骤;

因此多线程条件下会由于 重复赋值等问题造成结果有误。
解决i++有两种实现方法
1.sychronized和block
2.使用原子操作类

21.volatile在双重判断锁的单例模式中的作用

指令重排是什么意思呢?比如java中简单的一句 instance = new Singleton,会被编译器编译成如下JVM指令:
memory =allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance =memory; //3:设置instance指向刚分配的内存地址

但是这些指令顺序并非一成不变,有可能会经过JVM和CPU的优化,指令重排成下面的顺序:
memory =allocate(); //1:分配对象的内存空间
instance =memory; //3:设置instance指向刚分配的内存地址
ctorInstance(memory); //2:初始化对象

J

JVM

1. jvm模型

JVM 内存共分为虚拟机栈,堆,方法区,程序计数器,本地方法栈五个部分。
1.程序计数器(线程私有):是当前线程锁执行字节码的行号指示器,每条线程都有一个独立的程序计数器,这类内存也称为“线程私有”的内存。正在执行java方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址)。如果是Natice方法,则为空。

2.java 虚拟机栈(线程私有):每个方法在执行的时候也会创建一个栈帧,存储了局部变量,操作数,动态链接,方法返回地址。每个方法从调用到执行完毕,对应一个栈帧在虚拟机栈中的入栈和出栈。通常所说的栈,一般是指在虚拟机栈中的局部变量部分。局部变量所需内存在编译期间完成分配,如果线程请求的栈深度大于虚拟机所允许的深度,则StackOverflowError。如果虚拟机栈可以动态扩展,扩展到无法申请足够的内存,则OutOfMemoryError。

3.本地方法栈(线程私有):和虚拟机栈类似,主要为虚拟机使用到的Native方法服务。也会抛出StackOverflowError 和OutOfMemoryError。

4.Java堆(线程共享):被所有线程共享的一块内存区域,在虚拟机启动的时候创建,用于存放对象实例。对可以按照可扩展来实现(通过-Xmx 和-Xms 来控制)当队中没有内存可分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常。

5.方法区(线程共享):被所有方法线程共享的一块内存区域。用于存储已经被虚拟机加载的类信息,常量,静态变量等。这个区域的内存回收目标主要针对常量池的回收和堆类型的卸载。

2. JVM如何判断哪些对象可以被回收

有两种方式:
1.引用计数器算法:
引用计数器算法原理:给对象增加一个引用计数器,每当有一个地方引用它时,计数器的值加一,当引用失效时,计数器减一;在任何时刻计数器的值为零的对象就是不可能再被引用的,也就是被回收的对象。
但这个算法存在缺陷,当出现循环引用的时候,对于循环引用的对象,他的引用计数器可能永远不会变为零。

2.可达性分析算法:
在主流的JVM实现中,都是通过可达性分析算法来判断对象存活性的,基本原理是:通过一系列的被称为“GC Roots”的对象作为起始点,从这些对象开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots对象没有任何引用链相连,就认为这个对象不可达,判定为不可用对象,可以被回收。
这里使用一系列的GC Roots对象作为连接起始点,这是算法的精髓,因为GC Roots不会被回收,所以与他相连接的都是有用的对象。
3. gc算法
在进行gc算法之前首先需要判断对象是否是“垃圾”,判断

复制算法:
将内存分为两块,当一块内存用完了,就将存活的对象复制到另一块内存中。
优点:空间连续,没有内存碎片,运行效率高。
缺点:占用内存,如果复制长期生存的对象,会导致效率低。
主要用在新生代,因为新生代对象存活率低。
标记-清除算法:
先标记出需要清除的对象,再将标记的对象回收。
优点:占用内存小
缺点:
(1)需要进行两次动作,标记和清除,所以效率低。
(2)回收完之后,内存不连续,会有内存碎片。
标记-整理算法:
先标记出需要清除的对象,但是不进行回收,而是让所有存活对象都向一段移动,然后清除边界之外的内存空间。
优点:占用内存小,没有内存碎片
缺点:效率低
分代收集算法:
根据Java堆的新生代和老年代的特点,选用不同的回收算法。新生代内存空间大,对象会大量死去,回收频繁,使用效率高的复制算法,只需要每次复制少量存活下来的对象即可。老年代内存空间小,对象存活率高,使用标记-清除/标记-整理算法。

4. 内存泄漏和内存溢出

内存泄漏memory leak :是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄漏似乎不会有大的影响,但内存泄漏堆积后的后果就是内存溢出。
内存溢出 out of memory :指程序申请内存时,没有足够的内存供申请者使用,或者说,给了你一块存储int类型数据的存储空间,但是你却存储long类型的数据,那么结果就是内存不够用,此时就会报错OOM,即所谓的内存溢出。

5. Threadlocal内存泄漏问题

Threadlocal中使用的key为Threadlocal的弱引用,而value是强引用。所以,如果,Threadlocal在没有被外部强引用的情况下,在垃圾回收的时候,key会被清理掉,而value不会被清理掉,这样, Threadlocal中就会出现key为null的entry,如果不作任何措施,value永远不会被GC回收,这时候就可能产生内存泄漏。ThreadLocalMap的实现中已经考虑了这个问题,在调用set(),get(),remove()方法的时候,会清理掉key为null的记录。

6. 内存泄漏举例

1.单例对象在初始化后将在 JVM 的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部的引用,那么这个对象将不能被 JVM 正常回收,导致内存泄漏
2.未及时释放链接资源,数据库连接等

1.单例对象在初始化后将在 JVM 的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部的引用,那么这个对象将不能被 JVM 正常回收,导致内存泄漏
2.未及时释放链接资源,数据库连接等

7. GC的流程

使用分代回收的思想:新生代、老年代、永久代

新生代:使用复制算法进行删除,一个新生代空间被划分为三份,一份是eden区,两份生存区,对象首先被创建在eden区,因为新建的对象大部分的生存周期都很短,用完即弃,所以使用复制算法效率高,存活的对象被复制到一个生存区,eden清理,下一次的时候把eden区和生存区的对象都存入另一个生存区,在清理掉这两个区域,重复15次左右,把依然存活的将被复制到老年代

老年代:这个区域的对象存活几率比较大,使用标记-整理算法进行清理

永久代:垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因。

8. 双亲委派机制

1.如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行。

2.如果父类的加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终会到达顶层的启动类加载器。(从这里就可以看出来,类加载请求都会先到达启动类加载器)

3.如果父类加载器可以完成类加载任务,就成功返回,倘若无法完成此加载任务,则委派给它的子加载器去加载。
如图所示,如果有个类加载请求来了,会一直向上委托,直到引导类加载器;然后引导类加载器尝试加载,如果它不能加载,则会给他的子加载器扩展类加载器加载;如果扩展类加载器还是不能加载;则再到下一级系统类加载器。

双亲委派机制的好处:
1.避免类的重复加载。一旦一个类被父类加载器加载之后,就不会再被委派给子类进行加载。
2.保护程序安全。
3.避免类的重复加载
4.保护程序安全,防止核心API被随意篡改
自定义类:java.lang.String (没用)
自定义类:java.lang.ShkStart(报错:阻止创建 java.lang开头的类)

类加载器自上至下依次是:
启动类加载器:主要负责加载核心的类库(java.lang.*等)
扩展类加载器:主要负责加载jre/lib/ext目录下的一些扩展的jar
应用程序类加载器:主要负责加载应用程序的主函数类
用户自定义加载器。
所以双亲委派机制可以防止用户自定义的类影响核心API

9. JMM内存模型

处理器上的寄存器的读写的速度比内存快几个数量级,为了解决这种速度矛盾,在它们之间加入了高速缓存。
加入高速缓存带来了一个新的问题:缓存一致性。如果多个缓存共享同一块主内存区域,那么多个缓存的数据可能会不一致,需要一些协议来解决这个问题。所有的变量都存储在主内存中,每个线程还有自己的工作内存 ,工作内存存储在高速缓存或者寄存器中,保存了该线程使用的变量的主内存副本拷贝。
线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。

…待更新

猜你喜欢

转载自blog.csdn.net/weixin_43424363/article/details/120812270