面试必考体系庞大的Handler你真的都了解吗?Handler二十七问带你打破砂锅问到底!

当子线程网络操作之后,需要切换到主线程进行UI更新。

总之一句话,Hanlder的存在就是为了解决在子线程中无法访问UI的问题。

为什么建议子线程不访问(更新)UI?

因为Android中的UI控件不是线程安全的,如果多线程访问UI控件那还不乱套了。

那为什么不加锁呢?

  • 会降低UI访问的效率。本身UI控件就是离用户比较近的一个组件,加锁之后自然会发生阻塞,那么UI访问的效率会降低,最终反应到用户端就是这个手机有点卡。

  • 太复杂了。本身UI访问时一个比较简单的操作逻辑,直接创建UI,修改UI即可。如果加锁之后就让这个UI访问的逻辑变得很复杂,没必要。

所以,Android设计出了 单线程模型 来处理UI操作,再搭配上Handler,是一个比较合适的解决方案。

子线程访问UI的 崩溃原因 和 解决办法?

崩溃发生在ViewRootImpl类的checkThread方法中:

void checkThread() {

if (mThread != Thread.currentThread()) {

throw new CalledFromWrongThreadException(

“Only the original thread that created a view hierarchy can touch its views.”);

}

}

其实就是判断了当前线程 是否是 ViewRootImpl创建时候的线程,如果不是,就会崩溃。

而ViewRootImpl创建的时机就是界面被绘制的时候,也就是onResume之后,所以如果在子线程进行UI更新,就会发现当前线程(子线程)和View创建的线程(主线程)不是同一个线程,发生崩溃。

解决办法有三种:

  • 在新建视图的线程进行这个视图的UI更新,主线程创建View,主线程更新View。

  • ViewRootImpl创建之前进行子线程的UI更新,比如onCreate方法中进行子线程更新UI。

  • 子线程切换到主线程进行UI更新,比如Handler、view.post方法。

MessageQueue是干嘛呢?用的什么数据结构来存储数据?

看名字应该是个队列结构,队列的特点是什么?先进先出,一般在队尾增加数据,在队首进行取数据或者删除数据。

Hanlder中的消息似乎也满足这样的特点,先发的消息肯定就会先被处理。但是,Handler中还有比较特殊的情况,比如延时消息。

延时消息的存在就让这个队列有些特殊性了,并不能完全保证先进先出,而是需要根据时间来判断,所以Android中采用了链表的形式来实现这个队列,也方便了数据的插入。

来一起看看消息的发送过程,无论是哪种方法发送消息,都会走到sendMessageDelayed方法

public final boolean sendMessageDelayed(@NonNull Message msg, long delayMillis) {

if (delayMillis < 0) {

delayMillis = 0;

}

return sendMessageAtTime(msg, SystemClock.uptimeMillis() + delayMillis);

}

public boolean sendMessageAtTime(@NonNull Message msg, long uptimeMillis) {

MessageQueue queue = mQueue;

return enqueueMessage(queue, msg, uptimeMillis);

}

sendMessageDelayed方法主要计算了消息需要被处理的时间,如果delayMillis为0,那么消息的处理时间就是当前时间。

然后就是关键方法enqueueMessage

