腾讯、阿里、华为面试必问,2024最新Android框架体系架构面试题

所以 add(E) 方法是根据 ReentrantLock + 数组copy + update Object[] 内存地址 + volatile 来保证其数据安全性的。

面试官:

你刚刚说 add(E) 函数中是通过 ReentrantLock + 数组copy 等其它手段来实现的线程安全,那既然有了互斥锁保证了线程安全,为什么还要 copy 数组呢 ?

程序员:

的确,对 add(E) 进行加锁后,能够保证同一时刻,只有一个线程能对数组进行 add(E),在同单核 CPU 下的多线程环境下肯定没有问题,但我们现在的机器都是多核 CPU,如果我们不通过复制拷贝新建数组,修改原数组容器的内存地址的话,是无法触发 volatile 可见性效果的,那么其他 CPU 下的线程就无法感知数组原来已经被修改了,就会引发多核 CPU 下的线程安全问题。

假设我们不复制拷贝,而是在原来数组上直接修改值,数组的内存地址就不会变,而数组被 volatile 修饰时,必须当数组的内存地址变更时,才能及时的通知到其他线程,内存地址不变,仅仅是数组元素值发生变化时,是无法把数组元素值发生变动的事实,通知到其它线程的。

面试官:

嗯,看来你对这些机制都了解的挺清楚的,那你在说说 remove 是怎么保证的线程安全吧?

2、remove 是怎么保证线程安全的?

其实 remove 保证线程安全机制跟 add 思路都差不多,都是先加锁 +不同策略的数组拷贝最后是释放锁。

面试官:

add , remove 方法内部都实现了 copy ,在性能上你有什么优化建议吗?

程序员:

尽量使用 addAll、removeAll 方法,而不要在循环里面使用 add、remove 方法,主要是因为 for 循环里面使用 add 、remove 的方式,在每次操作时,都会进行一次数组的拷贝(甚至多次),非常耗性能,而 addAll、removeAll 方法底层做了优化,整个操作只会进行一次数组拷贝,由此可见,当批量操作的数据越多时,批量方法的高性能体现的越明显。

Map

1、说一下你对 HashMap 的了解

程序员:

HashMap 底层是数组 + 单链表 + 红黑树 组成的存储数据结构,简单来说当链表长度大于等于 8 并且数组长度大于 64 那么就会由链表转为红黑树,当红黑树的大小容量 <= 6 时又转换为 链表的一个底层结构。非线程安全的。

可以用一张图来解释 HashMap 底层结构,如下所示:

图解:

  1. 最左边 table 是 HashMap 的数组结构,允许 Node 的 value 值为 NULL

  2. 数组的扩容机制第一次默认扩容大小为 16 size, 扩容阀值为 threshold = size * loadFactor -> 12 = 16 * 0.75 ,只要 ++size > threshold 就按照 newCap = oldCap << 1 机制来扩容。

  3. 数组的下标有可能是一个链表、红黑树,也有可能只是一个 Node,只有当数组长度 > 64,链表长度 >= 8 才会将数组中的 Node 节点转为 TreeNode 节点。也只有当红黑树的大小 <= 6 时,才转为单链表结构。

程序员:

HashMap 底层的基本实现实现基本就是这样。

面试官:

嗯,那你描述一下 put(K key, V value) 这个 API 的存储过程 。

程序员:

  1. 好的,我先描述一下基本流程,最后我画一张流程图来总结一下

  2. 根据 key 通过该公式 (h = key.hashCode()) ^ (h >>> 16) 计算 hash 值

  3. 判断 HashMap table 数组是否已经初始化,如果没有初始化,那么就按照默认 16 的大小进行初始化,扩容阀值也将按照 size * 0.75 来定义

  4. 通过该公式 (n - 1) & hash 拿到存入 table 的 index 索引,判断当前索引下是否有值,如果没有值就进行直接赋值 tab[index] , 如果有值,那么就会发生 hash 碰撞 💥 ,也就是俗称 hash冲突 , 在 JDK中的解决是的办法有 2 个,其一是链表,其二是 红黑树。

  5. 当发送 hash 冲突 首先判断数组中已存入的 key 是否与当前存入的 key 相同,并且内存地址也一样,那么就直接默认直接覆盖 values

  6. 如果 key 不相等,那么先拿到 tab[index] 中的 Node是否是红黑树,如果是红黑树,那么就加入红黑树的节点;如果 Node 节点不是红黑树,那么就直接放入 node 的 next 下,形成单链表结构。

  7. 如果链表结构的长度 >= 8 就转为红黑树的结构。

  8. 最后检查扩容机制。

