Android 高级面试-3:Java、同步和并发相关

主要内容:Kotlin, Java, RxJava, 多线程/并发, 集合java

一、Java 相关

1.1 缓存相关

  • LruCache 的原理
  • DiskLruCache 的原理

LruCache 用来实现基于内存的缓存,LRU 就是最近最少使用的意思,LruCache 基于 LinkedHashMap 实现。LinkedHashMap 是在 HashMap 的基础之上进行了封装,除了具备哈希功能,还将数据插入到双向链表中维护。每次读取的数据会被移动到链表的尾部,当达到了缓存的最大的容量的时候就将链表的首部移出。使用 LruCache 的时候须要注意的是单位的问题,由于该 API 并不清楚要存储的数据是如何计算大小的,因此它提供了方法供咱们实现大小的计算方式。(《Android 内存缓存框架 LruCache 的源码分析》git

DiskLruCache 与 LruCache 相似,也是用来实现缓存的,而且也是基于 LinkedHashMap 实现的。不一样的是,它是基于磁盘缓存的,LruCache 是基于内存缓存的。因此,LinkedHashMap 可以存储的空间更大,可是读写的速率也更慢。使用 DiskLruCache 的时候须要到 Github 上面去下载。OkHttp 和 Glide 的磁盘缓存都是基于 DiskLruCache 开发的。DiskLruCahce 内部维护了一个日志文件,记录了读写的记录的信息。其余的基本都是基础的磁盘 IO 操做。github

  • Glide 缓存的实现原理

1.2 List 相关

  • ArrayList 与 LinkedList 区别
  1. ArrayList 是基于动态数组,底层使用 System.arrayCopy() 实现数组扩容;查找值的复杂度为 O(1),增删的时候可能扩容,复杂度也比 LinkedList 高;若是可以大概估出列表的长度,能够经过在 new 出实例的时候指定一个大小来指定数组的初始大小,以减小扩容的次数;适合应用到查找多于增删的情形,好比做为 Adapter 的数据的容器
  2. LinkedList 是基于双向链表;增删的复杂度为 O(1),查找的复杂度为 O(n);适合应用到增删比较多的情形
  3. 两种列表都不是线程安全的,Vector 是线程安全的,可是它的线程安全的实现方式是经过对每一个方法进行加锁,因此性能比较低。

若是想线程安全地使用这列表类(能够参考下面的问题)面试

  • 如何实现线程间安全地操做 List?

咱们有几种方式能够线程间安全地操做 List. 具体使用哪一种方式,能够根据具体的业务逻辑进行选择。一般有如下几种方式:数据库

  1. 第一是在操做 List 的时候使用 sychronized 进行控制。咱们能够在咱们本身的业务方法上面进行加锁来保证线程安全。
  2. 第二种方式是使用 Collections.synchronizedList() 进行包装。这个方法内部使用了私有锁来实现线程安全,就是经过对一个全局变量进行加锁。调用咱们的 List 的方法以前须要先获取该私有锁。私有锁能够下降锁粒度。
  3. 第三种是使用并发包中的类,好比在读多写少的状况下,为了提高效率可使用 CopyOnWriteArrayList 代替 ArrayList,使用 ConcurrentLinkedQueue 代替 LinkedList. 并发容器中的 CopyOnWriteArrayList 在读的时候不加锁,写的时候使用 Lock 加锁。ConcurrentLinkedQueue 则是基于 CAS 的思想,在增删数据以前会先进行比较。

1.3 Map 相关

  • SparseArray 的原理

SparseArray 主要用来替换 Java 中的 HashMap,由于 HashMap 将整数类型的键默认装箱成 Integer (效率比较低). 而 SparseArray 经过内部维护两个数组来进行映射,而且使用二分查找寻找指定的键,因此它的键对应的数组无需是包装类型。SparseArray 用于当 HashMap 的键是 Integer 的状况,它会在内部维护一个 int 类型的数组来存储键。同理,还有 LongSparseArray, BooleanSparseArray 等,都是用来经过减小装箱操做来节省内存空间的。可是,由于它内部使用二分查找寻找键,因此其效率不如 HashMap 高,因此当要存储的键值对的数量比较大的时候,考虑使用 HashMap.编程

  • HashMap、ConcurrentHashMap 以及 HashTable
  • hashmap 如何 put 数据(从 hashmap 源码角度讲解)?(掌握 put 元素的逻辑)

HashMap (下称 HM) 是哈希表,ConcurrentHashMap (下称 CHM) 也是哈希表,它们之间的区别是 HM 不是线程安全的,CHM 线程安全,而且对锁进行了优化。对应 HM 的还有 HashTable (下称 HT),它经过对内部的每一个方法加锁来实现线程安全,效率较低。设计模式

HashMap 的实现原理:HashMap 使用拉链法来解决哈希冲突,即当两个元素的哈希值相等的时候,它们会被方进一个桶当中。当一个桶中的数据量比较多的时候,此时 HashMap 会采起两个措施,要么扩容,要么将桶中元素的数据结构从链表转换成红黑树。所以存在几个常量会决定 HashMap 的表现。在默认的状况下,当 HashMap 中的已经被占用的桶的数量达到了 3/4 的时候,会对 HashMap 进行扩容。当一个桶中的元素的数量达到了 8 个的时候,若是桶的数量达到了 64 个,那么会将该桶中的元素的数据结构从链表转换成红黑树。若是桶的数量尚未达到 64 个,那么此时会对 HashMap 进行扩容,而不是转换数据结构。数组

从数据结构上,HashMap 中的桶中的元素的数据结构从链表转换成红黑树的时候,仍然能够保留其链表关系。由于 HashMap 中的 TreeNode 继承了 LinkedHashMap 中的 Entry,所以它存在两种数据结构。缓存

HashMap 在实现的时候对性能进行了不少的优化,好比使用截取后面几位而不是取余的方式计算元素在数组中的索引。使用哈希值的高 16 位与低 16 进行异或运算来提高哈希值的随机性。安全

由于每一个桶的元素的数据结构有两种可能,所以,当对 HashMap 进行增删该查的时候都会根据结点的类型分红两种状况来进行处理。当数据结构是链表的时候处理起来都很是容易,使用一个循环对链表进行遍历便可。当数据结构是红黑树的时候处理起来比较复杂。红黑树的查找能够沿用二叉树的查找的逻辑。

下面是 HashMap 的插入的逻辑,全部的插入操做最终都会调用到内部的 putVal() 方法来最终完成。

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    private V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;

        if ((tab = table) == null || (n = tab.length) == 0) { // 原来的数组不存在
            n = (tab = resize()).length;
        }

        i = (n - 1) & hash; // 取哈希码的后 n-1 位,以获得桶的索引
        p = tab[i]; // 找到桶
        if (p == null) { 
            // 若是指定的桶不存在就建立一个新的,直接new 出一个 Node 来完成
            tab[i] = newNode(hash, key, value, null);
        } else { 
            // 指定的桶已经存在
            Node<K,V> e; K k;

            if (p.hash == hash // 哈希码相同
                && ((k = p.key) == key || (key != null && key.equals(k))) // 键的值相同
            ) {
                // 第一个结点与咱们要插入的键值对的键相等
                e = p;
            } else if (p instanceof TreeNode) {
                // 桶的数据结构是红黑树,调用红黑树的方法继续插入
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            } else {
                // 桶的数据结构是链表,使用链表的处理方式继续插入
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        // 已经遍历到了链表的结尾,尚未找到,须要新建一个结点
                        p.next = newNode(hash, key, value, null);
                        // 插入新结点以后,若是某个链表的长度 >= 8,则要把链表转成红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) {
                            treeifyBin(tab, hash);
                        }
                        break;
                    }
                    if (e.hash == hash // 哈希码相同 
                        && ((k = e.key) == key || (key != null && key.equals(k))) // 键的值相同
                    ) {
                        // 说明要插入的键值对的键是存在的,须要更新以前的结点的数据
                        break;
                    }
                    p = e;
                }
            }

            if (e != null) { 
                // 说明指定的键是存在的,须要更新结点的值
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null) {
                    e.value = value;
                }
                return oldValue;
            }
        }

        ++modCount;

        // 若是插入了新的结点以后,哈希表的容量大于 threshold 就进行扩容
        if (++size > threshold) {
            resize(); // 扩容
        }
        return null;
    }
