Java面试总结

前言

工作加实习两年了,想总结和记录这几天的面试重点和题目,尽量把答案写出来,由于大多网上搜的或者查阅书籍,如有错误还忘指出。
大多数好的公司面的比较有深度,主要还是要靠自学和积累,文章中答案都不深度讨论,后续会细细分析。

基础篇

集合和多线程是Java基础的重中之重

1. Map 有多少个实现类?

Java自带了各种 Map 类,这些 Map 类可归为三种类型:

  • 通用 Map,用于在应用程序中管理映射,通常在 Java.util 程序包中实现:HashMap Hashtable Properties LinkedHashMap IdentityHashMap TreeMap WeakHashMap ConcurrentHashMap
  • 专用 Map,你通常不必亲自创建此类 Map,而是通过某些其他类对其进行访问:java.util.Attributes javax.print.attribute.standard.PrinterStateReasons java.security.Provider java.awt.RenderingHints javax.swing.UIDefaults
  • 一个用于帮助实现你自己的 Map 类的抽象类:AbstractMap

不用记住所有的,根据自己比较常用和了解的来回答就可以。

2. HashMap 是不是有序的?有序的 Map 类是哪个?Hashtable 是如何实现线程安全的?

HashMap 不是有序的,下一题通过源码来解析HashMap的结构。

LinkedHashMap 是有序的,并且可以分为按照插入顺序和访问顺序的链表,默认是按插入顺序排序的。在创建的时候如果是new LinkedHashMap(16,0.75f,true),true就代表按照访问顺序排序,那么调用 get 方法后,会将这次访问的元素移至尾部,不断访问可以形成按访问顺序排序的链表。

根据源码可以看到,Hashtable 中的方法都加上了 synchronized 关键字。

3. HashMap 的实现原理以及如何扩容?

众所周知 HashMap 是数组和链表的结合,如下图所示:

Java面试总结_第1张图片
map.png

左边是连续的数组,我们可以称为哈希桶,右边连接着链表。

具体如何实现我们来看源码,在 HashMap 定义中有一个属性Entry[] table属性,所以可以看到 HashMap 的实质是一个 Entry 数组。
看 HashMap 的初始化:

private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);

        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];  //初始化
        initHashSeedAsNeeded(capacity);
    }

我们看到table = new Entry[capacity]这行,这行就是 HashMap 的初始化。capacity 是容量,容量的默认大小是16,最大不能超过2的30次方。然后我们来看 put() 方法:

public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);  //计算出key的hash值
        int i = indexFor(hash, table.length);  //通过容量来找到key对应哈希桶的位置
         //在对应的哈希桶上的链表查找是否有相同的key,如果有则覆盖
        for (Entry e = table[i]; e != null; e = e.next) {  
            Object k;
            //这里解释了map就是根据对象的hashcode和equals方法来决定有没有重复的key
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;
        //添加一个键值对
        addEntry(hash, key, value, i);
        return null;
    }

可以看到,addEntry 才是真正在添加一个键值对,addEntry 方法中有扩容的操作,这个等会儿再看,所以我们直接看添加的键值对的操作:

void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry e = table[bucketIndex];    //拿到哈希桶中存放的地址
        //新建的entry指向刚刚那个地址,并且哈希桶指向新建的entry
        table[bucketIndex] = new Entry<>(hash, key, value, e);  
        size++;
}

以上注释就说明当加入一个新键值对的时候,新的键值对找到对应的哈希桶之后就插入到链表的头结点上。

接下来看扩容,我们说到数组有容量,默认16,在 HashMap 的定义中还有负载因子,默认为0.75,一旦数组存放的元素超过16*0.75=12个就需要增大哈希桶。我们看 addEntry方法:

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);  //如果是默认16,则增大到32
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
 }

接下来看 resize 方法:

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];  //创建新的数组,大小为原来数组的两倍
        //将所有元素按照存放规则重新存放到新的数组中
        transfer(newTable, initHashSeedAsNeeded(newCapacity));  
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

每次扩容都会重新计算所有 key 的哈希值以及将所有元素重排,比较浪费资源,所以在创建 HashMap 时,我们尽量初始化适当的容量以减少元素重排带来的开支。