整个 put 流程就是这样,可以用一个流程图来进行总结,如下所示:

image

面试官:

嗯,理解的很透彻,刚刚你说解决 hash 冲突有 2 种办法,那你描述一下红黑树是怎么实现新增的?

程序员:

好的,基本流程有如下几步:

1、首先判断新增的节点在红黑树上是不是已经存在,判断手段有如下两种:

​ 1.1、如果节点没有实现 Comparable 接口,使用 equals 进行判断;

​ 1.2、如果节点自己实现了 Comparable 接口,使用 compareTo 进行判断。

2、新增的节点如果已经在红黑树上,直接返回;不在的话,判断新增节点是在当前节点的左边还是右边,左边值小,右边值大;

3、自旋递归 1 和 2 步,直到当前节点的左边或者右边的节点为空时,停止自旋,当前节点即为我们新增节点的父节点;

4、把新增节点放到当前节点的左边或右边为空的地方,并于当前节点建立父子节点关系;

5、进行着色和旋转,结束。

面试官:

你知道链表转红黑树定义的长度为什么是 8 吗?

程序员:

这个答案,我通过 HashMap 类中的注释有留意过,它大概描述的意思是链表查询的时间复杂度是 O (n),红黑树的查询复杂度是 O (log (n))。在链表数据不多的时候,使用链表进行遍历也比较快,只有当链表数据比较多的时候,才会转化成红黑树,但红黑树需要的占用空间是链表的 2 倍,考虑到转化时间和空间损耗,所以我们需要定义出转化的边界值。

在考虑设计 8 这个值的时候,我们参考了泊松分布概率函数,由泊松分布中得出结论,链表各个长度的命中概率为:

  • 0: 0.60653066
  • 1: 0.30326533
  • 2: 0.07581633
  • 3: 0.01263606
  • 4: 0.00157952
  • 5: 0.00015795
  • 6: 0.00001316
  • 7: 0.00000094
  • 8: 0.00000006

意思是,当链表的长度是 8 的时候,出现的概率是 0.00000006,不到千万分之一,所以说正常情况下,链表的长度不可能到达 8 ,而一旦到达 8 时,肯定是 hash 算法出了问题,所以在这种情况下,为了让 HashMap 仍然有较高的查询性能,所以让链表转化成红黑树,我们正常写代码,使用 HashMap 时,几乎不会碰到链表转化成红黑树的情况,毕竟概念只有千万分之一。

面试官:

嗯,那你在说说数组为什么每次都是以 2的幂次方扩容?

程序员:

好的。如下是我的理解。

HashMap 为了存取高效,要尽量减少碰撞,就是要尽量把数据分配均匀。

比如:

2 的 n 次方实际就是 1 后面 n 个 0,2 的 n 次方 -1,实际就是 n 个 1。

那么长度为 8 时候,3 & (8-1) = 3 ,2 & (8-1) = 2 ,不同位置上,不碰撞。

而长度为 5 的时候,3 & (5-1)= 0 , 2 & (5-1) = 0,都在 0 上,出现碰撞了。

//3 & 4
011
100
000

//2 & 4
010
100
000

所以,保证容积是 2 的 n 次方,是为了保证在做 (size-1) 的时候,每一位都能 & 1 ,也就是和 1111……1111111进行与运算。

面试官:

在考你一道扩容机制的题目,现在后台的图片数据有 1000 条, 当我请求下来也处理完了,现在我想要缓存到 Map 中,如果我直接调用 new HashMap(1000) 构造方法,内部还会扩容吗?

程序员:

