专栏首页我要变牛Java杂谈之synchronized锁升级

Java杂谈之synchronized锁升级

--------无聊望见了犹豫 达到理想不太易


胡思乱想,枯木逢春

本来计划一个月更新一到两篇技术文章,由于最近生病只能在家吃饭和多次复查,原本一些的空闲时间被辗转于医院和做饭占用,生病后才能更深刻体会健康的重要性。

另外由于搬家周末也是几乎都没有什么时间,搬东西时发现东西越搬越多,很多估计以后永远不会再用到的东西,看到这些想起小学时学过的一篇文章《哨子》,买了很多无用的东西同时又缺少断舍离的勇气,导致杂物缠身。以后买东西切记不要为一个“哨子”付出过高的代价,同时该舍弃时就要舍弃,太深的留恋反而变成了牵绊。


用户态与内核态

所有的JVM底层原理逃不开操作系统,从操作系统层面看程序分为内核态和用户态

什么是用户态和内核态

  • 用户态:只能受限的访问内存,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。
  • 内核态:cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。

为什么要有用户态和内核态?

由于需要限制不同的程序之间的访问能力, 防止他们获取别的程序的内存数据, 或者获取外围设备的数据, 并发送到网络, 划分出两个权限等级 -- 用户态和内核态。

为什么要讲用户态和内核态?

因为要理解syncronized锁升级就必须有这方面的基础,JVM更多的是定义规范实现很多种,本文主要介绍oracle实现的Hotspot版本,像IBM的J9、taobaoVM都有类似的实现。

syncronized锁升级

很多文章已经介绍过JDK早期(1.6之前不包括1.6),syncronized是重量级锁。

什么是锁

从古代的门闩、铁锁到现在的密码锁、指纹锁,锁的便携性和安全性不断提高,对私有财产保护更加高效和健全。在计算机世界里,单机线程时代里没有锁的概念。自从出现了资源竞争,我们才意识到需要对部分场景执行的现场加锁表明自己短暂的拥有。计算机开始的锁都是悲观锁,发展到现在乐观锁、偏向锁、分段锁等。锁主要提供了两种特性:互斥性和不可见性。因为有锁的存在,某些操作对外界来说是黑箱进行的,只有锁的持有者才知道对变量做了什么修改。

什么是重量级锁?

早期synchronized因为申请锁资源必须通过内核kernel系统调用,所以称为重量级锁。

为什么是重量级锁

为什么经过内核就是重量级锁了???这就要从synchronized底层说起,早期底层实现为了简便直接用了互斥锁,synchronized应该称为监视器锁(Monitor)本质是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。每个对象都对应于一个可称为" 互斥锁" 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象,这个互斥锁的CPU与内存的北桥信号或总线。同时,执行互斥锁需要操作系统的调用,由于Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就需要从用户态转换到内核态中,状态转换需要耗费很多的处理器时间。所以synchronized是Java语言中的一个重量级操作,重量的原因是需要操作系统大哥帮忙调度,这就会涉及系统调用和中断,下面汇编代码简单说明系统调用过程

section .text
global _start
_start:

    mov edx, len
    mov ecx, msg
    mov ebx, 1 ;文件描述符1 std_out
    mov eax, 4 ;write函数系统调用号 4
    int 0x80

    mov ebx, 0
    mov eax, 1 ;exit函数系统调用号
    int 0x80

当程序执行系统调用时首先使用类似int 80H的软中断命令保存现场,去系统调用、内核执行、然后恢复现场,每个线程都会有两个栈,一个内核态栈和一个用户态栈。当中断执行时就会有用户态转到内核态,系统调用会执行栈的切换,而且内核态对用户态是不信任的,需要做一系列额外的检查,系统调用的返回过程需要很多检查比如是否需要调度、同时还要保存上下文,这都是说明为什么是重量级锁的原因。

为什么说是同步监视器

先看如下代码,一个简单的synchronized代码块

public class SynLock {
    public void testSynBlock() {
        synchronized (this) {
            System.out.println("steven");
        }
    }
}

javac编译后可以看下字节码,如果用文本工具直接打卡都是16进制代码,除了最开始java版本基本看不懂,所以用javap反编译来看,javap可以把字节码编译成一些可读的代码形式,反编译class后如下:

从图中反编译可以看到多了monitorenter 和 monitorexit指令,JVM规范里对moniterenter 和 monitorexit的介绍:

大体意思: 每个对象都有一个监视器(Moniter)与它相关联,执行moniterenter指令的线程将获得与objectref关联的监视器的所有权,如果另一个线程已经拥有与objectref关联的监视器,则当前线程将等待直到对象被解锁为止。

大体意思: 一个monitorenter和一个或多个monitorexit指令来实现Java语言的同步代码块 monitorenter和monitorexit指令没有被用在同步方法上

现在又多出两个疑问:

  1. 为什么一个monitorenter和一个或多个monitorexit
  2. 为什么monitorenter和monitorexit指令没有被用在同步方法上

答案来了:

monitorenter过程如下:

如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1(典型的重入锁逻辑). 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权

monitorexit的执行线程必须是monitor的所有者。

指令执行时,monitor的进入数减1 如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者 其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权

也就是说monitorexit对应释放锁的过程,也就必须在代码块所有可能的出口执行,从上图反编译图中看出一个monitorenter对应了两个monitorexit,此处就是因为代码块有两处结束的可能一是执行完,而是抛出异常。

具体实现Hotspot源码如下,InterpreterRuntime:: monitorenter方法:

IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))
#ifdef ASSERT
  thread->last_frame().interpreter_frame_verify_monitor(elem);
#endif
  if (PrintBiasedLockingStatistics) {
    Atomic::inc(BiasedLocking::slow_path_entry_count_addr());
  }
  Handle h_obj(thread, elem->obj());
  assert(Universe::heap()->is_in_reserved_or_null(h_obj()),
         "must be NULL or an object");
  if (UseBiasedLocking) {
    // Retry fast entry if bias is revoked to avoid unnecessary inflation
    ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK);
  } else {
    ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK);
  }
  assert(Universe::heap()->is_in_reserved_or_null(elem->obj()),
         "must be NULL or an object");
#ifdef ASSERT
  thread->last_frame().interpreter_frame_verify_monitor(elem);
#endif
IRT_END

第二个问题:首先看下方法代码

public class SynLock {
    private synchronized void testSynMethod() {
        System.out.println("steven");
    }
}

用命令行执行编译和反编译很是繁琐,所以这次改用IDEA和Jclasslib插件来分析:

从图中可以看出方法内字节码没有monitor相关操作

但是在方法的access _Flags上可以看到加上了synchronized标签,此时把方法修改成静态方法,可以看到 flags上又多出了static标签,学过字节码应该都知道此处含义,JVM就是通过这个标签来识别方法属性,当JVM执行引擎执行某一个方法时,其会从方法区中获取该方法的access_flags,检查其是否有ACC_SYNCRHONIZED标识符,若是有该标识符,则说明当前方法是同步方法,需要先获取当前对象的monitor,再来执行方法,

如果是实例方法获取的对象就是this,如果是静态方法获取的对象就是class。 常见flags如下图所示:

锁升级

由于synchronized性能问题在JDK1.6前饱受诟病,同时和@author Doug Lea大神写的目前在JUC下的AQS实现的锁差距太大,synchronized开发人员感觉脸上挂不住,所以在1.6版本进行了大幅改造升级,于是就出现了现在常通说的锁升级或锁膨胀的概念,整体思路就是能不打扰操作系统大哥就不打扰大哥,能在用户态解决的就不经过内核。

升级过程

无锁(锁对象初始化时)-> 偏向锁(有线程请求锁) -> 轻量级锁(多线程轻度竞争)-> 重量级锁(线程过多或长耗时操作,线程自旋过度消耗cpu);

对象头

验证之前需要补充一点知识,锁的状态是保存在哪?

通过上面分析所有同步监视器都是监视的对应,锁的状态就在对象markword上,它是java对象数据结构中的一部分,对象的markword和java各种类型的锁密切相关;

markword数据的长度在32位和64位的虚拟机(未开启压缩指针.jvm配置参数:UseCompressedOops,compressed--压缩、oop--对象指针)中分别为32bit和64bit,它的最后2bit是锁状态标志位,用来标记当前对象的状态,对象的所处的状态,决定了markword存储的内容,如下表所示:

对象头包含两个word,mark word为第一个word根据文档可以知他里面包含了锁的信息,hashcode,gc信息等等,klass word为对象头的第二个word主要指向对象的元数据。 64位虚拟机锁对象状态:

简单来说:

状态

标志位

存储内容

未锁定

01

对象哈希码、对象分代年龄

轻量级锁定

00

指向锁记录的指针

重量级锁定

10

执行重量级锁定的指针

GC标记

11

空(不需要记录信息)

偏向锁

01