4. List的实现类以及区别?
  • ArrayList 是最常用的 List 实现类,内部是通过数组实现的,它允许对元素进行快速随机访问。数组的缺点是每个元素之间不能有间隔,当数组大小不满足时需要增加存储能力,就要将已经有数组的数据复制到新的存储空间中。当从 ArrayList 的中间位置插入或者删除元素时,需要对数组进行复制、移动、代价比较高。因此,它适合随机查找和遍历,不适合插入和删除,允许空元素。
  • LinkedList 是用链表结构存储数据的,很适合数据的动态插入和删除,随机访问和遍历速度比较慢。另外,接口中没有定义的方法get、remove、insertList,专门用于操作表头和表尾元素,可以当作堆栈、队列和双向队列使用。LinkedList 没有同步方法。如果多个线程同时访问一个List,则必须自己实现访问同步,一种解决方法是在创建List时构造一个同步的List:
    List list = Collections.synchronizedList(new LinkedList())
  • 应该避免使用 Vector,它只存在于支持遗留代码的类库中。
  • CopyOnWriteArrayList 是 List 的一个特殊实现,专门用于并发编程。
5. 如何实现线程并发?

用 Lock 接口的实现类或者 synchroized 关键字。

6. Lock 类比起 synchroized,优势在哪里?

Lock 接口是 JavaSE5 引入的新接口,最大的优势是为读和写分别提供了锁。
延伸:如果需要实现一个高效的缓存,它允许多个用户读,但只允许一个用户写,以此来保证它的完整性,如何实现?
读写锁 ReadWriteLock 拥有更加强大的功能,它可以分为读锁和解锁。读锁可以允许多个进行读操作的线程同时进入,但不允许写进程进入;写锁只允许一个写进程进入,在这期间任何进程都不能再进入。

要注意的是每个读写锁都有挂锁和解锁,最好将每一对挂锁和解锁操作都用 try、finally 来套入中间的代码,这样就会防止因异常发生而造成死锁的情况。

下面是一段示例:

import java.util.Random;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockTest {
    //用于关闭线程
    public volatile static boolean blag= true;
    public static void main(String[] args) throws InterruptedException {
        final TheData myData = new TheData();  //各线程的共享资源
        for (int i = 0; i < 3; i++) {   //开启三个读线程
            new Thread(new Runnable() {
                @Override
                public void run() {
                    while(blag){
                        myData.get();
                    }
                }
            }).start();
        }
        for (int i = 0; i < 3; i++) {   //开启三个写线程
            new Thread(new Runnable() {
                @Override
                public void run() {
                    while (blag) {
                        myData.put(new Random().nextInt(10000));
                    }
                }
            }).start();
        }
        Thread.sleep(5000);
        BLAG = false;
    }

}
/**
 * 模拟同步读写
 * @author hedy
 *
 */
class TheData{
    private Object data=null;
    private ReadWriteLock rwl = new ReentrantReadWriteLock();
    public void get(){
        rwl.readLock().lock();   //读锁开启,读线程均可进入
        try {
            System.out.println(Thread.currentThread().getName()+" is ready to read");
            Thread.sleep(new Random().nextInt(100));
            System.out.println(Thread.currentThread().getName()+" have read data "+data);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            rwl.readLock().unlock();    //读锁解锁
        }
    }
    
    public void put(Object data){
        rwl.writeLock().lock(); //写锁开启,这时只有一个写线程进入
        try {
            System.out.println(Thread.currentThread().getName()+" is ready to write");
            Thread.sleep(new Random().nextInt(100));
            this.data = data;
            System.out.println(Thread.currentThread().getName()+" have write data "+data);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            rwl.writeLock().unlock();   //写锁解锁
        }
        
    }
}
7. java中的 wait() 和 sleep() 方法有何不同?

最大的不同是在等待 wait 时会释放锁,而 sleep 一直持有锁,wait
通常被用于线程间交互,sleep 通常被用于暂停执行。
其他不同有:

  • sleep 是 Thread 类的静态方法,wait 是 Object 方法
  • wait,notify 和 notifyAll 只能在同步控制方法或者同步控制块里面使用,而 sleep 可以在任何地方使用
  • sleep 必须捕获异常,而 wait,notiry 和 notifyAll 不需要捕获异常