你可以这样回答,其实如果直接给定 1000 的初始化容量,那么我们需要根据源码中的计算来分析,有如下几个步骤:

1、首先会在构造函数中调用 1024 = tableSizeFor(1000); 该 API 来计算扩容阀值。

你可不要认为,这里就是真正的扩容大小,它在扩容的时候还会有一个计算公式。

2、计算真正的扩容阀值。

那么根据第一次 put 数据的时候,判断 table 是否为空,如果为空那么就需要扩容。

final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table; //第一次进来为 null 那么就是 0 长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold; //这里其实就是 1024
int newCap, newThr = 0;
if (oldCap > 0) {
…//省略代码
} else if (oldThr > 0)
newCap = oldThr;// newCap = 1024
if (newThr == 0) {
float ft = (float)newCap * loadFactor;//1024 * 0.75 = 768.0
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE); //768
}
//更新扩容的阀值
threshold = newThr;
//实例化一个数组
@SuppressWarnings({“rawtypes”,“unchecked”})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//正在扩容 newTab 的大小
table = newTab;
…//省略代码
}
return newTab;
}

可以看到其实真正的扩容阀门是 768。

3、判断扩容机制

那么只要添加到 768 的时候,就会发生扩容,如下代码所示:

if (++size > threshold)//769 > 768 需要扩容
resize();

所以当我们给定 1000 为初始化扩容容量的时候,是需要扩容的。因为底层并不会真正以 1024 来进行设置阀门,它还要乘以一个加载因子。这个时候其实我们可以有办法不让它扩容,那就是调用 new HashMap(1000,1f) 那么就不会扩容了。

这里你不仅给出了实际答案,还提供了解决办法。 面试官对你的回答肯定是满意的。

2、说一下你对 ArrayMap 的了解

程序员:

ArrayMap 底层通过两个数组来建立映射关系,其中 int[] mHashes 按大小顺序保存 Key 对象 hashCode 值,Object[] mArray 按 mHashes 的顺序用相邻位置保存 Key 对象和 Value 对象。mArray 长度 是 mHashes 长度的 2 倍。

存储数据是根据 key 的 hashcode() 方法得到 hash 值,计算出在 mArrays 的 index 值,然后利用二分查找找到对应的位置进行插入,当出现哈希冲突时,会在 inde 的相邻位置插入。

取数据是根据 key 的 hashcode() 方法得到 hash 值,然后通过 hash 值根据二分查找拿到 mHashes 的 index 索引,最后在根据 index + 1 索引拿到 mArrays 对应的 values 值。

3、你在工作中对 HashMap 和 ArrayMap 还有 SparseArray 是怎么选型的 ?

程序员:

好的,我总结了一套性能对比,每次需求我都是参考如下的总结。

4、有用过 LinkedHashMap 吗 ?底层怎么维护插入顺序的,又是怎么维护删除最少访问元素的 ?

ps: 由于内部存储机制都是散开的,如果按照散开的来连接,那图上连接线估计很乱,所以为了上图能够稍微好点一点,我就按照我自己的思路来绘制的,当然,内部结构还是不会变的。

程序员:

有用过,之前看 LruCache 底层也是基于 LinkedHashMap 实现的。那我还是按照我的思路来回答吧。

通过翻阅源码得知它是继承于 HashMap ,那么间接的它也拥有了 HashMap 的所有特性,而且在此基础上,还提供了两大特性,一个是增加了插入顺序和实现了最近最少访问的删除策略。

先来看下是怎么实现顺序插入:

LinkedHashMap 外部结构是一个双向链表结构,内部是一个 HashMap 结构,它就是相当于 HashMap + LinkedHashMap 的结合体。

其实 LinkedHashMap 的源码实现很简单,它就是重写了 HashMap##put 方法执行中调用的 newNode/newTreeNode 方法。然后在该函数内部中实现了链表的双向连接。如下图所示:

总结来说,LinkedHashMap 把新增的节点都使用双向链表连接起来,从而实现了插入顺序。然后核心的数据结构还是交于 HashMap 来处理维护的。