偏向线程ID、偏向时间戳、对象分代年龄

为什么选取这个过程呢???

JDK开发人员做了大量统计,得出的结论是虽然开发人员加上synchronized来互斥资源访问,但是真正竞争资源的时间几乎没有或者很短暂,也就是说很多的锁是没有必要的。

synchronizer再Hotspot中的源码为synchronizer.cpp如下所示,可以看到BiasedLock和CAS等锁

revoke_and_rebias

void ObjectSynchronizer::fast_enter(Handle obj, BasicLock* lock, bool attempt_rebias, TRAPS) {
 if (UseBiasedLocking) {
    if (!SafepointSynchronize::is_at_safepoint()) {
      BiasedLocking::Condition cond = BiasedLocking::revoke_and_rebias(obj, attempt_rebias, THREAD);
      if (cond == BiasedLocking::BIAS_REVOKED_AND_REBIASED) {
        return;
      }
    } else {
      assert(!attempt_rebias, "can not rebias toward VM thread");
      BiasedLocking::revoke_at_safepoint(obj);
    }
    assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
 }

 slow_enter (obj, lock, THREAD) ;
}
void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {
  markOop mark = obj->mark();
  assert(!mark->has_bias_pattern(), "should not see bias pattern here");

  if (mark->is_neutral()) {
    // Anticipate successful CAS -- the ST of the displaced mark must
    // be visible <= the ST performed by the CAS.
    lock->set_displaced_header(mark);
    if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark)) {
      TEVENT (slow_enter: release stacklock) ;
      return ;
    }
    // Fall through to inflate() ...
  } else
  if (mark->has_locker() && THREAD->is_lock_owned((address)mark->locker())) {
    assert(lock != mark->locker(), "must not re-lock the same lock");
    assert(lock != (BasicLock*)obj->mark(), "don't relock with same BasicLock");
    lock->set_displaced_header(NULL);
    return;
  }

#if 0
  // The following optimization isn't particularly useful.
  if (mark->has_monitor() && mark->monitor()->is_entered(THREAD)) {
    lock->set_displaced_header (NULL) ;
    return ;
  }
#endif

  // The object header will never be displaced to this lock,
  // so it does not matter what the value is, except that it
  // must be non-zero to avoid looking like a re-entrant lock,
  // and must not look locked either.
  lock->set_displaced_header(markOopDesc::unused_mark());
  ObjectSynchronizer::inflate(THREAD, obj())->enter(THREAD);
}

怎么证明存在升级过程

通过对象头就可以知道锁状态,所以可以通过观察对象头来验证,我知道的有两种方式打印出来,一是通过java agent在对象创建后增加代理用ObjectSizeService.sizeOf,一种是OpenJDK提供的JOL来实现。

JOL(Java object layeout)java对象布局,引入maven坐标:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.10</version>
</dependency>

测试代码:

@Test
public void test_object_layout() {
    Object o = new Object();
    System.out.println(VM.current().details());
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

简单分析下打印内容: 整个对象一共16B,其中对象头(Object header)12B,还有4B是对齐的字节(因为在64位虚拟机上对象的大小必 须是8的倍数),由于这个对象里面没有任何字段,故而对象的实例数据为0B。

在这里插入图片描述

ObjectHeader的12B是什么

这个12B当中分别存储的是什么呢?(不同位数的VM对象头的长度不一 样,我本地的是64bit的vm),openJdk文档中有解释:

mark word为第一个word根据文档可以知他里面包含了锁的信息,hashcode,gc信息等等,klass word为对象头的第二个word主要指向对象的元数据,,根据上述利用JOL打印的对象头信息可以知道一个对象头是12Byte,其中8Byte是mark word 那么剩下的4Byte就是klass word了,和锁相关的就是mark word了,那么接下来重点分析mark word里面信息

码验证偏向锁

@Test
public void test_syn_lock() {
    Object o = new Object();
    synchronized (o){
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
    System.out.println("--------------------------------------");
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

从结果markword截图没有偏向锁,直接变成轻量级锁,这是什么原因呢?这是JDK开发人员故意而为之,因为一般启动时会有很多对象分配、jvm,gc等线程竞争没必要立刻开启偏向锁,默认延迟4秒开启。把上述代码当中加上 睡眠5秒的代码,结果就会不一样了

@Test
public void test_syn_lock() {
    TimeUnit.SECONDS.sleep(5L);
    Object o = new Object();
    synchronized (o){
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
    System.out.println("--------------------------------------");
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

jvm默认延时4s自动开启偏向锁(此时为匿名偏向锁,不指向任务线程),可通过-XX:BiasedLockingStartUpDelay=0取消延时;如果不要偏向锁,可通过-XX:-UseBiasedLocking = false来设置。

验证重量级锁:

@Test
public void test_syn_heavy_lock() throws InterruptedException {
    Object o = new Object();
    //模拟多线程竞争
    for (int i = 0; i < 100; i++) {
        new Thread(()->{
            synchronized (o){
                try {
                    TimeUnit.SECONDS.sleep(1L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
    TimeUnit.SECONDS.sleep(5L);
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
    TimeUnit.SECONDS.sleep(100L);
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

结果可以看出:所有线程结束后已经不存在竞争时并不会变为无锁状态,也就是说锁只能升级,不能降级,竞争比较严重时升级为重量级锁,偏向锁和轻量级锁在用户态维护不需要经过内核态,重量级锁需要切换到内核态(os)进行维护,这也是为什么JDK1.6后synchronized性能大幅提升的本质。

轻量级锁

在锁升级过程中有一个轻量级锁,轻量级锁一般指的就是自旋锁CAS(Compare And Exchange),对java开发者来说这种锁也可以看成无锁,因为在java代码层面没有锁的代码。

CAS因为经常配合循环操作,直到完成为止,所以泛指一类操作,cas(v, a, b) ,变量v,期待值a, 修改值b,可能出现ABA问题,解决办法(版本号 AtomicStampedReference),基础类型简单值不需要版本号。

JDK1.6后大量引入CAS操作,比如原子操作类AtomicXXX, synchronized全是C ++ 实现无法跟踪,所以以AtomicInteger举例CAS,AtomicInteger调用incrementAndGet方法会调用unsafe类compareAndSwapInt方法,再往里跟代码发现无法进入,以为此时应是native方法已经是C++实现了,可以在oracle官网下载Hotspot代码分析,大体思路就是用linux_x86汇编语言的lock和cmpxchg指令, 这几个指令都是在用户态实现,不会经过内核态的切换,所以效率比较高,所以称之为轻量级锁。

下面为CAS典型的JUC包下的AtomicIntegerr核心代码部分,这块代码比synchronized要清晰一点,有C++基础的可以研究下

java:AtomicInteger:

public final int incrementAndGet() {
        for (;;) {// 自旋
            int current = get();
            int next = current + 1;
            if (compareAndSet(current, next))
                return next;
        }
    }

public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

Java :Unsafe:

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

jdk8u: unsafe.cpp: cmpxchg = compare and exchange

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

jdk8u: atomic_linux_x86.inline.hpp 93行

is_MP = Multi Processors 多个CPU时处理:

inline jint     Atomic::cmpxchg    (jint     exchange_value, volatile jint*     dest, jint     compare_value) {
  int mp = os::is_MP();
  __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
                    : "=a" (exchange_value)
                    : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
                    : "cc", "memory");
  return exchange_value;
}

底层是通过指令cmpxchgl来实现,如果程序是多核环境下,还会先在cmpxchgl前生成lock指令前缀,反之如果是在单核环境下就不需要生成lock指令前缀。为什么多核要生成lock指令前缀?因为CAS是一个原子操作,原子操作隐射到计算机汇编级别的实现,多核CPU的时候,如果这个操作给到了多个CPU,就破坏了原子性,所以多核环境肯定得先加一个lock指令,不管这个它是以总线锁还是以缓存锁来实现的,单核就不存在这样的问题了。JVM中除了CAS还有八种原子指令,有兴趣的可以自行学习。


jdk8u: os.hpp is_MP()

  static inline bool is_MP() {//判断是否是多核
    // During bootstrap if _processor_count is not yet initialized
    // we claim to be MP as that is safest. If any platform has a
    // stub generator that might be triggered in this phase and for
    // which being declared MP when in fact not, is a problem - then
    // the bootstrap routine for the stub generator needs to check
    // the processor count directly and leave the bootstrap routine
    // in place until called after initialization has ocurred.
    return (_processor_count != 1) || AssumeMP;
  }

jdk8u: atomic_linux_x86.inline.hpp

#define LOCK_IF_MP(mp) "cmp $0, " #mp "; je 1f; lock; 1: "

最后:cmpxchg = cas修改变量值 可以通过C++代码发现,CAS最终是以lock cmpxchg指令来实现的,这两个指令都是汇编指令,对我们java应用开发人员来说可以理解为硬件级别的代码。


使用场景

synchronized 相比如AQS锁使用更简洁不需要显示的获取锁、释放锁,同时又有偏向锁、自旋锁等高性能方式,所以在可能存在资源竞争但是可能性很小或者竞争等待很短时使用synchronized 更好。

最后留下几个问题思考

简述锁升级过程?

自旋锁什么时候升级为重量级锁?

为什么有自旋锁还需要重量级锁?

偏向锁是否一定比自旋锁效率高?

本文分享自微信公众号 - 你呀不牛(notNiu),作者:stevenniu

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-08-17

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 浅谈synchronized与Object.wait/notify原理

    synchronized是Java中常用的锁机制,synchronized+Object.wait/notify是常用的等待唤醒机制,那它们的实现原理是什么呢?...

    luoxn28
  • Java synchronized之类锁/对象锁

    前言synchronized的锁粒度介绍synchronized 的用法举例类锁的场景举例对象锁的场景举例

    用户7886150
  • BATJ等公司必问的8道Java经典面试题,你都会了吗?

    我在 Oracle 已经工作了近 7 年,面试过从初级到非常资深的 Java 工程师,且由于 Java 组工作任务的特点,我非常注重面试者的计算机科学基础和编程...

    Java团长
  • synchronized的锁升级、锁膨胀

    即第一个拿到锁的线程,锁会在对象头 Mark Word 中通过 CAS 记录该线程 ID,该线程以后每次拿锁时都不需要进行 CAS(指轻量级锁)。

    java乐园
  • 【JAVA 进阶之锁机制】synchronized 的锁升级-持续更新....

    1、synchronized 的基本认识 场景:Synchronized是一个同步关键字,在某些多线程场景下,如果不进行同步会导致数据不安全,而Synchro...

    用户5640963
  • 跳槽时,这些Java面试题99%会被问到

    工作多年以及在面试中,我经常能体会到,有些面试者确实是认真努力工作,但坦白说表现出的能力水平却不足以通过面试,通常是两方面原因:

    java思维导图
  • java杂谈之各种锁

    悲观锁和乐观锁并不是某个具体的锁而是一种并发编程的基本概念。乐观锁和悲观锁最早出现在数据库的设计当中,后来逐渐被 Java 的并发包所引入。

    你呀不牛
  • synchronized 锁的升级过程

    付威
  • 【纯干货】Java 并发进阶常见面试题总结

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

    乔戈里
  • 【纯干货】Java 并发进阶常见面试题总结

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

    Java技术江湖
  • 想追女神?先学 Synchronized 吧

    Synchronized 用的锁是存在对象头里的,用来表明当前对象所持有的锁。在 Java SE1.6 之前,Synchronized 是作为重量锁出现的,一旦...

    出其东门
  • 2019秋招:460道Java后端面试高频题答案版【模块三:Java并发】

    Java 并发是 Java 后端开发面试中最重要的模块之一,毕竟这是对 Java 基础的深度考核。而且现在基本上程序都是需要使用多线程进行处理的,如果说...

    乔戈里
  • 最全的BAT大型互联网公司面试题整理

    最近有很多网友都在求大厂面试题。正好我之前电脑里面有这方面的整理,于是就发上来分享给大家。

    美的让人心动
  • Java大厂多线程面试题详解

    JavaEdge
  • BAT美团滴滴java面试大纲(带答案版)之三:多线程synchronized

    继续面试大纲系列文章。   从这一篇开始,我们进入ava编程中的一个重要领域---多线程!多线程就像武学中对的吸星大法,理解透了用好了可以得道成仙,俯瞰芸芸众生...

    小端
  • 面试官所认为的单例模式

    单例模式是23种GOF模式中最简单,也是最经常出现的一种设计模式,也是面试官最常爱考的一种模式,为什么呢? 因为单例模式足够简单,编写一个单例模式代码几分钟就能...

    用户5397975
  • 最全的BAT大厂面试题整理

    版权声明:本文为博主原创文章,未经博主允许不得转载。https://www.jianshu.com/p/c70989bd5f29

    AWeiLoveAndroid
  • 屎上最全的BAT面试,吐血整理,网友回复:那是真的厉害

    临近年关,又到了面试求职高峰期,最近有很多网友都在求大厂面试题。正好我之前电脑里面有这方面的整理,于是就发上来分享给大家。

    java爱好者
  • 大厂面试系列(三):并发编程

    zhaozhen

扫码关注云+社区

领取腾讯云代金券