复制代码

上面是 HashMap 的插入的逻辑,能够看出,它也是根据头结点的类型,分红红黑树和链表两种方式来进行处理的。对于链表,上面已经给出了具体的插入逻辑。在链表的情形中,除了基础的插入,当链表的长度达到了 8 的时候还要将桶的数据结构从链表转型成为红黑树。对于红黑树类型的数据结构,它调用 TreeNode 的 putTreeVal() 方法来完成往红黑树中插入结点的逻辑。(代码贴出来,慢慢领会吧:))

final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab, int h, K k, V v) {
    Class<?> kc = null;
    boolean searched = false;
    // 查找根节点, 索引位置的头节点并不必定为红黑树的根结点
    TreeNode<K,V> root = (parent != null) ? root() : this;  
    for (TreeNode<K,V> p = root;;) {    // 将根节点赋值给 p, 开始遍历
        int dir, ph; K pk;
        
        if ((ph = p.hash) > h)  
        // 若是传入的 hash 值小于 p 节点的 hash 值,则将 dir 赋值为 -1, 表明向 p 的左边查找树
            dir = -1; 
        else if (ph < h)    
        // 若是传入的 hash 值大于 p 节点的 hash 值,则将 dir 赋值为 1, 表明向 p 的右边查找树
            dir = 1;

        // 若是传入的 hash 值和 key 值等于 p 节点的 hash 值和 key 值, 则 p 节点即为目标节点, 返回 p 节点
        else if ((pk = p.key) == k || (k != null && k.equals(pk)))  
            return p;
        
        // 若是 k 所属的类没有实现 Comparable 接口 或者 k 和 p 节点的 key 相等
        else if ((kc == null &&
                  (kc = comparableClassFor(k)) == null) ||
                 (dir = compareComparables(kc, k, pk)) == 0) { 
            if (!searched) {    
                // 第一次符合条件, 该方法只有第一次才执行
                TreeNode<K,V> q, ch;
                searched = true;
                // 从 p 节点的左节点和右节点分别调用 find 方法进行查找, 若是查找到目标节点则返回
                if (((ch = p.left) != null && (q = ch.find(h, k, kc)) != null) 
                    || ((ch = p.right) != null && (q = ch.find(h, k, kc)) != null))  
                    return q;
            }
            // 使用定义的一套规则来比较 k 和 p 节点的 key 的大小, 用来决定向左仍是向右查找
            dir = tieBreakOrder(k, pk); // dir<0 则表明 k<pk,则向 p 左边查找;反之亦然
        }
 
        TreeNode<K,V> xp = p;   // xp 赋值为 x 的父节点,中间变量,用于下面给x的父节点赋值
        // dir<=0 则向 p 左边查找,不然向 p 右边查找,若是为 null,则表明该位置即为 x 的目标位置
        if ((p = (dir <= 0) ? p.left : p.right) == null) {  
        	// 走进来表明已经找到 x 的位置,只需将 x 放到该位置便可
            Node<K,V> xpn = xp.next;    // xp 的 next 节点 
            // 建立新的节点, 其中 x 的 next 节点为 xpn, 即将 x 节点插入 xp 与 xpn 之间
            TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);   
            if (dir <= 0)   // 若是时 dir <= 0, 则表明 x 节点为 xp 的左节点
                xp.left = x;
            else        // 若是时 dir> 0, 则表明 x 节点为 xp 的右节点
                xp.right = x;
            xp.next = x;    // 将 xp 的n ext 节点设置为 x
            x.parent = x.prev = xp; // 将 x 的 parent 和 prev 节点设置为xp
            // 若是 xpn 不为空,则将 xpn 的 prev 节点设置为 x 节点,与上文的 x 节点的 next 节点对应
            if (xpn != null)    
                ((TreeNode<K,V>)xpn).prev = x;
            moveRootToFront(tab, balanceInsertion(root, x)); // 进行红黑树的插入平衡调整
            return null;
        }
    }
}
复制代码
  • 集合 Set 实现 Hash 怎么防止碰撞

HashSet 内部经过 HashMap 实现,HashMap 解决哈希冲突使用的是拉链法,碰撞的元素会放进链表中,链表长度超过 8,而且已经使用的桶的数量大于 64 的时候,会将桶的数据结构从链表转换成红黑树。HashMap 在求得每一个结点在数组中的索引的时候,会使用对象的哈希码的高八位和低八位求异或,来增长哈希码的随机性。

  • HashSet 与 HashMap 怎么判断集合元素重复