在来看下是怎么实现的访问最少删除功能:

其实访问最少删除功能的这种策略也叫做 LRU 算法,底层原理就是把经常使用的元素会被追加到当前链表的尾结点,而不经常使用的就自然都靠在链表的头节点,然后我们就可以设置删除策略,比如给当前 Map 设置一个策略大小,那么当存入的数据大于设置的大小时,就会从头节点开始删除。

在源码当中,将经常使用的 节点数据追加到链表的操作是在 get API 中,如下所示:

public V get(Object key) {
Node<K,V> e;
// 调用 HashMap getNode 方法
if ((e = getNode(hash(key), key)) == null)
return null;
// 如果设置了 LRU 策略
if (accessOrder)
// 这个方式把当前 key 移动到尾节点
afterNodeAccess(e);
return e.value;
}

当存入数据的时候 LinkedHashMap 重写的 HashMap#putVal 方法中的 afterNodeInsertion API 。

//HashMap
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
…//删除其余代码

// 删除不经常使用的元素
afterNodeInsertion(evict);
}

//LinkedHashMap
// 删除不经常使用的元素
void afterNodeInsertion(boolean evict) { // possibly remove eldest
// 得到元素头节点
Entry<K,V> first;
// removeEldestEntry 来控制删除策略,removeEldestEntry 外部控制是否删除
if (evict && (first = head) != null && removeEldestEntry(first)) {
//拿到头节点的 key,删除头,因为最近使用的在 get 的时候都会移动到尾结点
K key = first.key;
// removeNode 删除节点
removeNode(hash(key), key, null, false, true);
}
}

总结来说,LinkedHashMap 的操作都是基于 HashMap 暴露的 API , 实现了顺序存储和最近最少删除策略。

说了这些原理之后,一般来说面试官不会再问其它的。因为核心功能我们都已经回答完了。

5、你知道 TreeMap 的内部是怎么排序的吗 ?

程序员:

嗯,知道。这个 API 我使用的比较少,之前只是看过它的源码,知道它的底层还是红黑树结构,跟 HashMap 的红黑树是一样的。然后 TreeMap 是利用了红黑树左大右小的性质,根据 key 来进行排序的。

面试官:

嗯,那你具体来说一下底层是怎么根据 key 排序的?

程序员:

在程序中,如果我们想给一个 List 排序的话,其一是实现 Comparable##compareTo 接口抽象方法,其二是利用外部排序器 Comparator 进行排序, 而 TreeMap 利用的也是此原理,从而实现了对 key 的排序。

我就直接说一下 put(K key, V value) API 怎么实现的排序吧。

1、判断红黑树的节点是否为空,为空的话,新增的节点直接作为根节点,代码如下:

Entry<K,V> t = root;
//红黑树根节点为空,直接新建
if (t == null) {
// compare 方法限制了 key 不能为 null
compare(key, key); // type (and possibly null) check
// 成为根节点
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}

2、根据红黑树左小右大的特性,进行判断,找到应该新增节点的父节点,代码如下:

Comparator<? super K> cpr = comparator;
if (cpr != null) {
//自旋找到 key 应该新增的位置,就是应该挂载那个节点的头上
do {
//一次循环结束时,parent 就是上次比过的对象
parent = t;
// 通过 compare 来比较 key 的大小
cmp = cpr.compare(key, t.key);
//key 小于 t,把 t 左边的值赋予 t,因为红黑树左边的值比较小,循环再比
if (cmp < 0)
t = t.left;
//key 大于 t,把 t 右边的值赋予 t,因为红黑树右边的值比较大,循环再比
else if (cmp > 0)
t = t.right;
//如果相等的话,直接覆盖原值
else
return t.setValue(value);
// t 为空,说明已经到叶子节点了
} while (t != null);
}

3、在父节点的左边或右边插入新增节点,代码如下:

//cmp 代表最后一次对比的大小,小于 0 ,代表 e 在上一节点的左边
if (cmp < 0)
parent.left = e;
//cmp 代表最后一次对比的大小,大于 0 ,代表 e 在上一节点的右边,相等的情况第二步已经处理了。
else
parent.right = e;