boolean enqueueMessage(Message msg, long when) {

synchronized (this) {

msg.markInUse();

msg.when = when;

Message p = mMessages;

boolean needWake;

if (p == null || when == 0 || when < p.when) {

msg.next = p;

mMessages = msg;

needWake = mBlocked;

} else {

needWake = mBlocked && p.target == null && msg.isAsynchronous();

Message prev;

for (;😉 {

prev = p;

p = p.next;

if (p == null || when < p.when) {

break;

}

if (needWake && p.isAsynchronous()) {

needWake = false;

}

}

msg.next = p;

prev.next = msg;

}

if (needWake) {

nativeWake(mPtr);

}

}

return true;

}

不懂得地方先不看,只看我们想看的:

  • 首先设置了Message的when字段,也就是代表了这个消息的处理时间

  • 然后判断当前队列是不是为空,是不是即时消息,是不是执行时间when大于表头的消息时间,满足任意一个,就把当前消息msg插入到表头。

  • 否则,就需要遍历这个队列,也就是链表,找出when小于某个节点的when,找到后插入。

好了,其他内容暂且不看,总之,插入消息就是通过消息的执行时间,也就是when字段,来找到合适的位置插入链表。

具体方法就是通过死循环,使用快慢指针p和prev,每次向后移动一格,直到找到某个节点p的when大于我们要插入消息的when字段,则插入到p和prev之间。或者遍历到链表结束,插入到链表结尾。

所以,MessageQueue就是一个用于存储消息、用链表实现的特殊队列结构。

延迟消息是怎么实现的?

总结上述内容,延迟消息的实现主要跟消息的统一存储方法有关,也就是上文说过的enqueueMessage方法。

无论是即时消息还是延迟消息,都是计算出具体的时间,然后作为消息的when字段进程赋值。

然后在MessageQueue中找到合适的位置(安排when小到大排列),并将消息插入到MessageQueue中。

这样,MessageQueue就是一个按照消息时间排列的一个链表结构。

MessageQueue的消息怎么被取出来的?

刚才说过了消息的存储,接下来看看消息的取出,也就是queue.next方法。

Message next() {

for (;😉 {

if (nextPollTimeoutMillis != 0) {

Binder.flushPendingCommands();

}

nativePollOnce(ptr, nextPollTimeoutMillis);

synchronized (this) {

// Try to retrieve the next message. Return if found.

final long now = SystemClock.uptimeMillis();

Message prevMsg = null;

Message msg = mMessages;

if (msg != null && msg.target == null) {

do {

prevMsg = msg;

msg = msg.next;

} while (msg != null && !msg.isAsynchronous());

}

if (msg != null) {

if (now < msg.when) {

nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);

} else {

// Got a message.

mBlocked = false;

if (prevMsg != null) {

prevMsg.next = msg.next;

} else {

mMessages = msg.next;

}

msg.next = null;

msg.markInUse();

return msg;

}

} else {

// No more messages.

nextPollTimeoutMillis = -1;

}

}

}

}

奇怪,为什么取消息也是用的死循环呢?

其实死循环就是为了保证一定要返回一条消息,如果没有可用消息,那么就阻塞在这里,一直到有新消息的到来。

其中,nativePollOnce方法就是阻塞方法,nextPollTimeoutMillis参数就是阻塞的时间。

那什么时候会阻塞呢?两种情况:

  • 1、有消息,但是当前时间小于消息执行时间,也就是代码中的这一句:

if (now < msg.when) {

nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);

}

这时候阻塞时间就是消息时间减去当前时间,然后进入下一次循环,阻塞。

  • 2、没有消息的时候,也就是上述代码的最后一句:

if (msg != null) {}

else {

// No more messages.

nextPollTimeoutMillis = -1;

}

-1就代表一直阻塞。

MessageQueue没有消息时候会怎样?阻塞之后怎么唤醒呢?说说pipe/epoll机制?

接着上文的逻辑,当消息不可用或者没有消息的时候就会阻塞在next方法,而阻塞的办法是通过pipe/epoll机制

epoll机制是一种IO多路复用的机制,具体逻辑就是一个进程可以监视多个描述符,当某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作,这个读写操作是阻塞的。在Android中,会创建一个Linux管道(Pipe)来处理阻塞和唤醒。

  • 当消息队列为空,管道的读端等待管道中有新内容可读,就会通过epoll机制进入阻塞状态。

  • 当有消息要处理,就会通过管道的写端写入内容,唤醒主线程。

同步屏障和异步消息是怎么实现的?

其实在Handler机制中,有三种消息类型:

  • 同步消息。也就是普通的消息。

  • 异步消息。通过setAsynchronous(true)设置的消息。

  • 同步屏障消息。通过postSyncBarrier方法添加的消息,特点是target为空,也就是没有对应的handler。

这三者之间的关系如何呢?

  • 正常情况下,同步消息和异步消息都是正常被处理,也就是根据时间when来取消息,处理消息。

  • 当遇到同步屏障消息的时候,就开始从消息队列里面去找异步消息,找到了再根据时间决定阻塞还是返回消息。

也就是说同步屏障消息不会被返回,他只是一个标志,一个工具,遇到它就代表要去先行处理异步消息了。