HashSet 内部使用 HashMap 实现,当咱们经过 put() 方法将一个键值对添加到哈希表当中的时候,会根据哈希值和键是否相等两个条件进行判断,只有当二者彻底相等的时候才认为元素发生了重复。

  • HashMap 的实现?与 HashSet 的区别?

HashSet 不容许列表中存在重复的元素,HashSet 内部使用的是 HashMap 实现的。在咱们向 HashSet 中添加一个元素的时候,会将该元素做为键,一个默认的对象做为值,构成一个键值对插入到内部的 HashMap 中。(HashMap 的实现见上文。)

  • TreeMap 具体实现

TreeMap 是基于红黑树实现的(后续完善)


1.4 注解相关

  • 对 Java 注解的理解

Java 注解在 Android 中比较常见的使用方式有 3 种:

  1. 第一种方式是基于反射的。由于反射自己的性能问题,因此它一般用来作一些简单的工做,好比为类、类的字段和方法等添加额外的信息,而后经过反射来获取这些信息。
  2. 第二种方式是基于 AnnotationProcessor 的,也就是在编译期间动态生成样板代码,而后经过反射触发生成的方法。好比 ButterKnife 就使用注解处理,在编译的时候 find 使用了注解的控件,并为其绑定值。而后,当调用 bind() 的时候直接反射调用生成的方法。Room 也是在编译期间为使用注解的方法生成数据库方法的。在开发这种第三方库的时候还可能使用到 Javapoet 来帮助咱们生成 Java 文件。
  3. 最后一种比较经常使用的方式是使用注解来取代枚举。由于枚举相比于常量有额外的内存开销,因此开发的时候一般使用常量来取代枚举。可是若是只使用常量咱们没法对传入的常量的范围进行限制,所以咱们可使用注解来限制取值的范围。以整型为例,咱们会在定义注解的时候使用注解 @IntDef({/*各类枚举值*/}) 来指定整型的取值范围。而后使用注解修饰咱们要方法的参数便可。这样 IDE 会给出一个提示信息,提示咱们只能使用指定范围的值。(《Java 注解及其在 Android 中的应用》

关联:ButterKnife, ARouter


1.5 Object 相关

  • Object 类的 equal() 和 hashcode() 方法重写?

这两个方法都具备决定一个对象身份功能,因此二者的行为必须一致,覆写这两个方法须要遵循必定的原则。能够从业务的角度考虑使用对象的惟一特征,好比 ID 等,或者使用它的所有字段来进行计算获得一个整数的哈希值。通常,我不会直接覆写该方法,除非业务特征很是明显。由于一旦修改以后,它的做用范围将是全局的。咱们还能够经过 IDEA 的 generate 直接生成该方法。

  • Object 都有哪些方法?
  1. wait() & notify(), 用来对线程进行控制,以让当前线程等待,直到其余线程调用了 notify()/notifyAll() 方法。wait() 发生等待的前提是当前线程获取了对象的锁(监视器)。调用该方法以后当前线程会释放获取到的锁,而后让出 CPU,进入等待状态。notify/notifyAll() 的执行只是唤醒沉睡的线程,而不会当即释放锁,锁的释放要看代码块的具体执行状况。
  2. clone() 与对象克隆相关的方法(深拷贝&浅拷贝?)
  3. finilize()
  4. toString()
  5. equal() & hashCode(),见上

1.6 字符串相关

  • StringBuffer 与 StringBuilder 的区别?

前者是线程安全的,每一个方法上面都使用 synchronized 关键字进行了加锁,后者是非线程安全的。通常状况下使用 StringBuilder 便可,由于非多线程环境进行加锁是一种没有必要的开销。

  • 对 Java 中 String 的了解
  1. String 不是基本数据类型。
  2. String 是不可变的,JVM 使用字符串池来存储全部的字符串对象。
  3. 使用 new 建立字符串,这种方式建立的字符串对象不存储于字符串池。咱们能够调用intern() 方法将该字符串对象存储在字符串池,若是字符串池已经有了一样值的字符串,则返回引用。使用双引号直接建立字符串的时候,JVM 先去字符串池找有没有值相等字符串,若是有,则返回找到的字符串引用;不然建立一个新的字符串对象并存储在字符串池。
  • String 为何要设计成不可变的?
  1. 线程安全:因为 String 是不可变类,因此在多线程中使用是安全的,咱们不须要作任何其余同步操做。
  2. String 是不可变的,它的值也不能被改变,因此用来存储数据密码很安全
  3. 复用/节省堆空间:实际在 Java 的开发当中 String 是使用最为频繁的类之一,经过 dump 的堆能够看出,它常常占用很大的堆内存。由于 java 字符串是不可变的,能够在 java 运行时节省大量 java 空间。不一样的字符串变量能够引用池中的相同的字符串。若是字符串是可变得话,任何一个变量的值改变,就会反射到其余变量,那字符串池也就没有任何意义了。
  • 常见编码方式有哪些? utf-8, unicode, ascii
  • Utf-8 编码中的中文占几个字节?

UTF-8 编码把一个 Unicode 字符根据不一样的数字大小编码成 1-6 个字节,经常使用的英文字母被编码成 1 个字节,汉字一般是 3 个字节,只有很生僻的字符才会被编码成 4-6 个字节。

参考文章,了解字符串编码的渊源:字符编码 ASCII UNICODE UTF-8


1.7 线程控制

  • 开启线程的三种方式,run() 和 start() 方法区别
  • 多线程:怎么用、有什么问题要注意;Android 线程有没有上限,而后提到线程池的上限
  • Java 线程池、线程池的几个核心参数的意义
  • 线程如何关闭,以及如何防止线程的内存泄漏

如何开启线程,线程池参数;注意的问题:线程数量,内存泄漏

// 方式 1:Thread 覆写 run() 方法;
    private class MyThread extends Thread {
        @Override
        public void run() {
            // 业务逻辑
        }
    }

    // 方式 2:Thread + Runnable
    new Thread(new Runnable() {
        public void run() {
            // 业务逻辑
        }
    }).start();

    // 方式 3:ExectorService + Callable
    ExecutorService executor = Executors.newFixedThreadPool(5);
    List<Future<Integer>> results = new ArrayList<Future<Integer>>();
    for (int i=0; i<5; i++) {
        results.add(executor.submit(new CallableTask(i, i)));
    }
复制代码

线程数量的问题:

Android 中并无明确规定能够建立的线程的数量,可是每一个进程的资源是有限的,线程自己会占有必定的资源,因此受内存大小的限制,会有数量的上限。一般,咱们在使用线程或者线程池的时候,不会建立太多的线程。线程池的大小经验值应该这样设置:(其中 N 为 CPU 的核数)

  1. 若是是 CPU 密集型应用,则线程池大小设置为 N + 1;(大部分时间在计算)
  2. 若是是 IO 密集型应用,则线程池大小设置为 2N + 1;(大部分时间在读写,Android)

下面是 Android 中的 AysncTask 中建立线程池的代码(建立线程池的核心参数的说明已经家在了注释中),

// CPU 的数量
    private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
    // 核心线程的数量:只有提交任务的时候才会建立线程,当当前线程数量小于核心线程数量,新添加任务的时候,会建立新线程来执行任务
    private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));
    // 线程池容许建立的最大线程数量:当任务队列满了,而且当前线程数量小于最大线程数量,则会建立新线程来执行任务
    private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;
    // 非核心线程的闲置的超市时间:超过这个时间,线程将被回收,若是任务多且执行时间短,应设置一个较大的值
    private static final int KEEP_ALIVE_SECONDS = 30;

    // 线程工厂:自定义建立线程的策略,好比定义一个名字
    private static final ThreadFactory sThreadFactory = new ThreadFactory() {
        private final AtomicInteger mCount = new AtomicInteger(1);

        public Thread newThread(Runnable r) {
            return new Thread(r, "AsyncTask #" + mCount.getAndIncrement());
        }
    };

    // 任务队列:若是当前线程的数量大于核心线程数量,就将任务添加到这个队列中
    private static final BlockingQueue<Runnable> sPoolWorkQueue =
            new LinkedBlockingQueue<Runnable>(128);

    public static final Executor THREAD_POOL_EXECUTOR;

    static {
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                /*corePoolSize=*/ CORE_POOL_SIZE,
                /*maximumPoolSize=*/ MAXIMUM_POOL_SIZE, 
                /*keepAliveTime=*/ KEEP_ALIVE_SECONDS, TimeUnit.SECONDS,
                /*workQueue=*/ sPoolWorkQueue, 
                /*threadFactory=*/ sThreadFactory
                /*handler*/ defaultHandler); // 饱和策略:AysncTask 没有这个参数
        threadPoolExecutor.allowCoreThreadTimeOut(true);
        THREAD_POOL_EXECUTOR = threadPoolExecutor;
    }