4、着色旋转,达到平衡,结束。

可以看到 TreeMap 排序是根据如果外部有传进来 Comparator 比较器,那么就用 Comparator 来进行对 key 比较,如果外部没有就用 Key 自己实现 Comparable 的 compareTo 方法。

6、ConcurrentHashMap 通过哪些手段保证了线程安全?

程序员:

它的主要结构跟 HashMap 一样底层都是基于数组 + 单链表 + 红黑树构成。

保证线程安全主要有一下几点:

1、储存 Map 数据的数组被 volatile 关键字修饰,一旦被修改,立马就能通知其他线程,因为是数组,所以需要改变其内存值,才能真正的发挥出 volatile 的可见特性;

//第一次插入时才会初始化,java7是在构造器时就初始化了
//容量大小都是2的幂次方,通过iterators进行迭代
transient volatile Node<K,V>[] table;

//扩容后的数组
private transient volatile Node<K,V>[] nextTable;

2、put 时,如果数组还未初始化,那么使用 Thread##yield 和 sun.misc.Unsafe##compareAndSwapInt 保证了只有一个线程初始化数组。

3、put 时,如果计算出来的数组下标索引没有值的话,采用无限 for 循环 + CAS 算法,来保证一定可以新增成功,又不会覆盖其他线程 put 进去的值;

//如果当前索引位置没有值,直接创建
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//cas 在 i 位置创建新的元素,当i位置是空时,创建成功结束for自循,否则继续自旋
if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))break;
}

/**

  • CAS
  • @param tab 要修改的对象
  • @param i 对象中的偏移量
  • @param c 期望值
  • @param v 更新值
  • @return true | false
    */
    static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
    }

4、如果 put 的节点正好在扩容,会等待扩容完成之后,再进行 put ,保证了在扩容时,老数组的值不会发生变化;

//如果当前的hash是转发节点的hash,表示该槽点正在扩容,就会一直等待扩容完成
if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);

5、对数组的槽点进行操作时,会先锁住槽点,保证只有当前线程才能对槽点上的链表或红黑树进行操作;

6、红黑树旋转时,会锁住根节点,保证旋转时的线程安全。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

面试官:

刚刚看你说了到了 CAS 算法,那你描述一下 CAS 算法在 ConcurrentHashMap 中的应用?

程序员:

CAS 其实是一种乐观锁,一般有三个值,分别为:赋值对象,原值,新值,在执行的时候,会先判断内存中的值是否和原值相等,相等的话把新值赋值给对象,否则赋值失败,整个过程都是原子性操作,没有线程安全问题。

ConcurrentHashMap 的 put 方法中,有使用到 CAS ,是结合无限 for 循环一起使用的,步骤如下:

  1. 计算出数组索引下标,拿出下标对应的原值;

  2. CAS 覆盖当前下标的值,赋值时,如果发现内存值和 1 拿出来的原值相等,执行赋值,退出循环,否则不赋值,转到 3;

  3. 进行下一次 for 循环,重复执行 1,2,直到成功为止。

可以看到这样做的好处,第一是不会盲目的覆盖原值,第二是一定可以赋值成功。

面试官:

嗯,还不错,那你再说下与 HashMap 的相同点和不同点

程序员:

相同点:

  1. 都实现了 Map 接口,继承了 AbstractMap 抽象类,所以两者的方法大多都是相似的,可以互相切换。

  2. 底层都是基于 数组 + 单链表 + 红黑树实现。

不同点:

  1. ConcurrentHashMap 是线程安全的,在多线程环境下,无需加锁,可直接使用;

  2. 数据结构上,ConcurrentHashMap 多了转移节点,主要用于保证扩容时的线程安全。

Queue

1、说一说你对队列的理解,队列和集合的区别 ?

程序员:

好的,那我先说一下对队列的理解,然后在说下区别;

