美文网首页
Java面试总结

Java面试总结

作者: smlrole | 来源:发表于2017-05-02 12:44 被阅读0次

    前言

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

    基础篇

    集合和多线程是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<String, String>(16,0.75f,true),true就代表按照访问顺序排序,那么调用 get 方法后,会将这次访问的元素移至尾部,不断访问可以形成按访问顺序排序的链表。

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

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

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

    map.png

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

    具体如何实现我们来看源码,在 HashMap 定义中有一个属性Entry<K,V>[] 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<K,V> 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<K,V> 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<Object> queue = new LinkedList<Object>();  //存储快
        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<Runnable>());
            
            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面试总结

          本文链接:https://www.haomeiwen.com/subject/qhkpzttx.html