复制代码

饱和策略:任务队列和线程池都满了的时候执行的逻辑,Java 提供了 4 种实现;
其余:

  1. 当调用了线程池的 prestartAllcoreThread() 方法的时候,线程池会提早启动并建立全部核心线程来等待任务;
  2. 当调用了线程池的 allowCoreThreadTimeOut() 方法的时候,超时时间到了以后,闲置的核心线程也会被移除。

run()start() 方法区别start() 会调用 native 的 start() 方法,而后 run() 方法会被回调,此时 run() 异步执行;若是直接调用 run(),它会使用默认的实现(除非覆写了),而且会在当前线程中执行,此时 Thread 如同一个普通的类。

private Runnable target;
    public void run() {
        if (target != null)  target.run();
    }
复制代码

线程关闭,有两种方式能够选择,一种是使用中断标志位进行判断。当须要中止线程的时候,调用线程的 interupt() 方法便可。这种状况下须要注意的地方是,当线程处于阻塞状态的时候调用了中断方法,此时会抛出一个异常,并将中断标志位复位。此时,咱们是没法退出线程的。因此,咱们须要同时考虑通常状况和线程处于阻塞时中断两种状况。

另外一个方案是使用一个 volatile 类型的布尔变量,使用该变量来判断是否应该结束线程。

// 方式 1:使用中断标志位
    @Override
    public void run() {
        try {
            while (!isInterrupted()) {
                // do something
            }
        } catch (InterruptedException ie) {  
            // 线程由于阻塞时被中断而结束了循环
        }
    }

    private static class MyRunnable2 implements Runnable {
        // 注意使用 volatile 修饰
        private volatile boolean canceled = false;

        @Override
        public void run() {
            while (!canceled) {
                // do something
            }
        }

        public void cancel() {
            canceled = true;
        }
    }
复制代码

防止线程内存泄漏

  1. 在 Activity 等中使用线程的时候,将线程定义成静态的内部类,非静态内部类会持有外部类的匿名引用;
  2. 当须要在线程中调用 Activity 的方法的时候,使用 WeakReference 引用 Activity;
  3. 或者当 Activity 须要结束的时候,在 onDestroy() 方法中终止线程。
  • wait()、notify() 与 sleep()

wait()/notify():

  1. wait()、notify() 和 notifyAll() 方法是 Object 的本地 final 方法,没法被重写。
  2. wait() 使当前线程阻塞,直到接到通知或被中断为止。前提是必须先得到锁,通常配合 synchronized 关键字使用,在 synchronized 同步代码块里使用 wait()、notify() 和 notifyAll() 方法。若是调用 wait() 或者 notify() 方法时,线程并未获取到锁的话,则会抛出 IllegalMonitorStateException 异常。再次获取到锁,当前线程才能从 wait() 方法处成功返回。
  3. 因为 wait()、notify() 和 notifyAll() 在 synchronized 代码块执行,说明当前线程必定是获取了锁的。当线程执行 wait() 方法时候,会释放当前的锁,而后让出 CPU,进入等待状态。只有当 notify()/notifyAll() 被执行时候,才会唤醒一个或多个正处于等待状态的线程,而后继续往下执行,直到执行完 synchronized 代码块或是中途遇到 wait()再次释放锁
    也就是说,notify()/notifyAll() 的执行只是唤醒沉睡的线程,而不会当即释放锁,锁的释放要看代码块的具体执行状况。因此在编程中,尽可能在使用了 notify()/notifyAll() 后当即退出临界区,以唤醒其余线程。
  4. wait() 须要被 try catch 包围,中断也可使 wait 等待的线程唤醒。
  5. notify()wait() 的顺序不能错,若是 A 线程先执行 notify() 方法,B 线程再执行 wait() 方法,那么 B 线程是没法被唤醒的。
  6. notify()notifyAll() 的区别:
    notify() 方法只唤醒一个等待(对象的)线程并使该线程开始执行。因此若是有多个线程等待一个对象,这个方法只会唤醒其中一个线程,选择哪一个线程取决于操做系统对多线程管理的实现。
    notifyAll() 会唤醒全部等待 (对象的) 线程,尽管哪个线程将会第一个处理取决于操做系统的实现。若是当前状况下有多个线程须要被唤醒,推荐使用 notifyAll() 方法。好比在生产者-消费者里面的使用,每次都须要唤醒全部的消费者或是生产者,以判断程序是否能够继续往下执行。