对队列的理解:

  1. 首先队列本身也是个容器,底层也会有不同的数据结构,比如 LinkedBlockingQueue 是底层是链表结构,所以可以维持先入先出的顺序,比如 DelayQueue 底层可以是队列或堆栈,所以可以保证先入先出,或者先入后出的顺序等等,底层的数据结构不同,也造成了操作实现不同;

  2. 部分队列(比如 LinkedBlockingQueue )提供了暂时存储的功能,我们可以往队列里面放数据,同时也可以从队列里面拿数据,两者可以同时进行;

  3. 队列把生产数据的一方和消费数据的一方进行解耦,生产者只管生产,消费者只管消费,两者之间没有必然联系,队列就像生产者和消费者之间的数据通道一样,如 LinkedBlockingQueue;

  4. 队列还可以对消费者和生产者进行管理,比如队列满了,有生产者还在不停投递数据时,队列可以使生产者阻塞住,让其不再能投递,比如队列空时,有消费者过来拿数据时,队列可以让消费者 hodler 住,等有数据时,唤醒消费者,让消费者拿数据返回,如 ArrayBlockingQueue;

  5. 队列还提供阻塞的功能,比如我们从队列拿数据,但队列中没有数据时,线程会一直阻塞到队列有数据可拿时才返回。

区别:

1、和集合的相同点,队列(部分例外)和集合都提供了数据存储的功能,底层的储存数据结构是有些相似的,比如说 LinkedBlockingQueue 和 LinkedHashMap 底层都使用的是链表,ArrayBlockingQueue 和 ArrayList 底层使用的都是数组。

2、和集合的区别:

  1. 部分队列和部分集合底层的存储结构很相似的,但两者为了完成不同的事情,提供的 API 和其底层的操作实现是不同的。

  2. 队列提供了阻塞的功能,能对消费者和生产者进行简单的管理,队列空时,会阻塞消费者,有其他线程进行 put 操作后,会唤醒阻塞的消费者,让消费者拿数据进行消费,队列满时亦然。

  3. 解耦了生产者和消费者,队列就像是生产者和消费者之间的管道一样,生产者只管往里面丢,消费者只管不断消费,两者之间互不关心。

2、有用过 LinkedBlockingQueue 队列吗? 说一下 LinkedBlockingQueue 底层怎么实现数据存取。

程序员:

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级安卓工程师,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年最新Android移动开发全套学习资料》送给大家,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
img
img
img
img

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频
如果你觉得这些内容对你有帮助,可以添加下面V无偿领取!(备注Android)
img

Android高级架构师

由于篇幅问题,我呢也将自己当前所在技术领域的各项知识点、工具、框架等汇总成一份技术路线图,还有一些架构进阶视频、全套学习PDF文件、面试文档、源码笔记做整理一份资料。

需要的朋友可以**私信【学习】**我分享给你,希望里面的资料可以给你们一个更好的学习参考。

或者直接点击下面链接免费获取

Android学习PDF+架构视频+面试文档+源码笔记

  • 330页PDF Android学习核心笔记(内含上面8大板块)

  • Android学习的系统对应视频

  • Android进阶的系统对应学习资料

  • Android BAT部分大厂面试题(有解析)

好了,以上便是今天的分享,希望为各位朋友后续的学习提供方便。觉得内容不错,也欢迎多多分享给身边的朋友哈。

习PDF文件、面试文档、源码笔记做整理一份资料。

需要的朋友可以**私信【学习】**我分享给你,希望里面的资料可以给你们一个更好的学习参考。

或者直接点击下面链接免费获取

Android学习PDF+架构视频+面试文档+源码笔记

  • 330页PDF Android学习核心笔记(内含上面8大板块)

[外链图片转存中…(img-czs36tgW-1711100970529)]

[外链图片转存中…(img-dSPBlUYC-1711100970529)]

  • Android学习的系统对应视频

  • Android进阶的系统对应学习资料

[外链图片转存中…(img-l9LnK4va-1711100970530)]

  • Android BAT部分大厂面试题(有解析)

[外链图片转存中…(img-pDi4Kxex-1711100970530)]

好了,以上便是今天的分享,希望为各位朋友后续的学习提供方便。觉得内容不错,也欢迎多多分享给身边的朋友哈。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值