所以同步屏障和异步消息的存在的意义就在于有些消息需要“加急处理”

同步屏障和异步消息有具体的使用场景吗?

使用场景就很多了,比如绘制方法scheduleTraversals

void scheduleTraversals() {

if (!mTraversalScheduled) {

mTraversalScheduled = true;

// 同步屏障,阻塞所有的同步消息

mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();

// 通过 Choreographer 发送绘制任务

mChoreographer.postCallback(

Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);

}

}

Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_CALLBACK, action);

msg.arg1 = callbackType;

msg.setAsynchronous(true);

mHandler.sendMessageAtTime(msg, dueTime);

在该方法中加入了同步屏障,后续加入一个异步消息MSG_DO_SCHEDULE_CALLBACK,最后会执行到FrameDisplayEventReceiver,用于申请VSYNC信号。

Message消息被分发之后会怎么处理?消息怎么复用的?

再看看loop方法,在消息被分发之后,也就是执行了dispatchMessage方法之后,还偷偷做了一个操作——recycleUnchecked

public static void loop() {

for (;😉 {

Message msg = queue.next(); // might block

try {

msg.target.dispatchMessage(msg);

}

msg.recycleUnchecked();

}

}

//Message.java

private static Message sPool;

private static final int MAX_POOL_SIZE = 50;

void recycleUnchecked() {

flags = FLAG_IN_USE;

what = 0;

arg1 = 0;

arg2 = 0;

obj = null;

replyTo = null;

sendingUid = UID_NONE;

workSourceUid = UID_NONE;

when = 0;

target = null;

callback = null;

data = null;

synchronized (sPoolSync) {

if (sPoolSize < MAX_POOL_SIZE) {

next = sPool;

sPool = this;

sPoolSize++;

}

}

}

recycleUnchecked方法中,释放了所有资源,然后将当前的空消息插入到sPool表头。

这里的sPool就是一个消息对象池,它也是一个链表结构的消息,最大长度为50。

那么Message又是怎么复用的呢?在Message的实例化方法obtain中:

public static Message obtain() {

synchronized (sPoolSync) {

if (sPool != null) {

Message m = sPool;

sPool = m.next;

m.next = null;

m.flags = 0; // clear in-use flag

sPoolSize–;

return m;

}

}

return new Message();

}

直接复用消息池sPool中的第一条消息,然后sPool指向下一个节点,消息池数量减一。

Looper是干嘛呢?怎么获取当前线程的Looper?为什么不直接用Map存储线程和对象呢?

在Handler发送消息之后,消息就被存储到MessageQueue中,而Looper就是一个管理消息队列的角色。Looper会从MessageQueue中不断的查找消息,也就是loop方法,并将消息交回给Handler进行处理。

而Looper的获取就是通过ThreadLocal机制:

static final ThreadLocal sThreadLocal = new ThreadLocal();

private static void prepare(boolean quitAllowed) {

if (sThreadLocal.get() != null) {

throw new RuntimeException(“Only one Looper may be created per thread”);

}

sThreadLocal.set(new Looper(quitAllowed));

}

public static @Nullable Looper myLooper() {

return sThreadLocal.get();

}

通过prepare方法创建Looper并且加入到sThreadLocal中,通过myLooper方法从sThreadLocal中获取Looper。

ThreadLocal运行机制?这种机制设计的好处?

下面就具体说说ThreadLocal运行机制。

//ThreadLocal.java

public T get() {

Thread t = Thread.currentThread();

ThreadLocalMap map = getMap(t);

if (map != null) {

ThreadLocalMap.Entry e = map.getEntry(this);

if (e != null) {

@SuppressWarnings(“unchecked”)

T result = (T)e.value;

return result;

}

}

return setInitialValue();

}

public void set(T value) {

Thread t = Thread.currentThread();

ThreadLocalMap map = getMap(t);

if (map != null)

map.set(this, value);

else

createMap(t, value);

}

ThreadLocal类中的get和set方法可以大致看出来,有一个ThreadLocalMap变量,这个变量存储着键值对形式的数据。

  • key为this,也就是当前ThreadLocal变量。

  • value为T,也就是要存储的值。

然后继续看看ThreadLocalMap哪来的,也就是getMap方法:

//ThreadLocal.java

ThreadLocalMap getMap(Thread t) {

return t.threadLocals;

}

//Thread.java

ThreadLocal.ThreadLocalMap threadLocals = null;

原来这个ThreadLocalMap变量是存储在线程类Thread中的。

所以ThreadLocal的基本机制就搞清楚了:

在每个线程中都有一个threadLocals变量,这个变量存储着ThreadLocal和对应的需要保存的对象。

这样带来的好处就是,在不同的线程,访问同一个ThreadLocal对象,但是能获取到的值却不一样。

挺神奇的是不是,其实就是其内部获取到的Map不同,Map和Thread绑定,所以虽然访问的是同一个ThreadLocal对象,但是访问的Map却不是同一个,所以取得值也不一样。

这样做有什么好处呢?为什么不直接用Map存储线程和对象呢?

打个比方:

  • ThreadLocal就是老师。

  • Thread就是同学。

  • Looper(需要的值)就是铅笔。

现在老师买了一批铅笔,然后想把这些铅笔发给同学们,怎么发呢?两种办法:

1、老师把每个铅笔上写好每个同学的名字,放到一个大盒子里面去(map),用的时候就让同学们自己来找。

这种做法就是Map里面存储的是同学和铅笔,然后用的时候通过同学来从这个Map里找铅笔。

这种做法就有点像使用一个Map,存储所有的线程和对象,不好的地方就在于会很混乱,每个线程之间有了联系,也容易造成内存泄漏。

2、老师把每个铅笔直接发给每个同学,放到同学的口袋里(map),用的时候每个同学从口袋里面拿出铅笔就可以了。

这种做法就是Map里面存储的是老师和铅笔,然后用的时候老师说一声,同学只需要从口袋里拿出来就行了。

很明显这种做法更科学,这也就是ThreadLocal的做法,因为铅笔本身就是同学自己在用,所以一开始就把铅笔交给同学自己保管是最好的,每个同学之间进行隔离。

还有哪些地方运用到了ThreadLocal机制?

比如:Choreographer。

public final class Choreographer {

// Thread local storage for the choreographer.

private static final ThreadLocal sThreadInstance =

new ThreadLocal() {

@Override

protected Choreographer initialValue() {

Looper looper = Looper.myLooper();

if (looper == null) {

throw new IllegalStateException(“The current thread must have a looper!”);

}

Choreographer choreographer = new Choreographer(looper, VSYNC_SOURCE_APP);

if (looper == Looper.getMainLooper()) {

mMainInstance = choreographer;

}

return choreographer;

}

};

private static volatile Choreographer mMainInstance;

Choreographer主要是主线程用的,用于配合 VSYNC中断信号。

所以这里使用ThreadLocal更多的意义在于完成线程单例的功能。

可以多次创建Looper吗?

Looper的创建是通过Looper.prepare方法实现的,而在prepare方法中就判断了,当前线程是否存在Looper对象,如果有,就会直接抛出异常:

private static void prepare(boolean quitAllowed) {

if (sThreadLocal.get() != null) {

throw new RuntimeException(“Only one Looper may be created per thread”);

}

sThreadLocal.set(new Looper(quitAllowed));

}

private Looper(boolean quitAllowed) {

mQueue = new MessageQueue(quitAllowed);

mThread = Thread.currentThread();

}

所以同一个线程,只能创建一个Looper,多次创建会报错。

Looper中的quitAllowed字段是啥?有什么用?

按照字面意思就是是否允许退出,我们看看他都在哪些地方用到了:

void quit(boolean safe) {

if (!mQuitAllowed) {

throw new IllegalStateException(“Main thread not allowed to quit.”);

}

synchronized (this) {

if (mQuitting) {

return;

}

mQuitting = true;

if (safe) {

removeAllFutureMessagesLocked();

} else {

removeAllMessagesLocked();

}

}

}

哦,就是这个quit方法用到了,如果这个字段为false,代表不允许退出,就会报错。

但是这个quit方法又是干嘛的呢?从来没用过呢。还有这个safe又是啥呢?

其实看名字就差不多能了解了,quit方法就是退出消息队列,终止消息循环。

  • 首先设置了mQuitting字段为true。

  • 然后判断是否安全退出,如果安全退出,就执行removeAllFutureMessagesLocked方法,它内部的逻辑是清空所有的延迟消息,之前没处理的非延迟消息还是需要取处理,然后设置非延迟消息的下一个节点为空(p.next=null)。

  • 如果不是安全退出,就执行removeAllMessagesLocked方法,直接清空所有的消息,然后设置消息队列指向空(mMessages = null)

然后看看当调用quit方法之后,消息的发送和处理:

//消息发送

boolean enqueueMessage(Message msg, long when) {

synchronized (this) {

if (mQuitting) {

IllegalStateException e = new IllegalStateException(

msg.target + " sending message to a Handler on a dead thread");

Log.w(TAG, e.getMessage(), e);

msg.recycle();

return false;

}

}

当调用了quit方法之后,mQuitting为true,消息就发不出去了,会报错。

再看看消息的处理,loop和next方法:

Message next() {

for (;😉 {

synchronized (this) {

if (mQuitting) {

dispose();

return null;

}

}

}

}

public static void loop() {

for (;😉 {

Message msg = queue.next();

if (msg == null) {

// No message indicates that the message queue is quitting.

return;

}

}

}

很明显,当mQuitting为true的时候,next方法返回null,那么loop方法中就会退出死循环。

那么这个quit方法一般是什么时候使用呢?

  • 主线程中,一般情况下肯定不能退出,因为退出后主线程就停止了。所以是当APP需要退出的时候,就会调用quit方法,涉及到的消息是EXIT_APPLICATION,大家可以搜索下。

  • 子线程中,如果消息都处理完了,就需要调用quit方法停止消息循环。

Looper.loop方法是死循环,为什么不会卡死(ANR)?

关于这个问题,强烈建议看看Gityuan的回答:https://www.zhihu.com/question/34652589

我大致总结下:

  • 主线程本身就是需要一只运行的,因为要处理各个View,界面变化。所以需要这个死循环来保证主线程一直执行下去,不会被退出。

  • 真正会卡死的操作是在某个消息处理的时候操作时间过长,导致掉帧、ANR,而不是loop方法本身。

  • 在主线程以外,会有其他的线程来处理接受其他进程的事件,比如Binder线程(ApplicationThread),会接受AMS发送来的事件

  • 在收到跨进程消息后,会交给主线程的Hanlder再进行消息分发。所以Activity的生命周期都是依靠主线程的Looper.loop,当收到不同Message时则采用相应措施,比如收到msg=H.LAUNCH_ACTIVITY,则调用ActivityThread.handleLaunchActivity()方法,最终执行到onCreate方法。

  • 当没有消息的时候,会阻塞在loop的queue.next()中的nativePollOnce()方法里,此时主线程会释放CPU资源进入休眠状态,直到下个消息到达或者有事务发生。所以死循环也不会特别消耗CPU资源。

Message是怎么找到它所属的Handler然后进行分发的?

在loop方法中,找到要处理的Message,然后调用了这么一句代码处理消息:

msg.target.dispatchMessage(msg);

所以是将消息交给了msg.target来处理,那么这个target是啥呢?

找找它的来头:

//Handler

private boolean enqueueMessage(MessageQueue queue,Message msg,long uptimeMillis) {

msg.target = this;

return queue.enqueueMessage(msg, uptimeMillis);

}

在使用Hanlder发送消息的时候,会设置msg.target = this,所以target就是当初把消息加到消息队列的那个Handler。

Handler 的 post(Runnable) 与 sendMessage 有什么区别

Hanlder中主要的发送消息可以分为两种:

  • post(Runnable)

  • sendMessage

public final boolean post(@NonNull Runnable r) {

return sendMessageDelayed(getPostMessage®, 0);

}

private static Message getPostMessage(Runnable r) {

Message m = Message.obtain();

m.callback = r;

return m;

}

通过post的源码可知,其实post和sendMessage的区别就在于:

post方法给Message设置了一个callback

那么这个callback有什么用呢?我们再转到消息处理的方法dispatchMessage中看看:

public void dispatchMessage(@NonNull Message msg) {

if (msg.callback != null) {

handleCallback(msg);

} else {

if (mCallback != null) {

if (mCallback.handleMessage(msg)) {

return;

}

}

handleMessage(msg);

}

}

private static void handleCallback(Message message) {

message.callback.run();

}

这段代码可以分为三部分看:

  • 如果msg.callback不为空,也就是通过post方法发送消息的时候,会把消息交给这个msg.callback进行处理,然后就没有后续了。

  • 如果msg.callback为空,也就是通过sendMessage发送消息的时候,会判断Handler当前的mCallback是否为空,如果不为空就交给Handler.Callback.handleMessage处理。

  • 如果mCallback.handleMessage返回true,则无后续了。

  • 如果mCallback.handleMessage返回false,则调用handler类重写的handleMessage方法。

所以post(Runnable) 与 sendMessage的区别就在于后续消息的处理方式,是交给msg.callback还是 Handler.Callback或者Handler.handleMessage

Handler.Callback.handleMessage 和 Handler.handleMessage 有什么不一样?为什么这么设计?

接着上面的代码说,这两个处理方法的区别在于Handler.Callback.handleMessage方法是否返回true:

  • 如果为true,则不再执行Handler.handleMessage

  • 如果为false,则两个方法都要执行。

那么什么时候有Callback,什么时候没有呢?这涉及到两种Hanlder的 创建方式:

最后

希望本文对你有所启发,有任何面试上的建议也欢迎留言分享给大家。

好了,今天的分享就到这里,如果你对在面试中遇到的问题,或者刚毕业及工作几年迷茫不知道该如何准备面试并突破现状提升自己,对于自己的未来还不够了解不知道给如何规划,来看看同行们都是如何突破现状,怎么学习的,来吸收他们的面试以及工作经验完善自己的之后的面试计划及职业规划。

好了~如果你看到了这里,觉得文章写得不错就给个赞呗?如果你觉得那里值得改进的,请给我留言。一定会认真查询,修正不足。谢谢。

为什么某些人会一直比你优秀,是因为他本身就很优秀还一直在持续努力变得更优秀,而你是不是还在满足于现状内心在窃喜!希望读到这的您能点个小赞和关注下我,以后还会更新技术干货,谢谢您的支持!

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 如果msg.callback为空,也就是通过sendMessage发送消息的时候,会判断Handler当前的mCallback是否为空,如果不为空就交给Handler.Callback.handleMessage处理。

  • 如果mCallback.handleMessage返回true,则无后续了。

  • 如果mCallback.handleMessage返回false,则调用handler类重写的handleMessage方法。

所以post(Runnable) 与 sendMessage的区别就在于后续消息的处理方式,是交给msg.callback还是 Handler.Callback或者Handler.handleMessage

Handler.Callback.handleMessage 和 Handler.handleMessage 有什么不一样?为什么这么设计?

接着上面的代码说,这两个处理方法的区别在于Handler.Callback.handleMessage方法是否返回true:

  • 如果为true,则不再执行Handler.handleMessage

  • 如果为false,则两个方法都要执行。

那么什么时候有Callback,什么时候没有呢?这涉及到两种Hanlder的 创建方式:

最后

希望本文对你有所启发,有任何面试上的建议也欢迎留言分享给大家。

好了,今天的分享就到这里,如果你对在面试中遇到的问题,或者刚毕业及工作几年迷茫不知道该如何准备面试并突破现状提升自己,对于自己的未来还不够了解不知道给如何规划,来看看同行们都是如何突破现状,怎么学习的,来吸收他们的面试以及工作经验完善自己的之后的面试计划及职业规划。

[外链图片转存中…(img-qKcywzy1-1714725393955)]

好了~如果你看到了这里,觉得文章写得不错就给个赞呗?如果你觉得那里值得改进的,请给我留言。一定会认真查询,修正不足。谢谢。

[外链图片转存中…(img-g4rICll2-1714725393956)]

为什么某些人会一直比你优秀,是因为他本身就很优秀还一直在持续努力变得更优秀,而你是不是还在满足于现状内心在窃喜!希望读到这的您能点个小赞和关注下我,以后还会更新技术干货,谢谢您的支持!

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 11
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值