对于 sleep()wait() 方法之间的区别,总结以下,

  1. 所属类不一样:sleep() 方法是 Thread 的静态方法,而 wait() 是 Object 实例方法。
  2. 做用域不一样:wait() 方法必需要在同步方法或者同步块中调用,也就是必须已经得到对象锁。而 sleep() 方法没有这个限制能够在任何地方种使用。
  3. 锁占用不一样:wait() 方法会释放占有的对象锁,使得该线程进入等待池中,等待下一次获取资源。而 sleep() 方法只是会让出 CPU 并不会释放掉对象锁;
  4. 锁释放不一样:sleep() 方法在休眠时间达到后若是再次得到 CPU 时间片就会继续执行,而 wait() 方法必须等待 Object.notift()/Object.notifyAll() 通知后,才会离开等待池,而且再次得到 CPU 时间片才会继续执行。
  • 线程的状态

  1. 新建 (NEW):新建立了一个线程对象。
  2. 可运行 (RUNNABLE):线程对象建立后,其余线程(好比 main 线程)调用了该对象的 start() 方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 CPU 的使用权 。
  3. 运行 (RUNNING):RUNNABLE 状态的线程得到了 CPU 时间片(timeslice) ,执行程序代码。
  4. 阻塞 (BLOCKED):阻塞状态是指线程由于某种缘由放弃了 CPU 使用权,也即让出了 CPU timeslice,暂时中止运行。直到线程进入 RUNNABLE 状态,才有机会再次得到 CPU timeslice 转到 RUNNING 状态。阻塞的状况分三种:
    1. 等待阻塞:RUNNING 的线程执行 o.wait() 方法,JVM 会把该线程放入等待队列 (waitting queue) 中。
    2. 同步阻塞:RUNNING 的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则 JVM 会把该线程放入锁池 (lock pool) 中。
    3. 其余阻塞:RUNNING 的线程执行 Thread.sleep(long)t.join() 方法,或者发出了 I/O 请求时,JVM 会把该线程置为阻塞状态。当 sleep() 状态超时、join() 等待线程终止或者超时、或者 I/O 处理完毕时,线程从新转入 RUNNABLE 状态。
  5. 死亡 (DEAD):线程 run()main() 方法执行结束,或者因异常退出了 run() 方法,则该线程结束生命周期。死亡的线程不可再次复生。
  • 死锁,线程死锁的 4 个条件?
  • 死锁的概念,怎么避免死锁?

当两个线程彼此占有对方须要的资源,同时彼此又没法释放本身占有的资源的时候就发生了死锁。发生死锁须要知足下面四个条件,

  1. 互斥:某种资源一次只容许一个进程访问,即该资源一旦分配给某个进程,其余进程就不能再访问,直到该进程访问结束。(一个筷子只能被一我的拿)
  2. 占有且等待:一个进程自己占有资源(一种或多种),同时还有资源未获得知足,正在等待其余进程释放该资源。(每一个人拿了一个筷子还要等其余人放弃筷子)
  3. 不可抢占:别人已经占有了某项资源,你不能由于本身也须要该资源,就去把别人的资源抢过来。(别人手里的筷子你不能去抢)
  4. 循环等待:存在一个进程链,使得每一个进程都占有下一个进程所需的至少一种资源。(每一个人都在等相邻的下一我的放弃本身的筷子)

产生死锁须要四个条件,那么,只要这四个条件中至少有一个条件得不到知足,就不可能发生死锁了。因为互斥条件是非共享资源所必须的,不只不能改变,还应加以保证,因此,主要是破坏产生死锁的其余三个条件。

破坏占有且等待的问题:容许进程只得到运行初期须要的资源,便开始运行,在运行过程当中逐步释放掉分配到的已经使用完毕的资源,而后再去请求新的资源。

破坏不可抢占条件:当一个已经持有了一些资源的进程在提出新的资源请求没有获得知足时,它必须释放已经保持的全部资源,待之后须要使用的时候再从新申请。释放已经保持的资源颇有可能会致使进程以前的工做实效等,反复的申请和释放资源会致使进程的执行被无限的推迟,这不只会延长进程的周转周期,还会影响系统的吞吐量。

破坏循环等待条件:能够经过定义资源类型的线性顺序来预防,可将每一个资源编号,当一个进程占有编号为i的资源时,那么它下一次申请资源只能申请编号大于 i 的资源。

《死锁的四个必要条件和解决办法》

  • synchronized 与 Lock 的区别
  • Lock 的实现原理
  • synchronized 的实现原理
  • ReentrantLock 的内部实现
  • CAS 介绍
  • 如何实现线程同步?synchronized, lock, 无锁同步, voliate, 并发集合,同步集合

sychronized 原理(表面的):

Java 虚拟机中的同步 (Synchronization) 基于进入和退出管程 (Monitor) 对象实现,不管是显式同步 (有明确的 monitorenter 和 monitorexit 指令,即同步代码块),仍是隐式同步都是如此。进入 monitorenter 时 monitor 中的计数器 count 加 1,释放当前持有的 monitor,count 自减 1. 反编译代码以后常常看到两个 monitorexit 指令对应一个 monitorenter,这是用来防止程序执行过程当中出现异常的。虚拟机须要保证即便程序容许中途出了异常,锁也同样能够被释放(执行第二个 monitorexit)。