8. 如何实现阻塞队列(BlockingQueue)?

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:在队列为空时,获取元素的线程会等待队列为飞控;当队列满时,存储元素的线程会等待队列可用。阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。

阻塞队列的简单实现:

import java.util.LinkedList;
import java.util.List;

public class BlockingQueue {
    
    private List queue = new LinkedList();  //存储快
    private int limit = 10; //默认队列大小
    public BlockingQueue(int limit){
        this.limit = limit;
    }
    public BlockingQueue() {}
    public synchronized void enQueue(Object item) throws InterruptedException{
        while (this.queue.size()==this.limit) {
            wait(); //很多资料上写不需要捕获异常,但看源码还是有异常声明
        }
        if (this.queue.size()==0) {
            notifyAll();
        }
        this.queue.add(item);   //元素添加到链表最后
    }

    public synchronized Object deQueue() throws InterruptedException{
        while (this.queue.size()==0){
            wait();
        }
        if (this.queue.size()==this.limit) {
            notifyAll();
        }
        return this.queue.remove(0);    //返回第一个数据,符合队列先进先出原理
    }
}
 
 

延伸:利用 Executors 创建线程池中的消息队列情况,ThreadPoolExecutor 是 Executors 的底层,建议使用 Executors ,想了解的可以自己查找资料。

9. 创建一千个线程对一个 static 的数据进行++,之后会不会是1000,为什么?如果将 static 的数据加上 volatile 呢?

首先我们直接用代码执行一下看结果:

public class VolatileTest {

    public static int count = 0;
    
    public static void main(String[] args) throws InterruptedException {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(1000,4000, 
                                  60l, TimeUnit.SECONDS, new LinkedBlockingQueue());
        
        for (int i = 0; i < 4000; i++) {  //由于1000个线程结果测试对比不明显,所以创建4000个
            executor.execute(new ThreadPoolTask());
        }
        executor.shutdown();  //执行完毕后关闭线程池
        while(!executor.isTerminated()){
            Thread.sleep(1000);
        }
        System.out.println(VolatileTest.count);  //线程都关闭之后输出结果
    }
    
}
class ThreadPoolTask implements Runnable{

    @Override
    public void run() {
        VolatileTest.count++;
    }
    
}

结果都不尽相同,大约在4000以下徘徊。接下来分析一下原因。Java 内存模型是这样的:

  • 所有的变量都存储在主内存中
  • 每个线程都有自己独立的工作内存,里面保证该线程是使用到的变量副本(即内存中变量的拷贝)

JVM 还规定:

  • 线程共享变量的所有操作必须在自己的工作内存中进行,不能直接从主内存中读写;
  • 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值得传递需要通过主内存来完成;

通过以上规定我们想象一下4000个线程在同时操作count++的场景:
首先 A 线程从主内存中拿到 count 值,比如说 0,放到了自己的工作内存中,在 A 没处理完,B 线程就去主内存拿 count 值,也是 0,当 A、B 线程处理完之后将 count 值再放入主内存时 count 变成了1,实际我们要的结果是 2,这时候就出现了错误。
接下来,如果我们将 count 前加 volatile 关键字,运行结果依然不保证是4000。先了解一下线程的可见性和原子性的定义:

  • 可见性:一个线程对共享变量的修改,能够及时的被其他线程见到
  • 原子性:一旦操作开始,那么它一定可以在可能发生的“上下文切换”之前执行完毕

而* volatile保证变量对线程的可见性,但不保证原子性 * ,如果要看volatile为什么不保证原子性可以看这篇文章。

而如果把 count 改为 AtomicInteger 类型,则即可以保证对线程的可见性以及原子性。

10. 在 synchroized 方法上加 static 和不加 static 的区别是什么?

synchroized 在修饰方法时称为对象锁,加了 static 则是类锁,对象锁则是每个对象都有一把锁,而类锁只有一个,即所有对象在调用此方法的时候共同用同一把锁。

你可能感兴趣的:(Java面试总结)