对同步方法,JVM 能够从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当调用方法时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,若是设置了,执行线程将先持有 monitor,而后再执行方法,最后再方法完成 (不管是正常完成仍是非正常完成) 时释放monitor. 在方法执行期间,其余任何线程都没法再得到同一个 monitor. 若是一个同步方法执行期间抛出了异常,而且在方法内部没法处理此异常,那这个同步方法所持有的 monitor 将在异常抛到同步方法以外时自动释放。

sychronized 原理(底层的):

在 Java 对象的对象头中,有一块区域叫作 MarkWord,其中存储了重量级锁 sychronized 的标志位,其指针指向的是 monitor 对象。每一个对象都存在着一个 monitor 与之关联。在 monitor 的数据结构中定义了两个队列,_WaitSet 和 _EntryList. 当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把 monitor 中的 owner 变量设置为当前线程同时 monitor 中的计数器 count 加 1,若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 _WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor (锁)并复位变量的值,以便其余线程进入获取 monitor (锁)。

由此看来,monitor 对象存在于每一个 Java 对象的对象头中(存储的指针的指向),synchronized 锁即是经过这种方式获取锁的,也是为何 Java 中任意对象能够做为锁的缘由,同时也是 notify()/notifyAll()/wait() 等方法存在于顶级对象 Object 中的缘由。

固然,从 MarkWord 的结构中也能够看出 Java 对 sychronized 的优化:Java 6 以后,为了减小得到锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,锁效率也获得了优化。

(关于 sychronized 的底层实现原理能够参考笔者的文章:并发编程专题 3:synchronized)

sychronized 与 lock 的区别体如今下面四个方面:

  1. 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程能够选择放弃等待;(两种方式获取锁的时候都会使计数+1,可是方式不一样,因此重入锁能够终端)
  2. 公平锁:当多个线程等待同一个锁时,公平锁会按照申请锁的时间顺序来依次得到锁;而非公平锁,当锁被释放时任何在等待的线程均可以得到锁(不论时间尝试获取的时间前后)。sychronized 只支持非公平锁,Lock 能够经过构造方法指定使用公平锁仍是非公平锁。
  3. 锁能够绑定多个条件:ReentrantLock 能够绑定多个 Condition 对象,而 sychronized 要与多个条件关联就不得不加一个锁,ReentrantLock 只要屡次调用newCondition 便可。

ReentrantLock 的实现原理

ReentrantLock 的实现是基于 AQS(同步器),同步器设计的思想是 CAS. 同步器中维护了一个链表,借助 CAS 的思想向链表中增删数据。其底层使用的是 sun.misc.Unsafe 类中的方法来完成 CAS 操做的。在 ReentrantLock 中实现两个 AQS 的子类,分别是 NonfairSyncFairSync. 也就是用来实现公平锁和非公平锁的关键。当咱们使用构造方法获取 ReentrantLock 实例的时候,能够经过一个布尔类型的参数指定使用公平锁仍是非公平锁。在实现上, NonfairSyncFairSync 的区别仅仅是,在当前线程获取到锁以前,是否会从上述队列中判断是否存在比本身更早申请锁的线程。对于公平锁,当存在这么一个线程的话,那么当前线程获取锁失败。当当前线程获取到锁的时候,也会使用一个 CAS 操做将锁获取次数 +1. 当线程再次获取锁的时候,会根据线程来进行判断,若是当前持有锁的线程是申请锁的线程,那么容许它再次获取锁,以此来实现锁的可重入。

所谓 CAS 就是 Compare-And-Swape,相似于乐观加锁。但与咱们熟知的乐观锁不一样的是,它在判断的时候会涉及到 3 个值:“新值”、“旧值” 和 “内存中的值”,在实现的时候会使用一个无限循环,每次拿 “旧值” 与 “内存中的值” 进行比较,若是两个值同样就说明 “内存中的值” 没有被其余线程修改过;不然就被修改过,须要从新读取内存中的值为 “旧值”,再拿 “旧值” 与 “内存中的值” 进行判断。直到 “旧值” 与 “内存中的值” 同样,就把 “新值” 更新到内存当中。

这里要注意上面的 CAS 操做是分 3 个步骤的,可是这 3 个步骤必须一次性完成,由于否则的话,当判断 “内存中的值” 与 “旧值” 相等以后,向内存写入 “新值” 之间被其余线程修改就可能会获得错误的结果。JDK 中的 sun.misc.Unsafe 中的 compareAndSwapInt 等一系列方法 Native 就是用来完成这种操做的。另外还要注意,上面的 CAS 操做存在一些问题:

  1. 一个典型的 ABA 的问题,也就是说当内存中的值被一个线程修改了,又改了回去,此时当前线程看到的值与指望的同样,但实际上已经被其余线程修改过了。想要解决 ABA 的问题,则可使用传统的互斥同步策略。
  2. CAS 还有一个问题就是可能会自旋时间过长。由于 CAS 是非阻塞同步的,虽然不会将线程挂起,但会自旋(无非就是一个死循环)进行下一次尝试,若是这里自旋时间过长对性能是很大的消耗。 根据上面的描述也能够看出,CAS 只能保证一个共享变量的原子性,当存在多个变量的时候就没法保证。一种解决的方案是将多个共享变量打包成一个,也就是将它们总体定义成一个对象,并用 CAS 保证这个总体的原子性,好比 AtomicReference
  • volatile 原理和用法

voliate 关键字的两个做用

  1. 保证变量的可见性:当一个被 voliate 关键字修饰的变量被一个线程修改的时候,其余线程能够马上获得修改以后的结果。当写一个 volatile 变量时,JMM 会把该线程对应的工做内存中的共享变量值刷新到主内存中,当读取一个 volatile 变量时,JMM 会把该线程对应的工做内存置为无效,那么该线程将只能从主内存中从新读取共享变量。
  2. 屏蔽指令重排序:指令重排序是编译器和处理器为了高效对程序进行优化的手段,它只能保证程序执行的结果时正确的,可是没法保证程序的操做顺序与代码顺序一致。这在单线程中不会构成问题,可是在多线程中就会出现问题。很是经典的例子是在单例方法中同时对字段加入 voliate,就是为了防止指令重排序。

volatile 是经过内存屏障(Memory Barrier) 来实现其在 JMM 中的语义的。内存屏障,又称内存栅栏,是一个 CPU 指令,它的做用有两个,一是保证特定操做的执行顺序,二是保证某些变量的内存可见性。若是在指令间插入一条内存屏障则会告诉编译器和 CPU,无论什么指令都不能和这条 Memory Barrier 指令重排序。Memory Barrier 的另一个做用是强制刷出各类 CPU 的缓存数据,所以任何 CPU 上的线程都能读取到这些数据的最新版本。

  • 手写生产者/消费者模式

参考 《并发编程专题-5:生产者和消费者模式》 中的三种写法。


1.8 并发包

  • ThreadLocal 的实现原理?

ThreadLocal 经过将每一个线程本身的局部变量存在本身的内部来实现线程安全。使用它的时候会定义它的静态变量,每一个线程看似是从 TL 中获取数据,而实际上 TL 只起到了键值对的键的做用,实际的数据会以哈希表的形式存储在 Thread 实例的 Map 类型局部变量中。当调用 TL 的 get() 方法的时候会使用 Thread.currentThread() 获取当前 Thread 实例,而后从该实例的 Map 局部变量中,使用 TL 做为键来获取存储的值。Thread 内部的 Map 使用线性数组解决哈希冲突。(《ThreadLocal的使用及其源码实现》)

  • 并发类:并发集合了解哪些?
  1. ConcurrentHashMap:线程安全的 HashMap,对桶进行加锁,下降锁粒度提高性能。
  2. ConcurrentSkipListMap:跳表,自行了解,给跪了……
  3. ConCurrentSkipListSet:借助 ConcurrentSkipListMap 实现
  4. CopyOnWriteArrayList:读多写少的 ArrayList,写的时候加锁
  5. CopyOnWriteArraySet:借助 CopyOnWriteArrayList 实现的……
  6. ConcurrentLinkedQueue:无界且线程安全的 Queue,其 poll()add() 等方法借助 CAS 思想实现。锁比较轻量。

1.9 输入输出

  • NIO

  • 多线程断点续传原理

断点续传和断点下载都是用的用的都是 RandomAccessFile,它能够从指定的位置开始读取数据。断点续传是由服务器给客户端一个已经上传的位置标记position,而后客户端再将文件指针移动到相应的 position,经过输入流将文件剩余部分读出来传输给服务器。

若是要使用多线程来实现断点续传,那么能够给每一个线程分配固定的字节的文件,分别去读,而后分别上传到服务器。

二、Kotlin 相关

  • 对 Kotlin 协程的了解

协程实际上就是极大程度的复用线程,经过让线程满载运行,达到最大程度的利用 CPU,进而提高应用性能。相比于线程,协程不须要进行线程切换,和多线程比,线程数量越多,协程的性能优点就越明显。第二大优点就是不须要多线程的锁机制,由于只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只须要判断状态就行了,因此执行效率比多线程高不少。

协程和线程,都能用来实现异步调用,可是这二者之间是有本质区别的:

  1. 协程是编译器级别的,线程是系统级别的。协程的切换是由程序来控制的,线程的切换是由操做系统来控制的。
  2. 协程是协做式的,线程是抢占式的。协程是由程序来控制何时进行切换的,而线程是有操做系统来决定线程之间的切换的。
  3. 一个线程能够包含多个协程。Java 中,多线程能够充分利用多核 cpu,协程是在一个线程中执行。4. 协程适合 IO 密集型 的程序,多线程适合 计算密集型 的程序(适用于多核 CPU 的状况)。当你的程序大部分是文件读写操做或者网络请求操做的时候,这时你应该首选协程而不是多线程,首先这些操做大部分不是利用 CPU 进行计算而是等待数据的读写,其次由于协程执行效率较高,子程序切换不是线程切换,是由程序自身控制,所以,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优点就越明显。
  4. 使用协程能够顺序调用异步代码,避免回调地狱

参考:是继续Rxjava,仍是应该试试Kotlin的协程 - Android架构的文章 - 知乎

  • Kotlin 跟 Java 比,kotlin 具备哪些优点?

Kotlin 是一门基于 JVM 的语言,它提供了很是多便利的语法特性。若是说 Kotlin 为何那么优秀的话,那只能说是由于它站在了 Java 的肩膀上。学习了一段时间以后,你会发现它的许多语法的设计很是符合咱们实际开发中的使用习惯。

好比,对于一个类,一般咱们不会去覆写它。尤为是 Java Web 方向,不少的类用来做为 Java Bean,它们没有特别多的继承关系。而 Kotlin 中的类默认就是不容许继承的,想容许本身的类被继承,你还必须显式地使用 open 关键字指定。

对于 Java Bean,做为一个业务对象,它会有许多的字段。按照 Java 中的处理方式,咱们要为它们声明一系列的 setter 和 getter 方法。而后,获取属性的时候必须使用 setter 和 getter 方法。致使咱们的代码中出现很是多的括号。而使用 Kotlin 则能够直接对属性进行赋值,显得优雅地多。

再好比 Java 中使用 switch 的时候,咱们一般会在每一个 case 后面加上 break,而 kotlin 默认帮助咱们 break,这样就节省了不少的代码量。

另外 Kotlin 很是优秀的地方在于对 NPE 的控制。在 Android 开发中,咱们可使用 @NoneNull 和 @Nullable 注解来标明某个字段是否可能为空。在 Java 中默认字段是空的,而且没有任何提示。你一个不留神可能就致使了 NPE,但 Kotlin 中就默认变量是非空的,你想让它为空必须单独声明。这样,对于可能为空的变量就给了咱们提示的做用,咱们知道它可能为空,就会去特地对其进行处理。对于可能为空的类,Kotlin 定义了以下的规则,使得咱们处理起来 NPE 也变得很是简单:

  1. 使用 ? 在类型的后面则说明这个变量是可空的;
  2. 安全调用运算符 ?.,以 a?.method() 为例,当 a 不为 null 则整个表达式的结果是 a.method() 不然是 null;
  3. Elvis 运算符 ?:,以 a ?: "A" 为例,当 a 不为 null 则整个表达式的结果是 a,不然是 “A”;
  4. 安全转换运算符 as?,以 foo as? Type 为例,当 foo 是 Type 类型则将 foo 转换成 Type 类型的实例,不然返回 null;
  5. 非空断言 !!,用在某个变量后面表示断言其非空,如 a!!
  6. let 表示对调用 let 的实例进行某种运算,如 val b = "AA".let { it + "A" } 返回 “AAA”;

诸如此类,不少时候,我以为 Java 设计的一些规则对人们产生了误导,实际开发中并不符合咱们的使用习惯。而 Kotlin 则是根据多年来人们使用 Java 的经验,简化了许多的调用,更加符合咱们使用习惯。因此说,Kotlin 之因此强大是由于站在 Java 的肩膀上。

三、设计模式

  • 谈谈你对 Android 设计模式的理解
  • 项目中经常使用的设计模式有哪些?
  1. 工厂+策略:用来建立各类实例,好比,美国一个实现,中国一个实现的情形;
  2. 观察者:一个页面对事件进行监听,注册,取消注册,通知;
  3. 单例:太多,为了延迟初始化;
  4. 构建者:类的参数太多,为了方便调用;
  5. 适配器:RecyclerView 的适配器;
  6. 模板:设计一个顶层的模板类,好比抽象的 Fragment 或者 Activity 等,可是注意组合优于继承,不要过分设计;
  7. 外观:相机模块,Camera1 和 Camera2,封装其内部实现,统一使用 CameraManager 的形式对外提供方法。
  • 手写观察者模式?

观察者设计模式相似于咱们常用的接口回调,下面的代码中在观察者的构造方法中订阅了主题,其实这个倒不怎么重要,何时订阅均可以。核心的地方就是主题中维护的这个队列,须要通知的时候调一下通知的方法便可。另外,若是在多线程环境中还要考虑如何进行线程安全控制,好比使用线程安全的集合等等。下面只是一个很是基础的示例程序,了解设计思想,用的时候能够灵活一些,没必要循规蹈矩。

public class ConcreteSubject implements Subject {
        private List<Observer> observers = new LinkedList<>(); // 维护观察者列表

        @Override
        public void registerObserver(Observer o) { // 注册一个观察者
            observers.add(o);
        }

        @Override
        public void removeObserver(Observer o) { // 移除一个观察者
            int i = observers.indexOf(o);
            if (i >= 0) {
                observers.remove(o);
            } 
        }

        @Override
        public void notifyObservers() { // 通知全部观察者主题的更新
            for (Observer o : observers) {
                o.method();
            }
        }
    }

    public class ConcreteObserver implements Observer {
        private Subject subject; // 该观察者订阅的主题

        public ConcreteObserver(Subject subject) {
            this.subject = subject;
            subject.registerObserver(this); // 将当前观察者添加到主题订阅列表中
        }
        
        // 当主题发生变化的时候,主题会遍历观察者列表并经过调用该方法来通知观察者
        @Override
        public void method() {
            // ... 
        }
    }
复制代码

(了解更多关于观察者设计模式的内容,请参考文章:设计模式解析:观察者模式

  • 手写单例模式,懒汉和饱汉
// 饱汉:就是在调用单例方法的时候,实例已经初始化过了
    public class Singleton {
        private static Singleton singleton = new Singleton();

        private Singleton() {}

        public static Singleton getInstance() {
            return singleton;
        }
    }

    // 懒汉:在调用方法的时候才进行初始化
    public class Singleton {
        private volatile static Singleton singleton;

        private Singleton() {}

        public static Singleton getInstance() {
            if (singleton == null) {
                sychronized(Singleton.class) {
                    if (singleton == null) {
                        singleton = new Singleton();
                    }
                }
            }
            return singleton;
        }
    } 
复制代码

另外,单例须要注意的问题是:1.若是用户使用反射进行初始化怎么办?能够在建立第二个实例的时候抛出异常;2.若是用户使用 Java 的序列化机制反复建立单例呢?将全部的实例域设置成 transient 的,而后覆写 readResolve() 方法并返回单例。

另外,单实例太多的时候能够想办法使用一个 Map 将它们存储起来,而后经过一种规则从哈希表中取出,这样就不必声明一大堆的单例变量了。

(了解更多关于单例设计模式的内容,请参考文章:设计模式-4:单例模式

  • 适配器模式、装饰者模式、外观模式、代理模式的异同?(这个几个设计模式比较容易混)

四个设计模式相同的地方是,它们都须要你传入一个类,而后内部使用你传入的这个类来完成业务逻辑。

咱们以字母 A,B,C 来表示 3 种不一样的类(某种东西)。

外观模式要隐藏内部的差别,提供一个一致的对外的接口 X,那么让定义 3 个类 AX, BX, CX 而且都实现 X 接口,其中分别引用 A, B, C 按照各自的方式实现 X 接口的方法便可。以相机开发为例,Camera1 和 Camera2 各有本身的实现方式,定义一个统一的接口和两个实现类。

假如如今有一个类 X,其中引用到了接口 A 的实现 AX. AX 的逻辑存在点问题,咱们想把它完善一下。咱们提供了 3 种方案,分别是 A1, A2 和 A3. 那么此时,咱们让 A1, A2 和 A3 都实现 A 接口,而后其中引用 AX 完成业务,在实现的 A 接口的方法中分别使用各自的方案进行优化便可。这种方式,咱们对 AX 进行了修饰,使其 A1, A2 和 A3 能够直接应用到 X 中。

对于适配器模式,假如如今有一个类 X,其中引用到了接口 A. 如今咱们不得不使用 B 来完成 A 的逻辑。由于 A 和 B 属于两个不一样的类,因此此时咱们须要一个适配器模式,让 A 的实现 AX 引用 B 的实现 BX 完成 A 接口的各个方法。

外观模式的目的是隐藏各种间的差别性,提供一致的对外接口。装饰者模式对外的接口是一致的,可是内部引用的实例是同一个,其目的是对该实例进行拓展,使其具备多种功能。因此,前者是多对一,后者是一对多的关系。而适配器模式适用的是两个不一样的类,它使用一种类来实现另外一个类的功能,是一对一的。相比之下,代理模式也是用一类来完成某种功能,而且一对一,但它是在同类之间,目的是为了加强类的功能,而适配器是在不一样的类之间。装饰者和代理都用来加强类的功能,可是装饰者装饰以后仍然是同类,能够无缝替换以前的类的功能。而代理类被修饰以后已是代理类了,是另外一个类,没法替换原始类的位置。


Android 高级面试系列文章,关注做者及时获取更多面试资料

本系列以及其余系列的文章均维护在 Github 上面:Github / Android-notes,欢迎 Star & Fork. 若是你喜欢这篇文章,愿意支持做者的工做,请为这篇文章点个赞👍!

相关文章
相关标签/搜索