无锁编程(Lock Free)框架 系列文章:

  • 1 前置知识:伪共享 原理 & 实战
  • 2 disruptor 使用和原理 图解
  • 3 akka 使用和原理 图解
  • 4 camel 使用和 原理 图解

1 disruptor 是什么?

Disruptor 是英国外汇交易公司 LMAX 开发的一个高性能队列,研发的初衷是解决内存队列的延迟问题(在性能测试中发现竟然与 I/O 操作处于同样的数量级)。

基于 Disruptor 开发的系统单线程能支撑每秒 600 万订单,2010 年在 QCon 演讲后,获得了业界关注。2011 年,企业应用软件专家 Martin Fowler 专门撰写长文介绍。同年它还获得了 Oracle 官方的 Duke 大奖。

目前,包括 Apache Storm、Camel、Log4j 2 在内的很多知名项目都应用了 Disruptor 以获取高性能。

需要特别指出的是,这里所说的队列是系统内部的内存队列,而不是 Kafka 这样的分布式队列。另外,本文所描述的 Disruptor 特性限于 3.3.4。

2 Java 内置队列的问题

介绍 Disruptor 之前,我们先来看一看常用的线程安全的内置队列有什么问题。Java 的内置队列如下表所示。

队列 有界性 数据结构
ArrayBlockingQueue bounded 加锁 arraylist
LinkedBlockingQueue optionally-bounded 加锁 linkedlist
ConcurrentLinkedQueue unbounded 无锁 linkedlist
LinkedTransferQueue unbounded 无锁 linkedlist
PriorityBlockingQueue unbounded 加锁 heap
DelayQueue unbounded 加锁 heap

队列的底层一般分成三种:数组、链表和堆。其中,堆一般情况下是为了实现带有优先级特性的队列,暂且不考虑。

从数组和链表两种数据结构来看,基于数组线程安全的队列,比较典型的是 ArrayBlockingQueue,它主要通过加锁的方式来保证线程安全;基于链表的线程安全队列分成 LinkedBlockingQueue 和 ConcurrentLinkedQueue 两大类,前者也通过锁的方式来实现线程安全,而后者以及上面表格中的 LinkedTransferQueue 都是通过原子变量 compare and swap(以下简称 “CAS”)这种不加锁的方式来实现的。

但是对 volatile 类型的变量进行 CAS 操作,存在伪共享问题,具体请参考专门的文章:

伪共享 (图解)

Disruptor 使用了类似上面的方案,解决了伪共享问题。

3 Disruptor 框架是如何解决伪共享问题的?

在 Disruptor 中有一个重要的类 Sequence,该类包装了一个 volatile 修饰的 long 类型数据 value,无论是 Disruptor 中的基于数组实现的缓冲区 RingBuffer,还是生产者,消费者,都有各自独立的 Sequence,RingBuffer 缓冲区中,Sequence 标示着写入进度,例如每次生产者要写入数据进缓冲区时,都要调用 RingBuffer.next()来获得下一个可使用的相对位置。对于生产者和消费者来说,Sequence 标示着它们的事件序号,来看看 Sequence 类的源码:

  class LhsPadding {protected long p1, p2, p3, p4, p5, p6, p7;
}class Value extends LhsPadding {protected volatile long value;
}class RhsPadding extends Value {protected long p9, p10, p11, p12, p13, p14, p15;
}public class Sequence extends RhsPadding {static final long INITIAL_VALUE = -1L;private static final Unsafe UNSAFE;private static final long VALUE_OFFSET;static {UNSAFE = Util.getUnsafe();try {VALUE_OFFSET = UNSAFE.objectFieldOffset(Value.class.getDeclaredField("value"));} catch(final Exception e) {throw new RuntimeException(e);}}```
public Sequence() {this(INITIAL_VALUE);
}public Sequence(final long initialValue) {UNSAFE.putOrderedLong(this, VALUE_OFFSET, initialValue);
}
```}

从第 1 到 11 行可以看到,真正使用到的变量 value,它的前后空间都由 8 个 long 型的变量填补了,对于一个大小为 64 字节的缓存行,它刚好被填补满(一个 long 型变量 value,8 个字节加上前 / 后个 7long 型变量填补,7*8=56,56+8=64 字节)。这样做每次把变量 value 读进高速缓存中时,都能把缓存行填充满(对于大小为 64 个字节的缓存行来说,如果缓存行大小大于 64 个字节,那么还是会出现伪共享问题),保证每次处理数据时都不会与其他变量发生冲突。

Disruptor 的使用场景

Disruptor 的最常用的场景就是 “生产者 - 消费者” 场景,对场景的就是 “一个生产者、多个消费者” 的场景,并且要求顺序处理。

当前业界开源组件使用 Disruptor 的包括 Log4j2、Apache Storm 等,它可以用来作为高性能的有界内存队列,基于生产者消费者模式,实现一个 / 多个生产者对应多个消费者。它也可以认为是观察者模式的一种实现,或者发布订阅模式。

举个例子,我们从 MySQL 的 BigLog 文件中顺序读取数据,然后写入到 ElasticSearch(搜索引擎)中。在这种场景下,BigLog 要求一个文件一个生产者,那个是一个生产者。而写入到 ElasticSearch,则严格要求顺序,否则会出现问题,所以通常意义上的多消费者线程无法解决该问题,如果通过加锁,则性能大打折扣。

实战:Disruptor 的 使用实例

我们从一个简单的例子开始学习 Disruptor:生产者传递一个 long 类型的值给消费者,而消费者消费这个数据的方式仅仅是把它打印出来。

定义一个 Event

首先定义一个 Event 来包含需要传递的数据:

public class LongEvent { private long value;public long getValue() { return value; } public void setValue(long value) { this.value = value; }
}

由于需要让 Disruptor 为我们创建事件,我们同时还声明了一个 EventFactory 来实例化 Event 对象。

public class LongEventFactory implements EventFactory { @Override public Object newInstance() { return new LongEvent(); }
}

定义事件处理器(disruptor 会回调此处理器的方法)

我们还需要一个事件消费者,也就是一个事件处理器。这个事件处理器简单地把事件中存储的数据打印到终端:

/** */public class LongEventHandler implements EventHandler<LongEvent> { @Override public void onEvent(LongEvent longEvent, long l, boolean b) throws Exception { System.out.println(longEvent.getValue()); }
}

定义事件源:事件发布器 发布事件

事件都会有一个生成事件的源,这个例子中假设事件是由于磁盘 IO 或者 network 读取数据的时候触发的,事件源使用一个 ByteBuffer 来模拟它接受到的数据,也就是说,事件源会在 IO 读取到一部分数据的时候触发事件(触发事件不是自动的,程序员需要在读取到数据的时候自己触发事件并发布):

public class LongEventProducer { private final RingBuffer<LongEvent> ringBuffer;public LongEventProducer(RingBuffer<LongEvent> ringBuffer) { this.ringBuffer = ringBuffer; } /** * onData用来发布事件,每调用一次就发布一次事件事件 * 它的参数会通过事件传递给消费者 * * @param bb */public void onData(ByteBuffer bb) { //可以把ringBuffer看做一个事件队列,那么next就是得到下面一个事件槽long sequence = ringBuffer.next();try { //用上面的索引取出一个空的事件用于填充 LongEvent event = ringBuffer.get(sequence);// for the sequence event.setValue(bb.getLong(0)); } finally { //发布事件 ringBuffer.publish(sequence); } }
}

很明显的是:当用一个简单队列来发布事件的时候会牵涉更多的细节,这是因为事件对象还需要预先创建。

发布事件最少需要两步:

获取下一个事件槽,发布事件(发布事件的时候要使用 try/finnally 保证事件一定会被发布)。

如果我们使用 RingBuffer.next () 获取一个事件槽,那么一定要发布对应的事件。如果不能发布事件,那么就会引起 Disruptor 状态的混乱。尤其是在多个事件生产者的情况下会导致事件消费者失速,从而不得不重启应用才能会恢复。

Disruptor 3.0 提供了 lambda 式的 API。这样可以把一些复杂的操作放在 Ring Buffer,所以在 Disruptor3.0 以后的版本最好使用 Event Publisher 或者 Event Translator (事件转换器) 来发布事件。

Disruptor3.0 以后的事件转换器(填充事件的业务数据)

public class LongEventProducerWithTranslator { //一个translator可以看做一个事件初始化器,publicEvent方法会调用它//填充Eventprivate static final EventTranslatorOneArg<LongEvent, ByteBuffer> TRANSLATOR = new EventTranslatorOneArg<LongEvent, ByteBuffer>() { public void translateTo(LongEvent event, long sequence, ByteBuffer bb) { event.setValue(bb.getLong(0)); } };private final RingBuffer<LongEvent> ringBuffer;public LongEventProducerWithTranslator(RingBuffer<LongEvent> ringBuffer) { this.ringBuffer = ringBuffer; } public void onData(ByteBuffer bb) { ringBuffer.publishEvent(TRANSLATOR, bb); }
}

上面写法的另一个好处是,Translator 可以分离出来并且更加容易单元测试。Disruptor 提供了不同的接口 (EventTranslator, EventTranslatorOneArg, EventTranslatorTwoArg, 等等) 去产生一个 Translator 对象。很明显,Translator 中方法的参数是通过 RingBuffer 来传递的。

组装起来

最后一步就是把所有的代码组合起来完成一个完整的事件处理系统。Disruptor 在这方面做了简化,使用了 DSL 风格的代码(其实就是按照直观的写法,不太能算得上真正的 DSL)。虽然 DSL 的写法比较简单,但是并没有提供所有的选项。如果依靠 DSL 已经可以处理大部分情况了。

注意:这里没有使用时间转换器,而是使用简单的 事件发布器。

public class LongEventMain { public static void main(String[] args) throws InterruptedException { // Executor that will be used to construct new threads for consumers Executor executor = Executors.newCachedThreadPool();// The factory for the event LongEventFactory factory = new LongEventFactory();// Specify the size of the ring buffer, must be power of 2.int bufferSize = 1024;// Construct the Disruptor Disruptor<LongEvent> disruptor = new Disruptor<LongEvent>(factory, bufferSize, executor);// Connect the handler disruptor.handleEventsWith(new LongEventHandler());// Start the Disruptor, starts all threads running disruptor.start();// Get the ring buffer from the Disruptor to be used for publishing. RingBuffer<LongEvent> ringBuffer = disruptor.getRingBuffer(); LongEventProducer producer = new LongEventProducer(ringBuffer); ByteBuffer bb = ByteBuffer.allocate(8);for (long l = 0; true; l++) { bb.putLong(0, l); //发布事件 producer.onData(bb); Thread.sleep(1000); } }
}

在 Java 8 使用 Disruptor

Disruptor 在自己的接口里面添加了对于 Java 8 Lambda 的支持。大部分 Disruptor 中的接口都符合 Functional Interface 的要求(也就是在接口中仅仅有一个方法)。所以在 Disruptor 中,可以广泛使用 Lambda 来代替自定义类。

public class LongEventMainJava8 { /** * 用lambda表达式来注册EventHandler和EventProductor * @param args * @throws InterruptedException */public static void main(String[] args) throws InterruptedException { // Executor that will be used to construct new threads for consumers Executor executor = Executors.newCachedThreadPool();// Specify the size of the ring buffer, must be power of 2.int bufferSize = 1024;// Construct the Disruptor Disruptor<LongEvent> disruptor = new Disruptor<>(LongEvent::new, bufferSize, executor);// 可以使用lambda来注册一个EventHandler disruptor.handleEventsWith((event, sequence, endOfBatch) -> System.out.println("Event: " + event.getValue()));// Start the Disruptor, starts all threads running disruptor.start();// Get the ring buffer from the Disruptor to be used for publishing. RingBuffer<LongEvent> ringBuffer = disruptor.getRingBuffer(); LongEventProducer producer = new LongEventProducer(ringBuffer); ByteBuffer bb = ByteBuffer.allocate(8);for (long l = 0; true; l++) { bb.putLong(0, l); ringBuffer.publishEvent((event, sequence, buffer) -> event.setValue(buffer.getLong(0)), bb); Thread.sleep(1000); } }
}

由于在 Java 8 中方法引用也是一个 lambda,因此还可以把上面的代码改成下面的代码:

public class LongEventWithMethodRef { public static void handleEvent(LongEvent event, long sequence, boolean endOfBatch) { System.out.println(event.getValue()); } public static void translate(LongEvent event, long sequence, ByteBuffer buffer) { event.setValue(buffer.getLong(0)); } public static void main(String[] args) throws Exception { // Executor that will be used to construct new threads for consumers Executor executor = Executors.newCachedThreadPool();// Specify the size of the ring buffer, must be power of 2.int bufferSize = 1024;// Construct the Disruptor Disruptor<LongEvent> disruptor = new Disruptor<>(LongEvent::new, bufferSize, executor);// Connect the handler disruptor.handleEventsWith(LongEventWithMethodRef::handleEvent);// Start the Disruptor, starts all threads running disruptor.start();// Get the ring buffer from the Disruptor to be used for publishing. RingBuffer<LongEvent> ringBuffer = disruptor.getRingBuffer(); LongEventProducer producer = new LongEventProducer(ringBuffer); ByteBuffer bb = ByteBuffer.allocate(8);for (long l = 0; true; l++) { bb.putLong(0, l); ringBuffer.publishEvent(LongEventWithMethodRef::translate, bb); Thread.sleep(1000); } }
}

Disruptor 如何实现高性能?

Disruptor 实现高性能主要体现了去掉了锁,采用 CAS 算法,同时内部通过环形队列实现有界队列。

  • 环形数据结构
    为了避免垃圾回收,采用数组而非链表。同时,数组对处理器的缓存机制更加友好。
  • 元素位置定位
    数组长度 2^n,通过位运算,加快定位的速度。下标采取递增的形式。不用担心 index 溢出的问题。index 是 long 类型,即使 100 万 QPS 的处理速度,也需要 30 万年才能用完。
  • 无锁设计
    每个生产者或者消费者线程,会先申请可以操作的元素在数组中的位置,申请到之后,直接在该位置写入或者读取数据。整个过程通过原子变量 CAS,保证操作的线程安全。

使用 Disruptor,主要用于对性能要求高、延迟低的场景,它通过 “榨干” 机器的性能来换取处理的高性能。如果你的项目有对性能要求高,对延迟要求低的需求,并且需要一个无锁的有界队列,来实现生产者 / 消费者模式,那么 Disruptor 是你的不二选择。

原理:Disruptor 的内部 Ring Buffer 环形队列

RingBuffer 是什么

RingBuffer 是一个环 (首尾相连的环),用做在不同上下文 (线程) 间传递数据的 buffer。
RingBuffer 拥有一个序号,这个序号指向数组中下一个可用元素。

Disruptor 使用环形队列的优势:

Disruptor 框架就是一个使用 CAS 操作的内存队列,与普通的队列不同,Disruptor 框架使用的是一个基于数组实现的环形队列,无论是生产者向缓冲区里提交任务,还是消费者从缓冲区里获取任务执行,都使用 CAS 操作。

使用环形队列的优势:

第一,简化了多线程同步的复杂度。学数据结构的时候,实现队列都要两个指针 head 和 tail 来分别指向队列的头和尾,对于一般的队列是这样,想象下,如果有多个生产者同时往缓冲区队列中提交任务,某一生产者提交新任务后,tail 指针都要做修改的,那么多个生产者提交任务,头指针不会做修改,但会对 tail 指针产生冲突,例如某一生产者 P1 要做写入操作,在获得 tail 指针指向的对象值 V 后,执行 compareAndSet()方法前,tail 指针被另一生产者 P2 修改了,这时生产者 P1 执行 compareAndSet()方法,发现 tail 指针指向的值 V 和期望值 E 不同,导致冲突。同样,如果多个消费者不断从缓冲区中获取任务,不会修改尾指针,但会造成队列头指针 head 的冲突问题(因为队列的 FIFO 特点,出列会从头指针出开始)。

环形队列的一个特点就是只有一个指针,只通过一个指针来实现出列和入列操作。如果使用两个指针 head 和 tail 来管理这个队列,有可能会出现 “伪共享” 问题(伪共享问题在下面我会详细说),因为创建队列时,head 和 tail 指针变量常常在同一个缓存行中,多线程修改同一缓存行中的变量就容易出现伪共享问题。

第二,由于使用的是环形队列,那么队列创建时大小就被固定了,Disruptor 框架中的环形队列本来也就是基于数组实现的,使用数组的话,减少了系统对内存空间管理的压力,因为它不像链表,Java 会定期回收链表中一些不再引用的对象,而数组不会出现空间的新分配和回收问题。

原理:Disruptor 的等待策略

Disruptor 默认的等待策略是 BlockingWaitStrategy。这个策略的内部适用一个锁和条件变量来控制线程的执行和等待(Java 基本的同步方法)。BlockingWaitStrategy 是最慢的等待策略,但也是 CPU 使用率最低和最稳定的选项。然而,可以根据不同的部署环境调整选项以提高性能。

SleepingWaitStrategy

和 BlockingWaitStrategy 一样,SpleepingWaitStrategy 的 CPU 使用率也比较低。它的方式是循环等待并且在循环中间调用 LockSupport.parkNanos (1) 来睡眠,(在 Linux 系统上面睡眠时间 60µs). 然而,它的优点在于生产线程只需要计数,而不执行任何指令。并且没有条件变量的消耗。但是,事件对象从生产者到消费者传递的延迟变大了。SleepingWaitStrategy 最好用在不需要低延迟,而且事件发布对于生产者的影响比较小的情况下。比如异步日志功能。

YieldingWaitStrategy

YieldingWaitStrategy 是可以被用在低延迟系统中的两个策略之一,这种策略在减低系统延迟的同时也会增加 CPU 运算量。YieldingWaitStrategy 策略会循环等待 sequence 增加到合适的值。循环中调用 Thread.yield () 允许其他准备好的线程执行。如果需要高性能而且事件消费者线程比逻辑内核少的时候,推荐使用 YieldingWaitStrategy 策略。例如:在开启超线程的时候。

BusySpinW4aitStrategy

BusySpinWaitStrategy 是性能最高的等待策略,同时也是对部署环境要求最高的策略。这个性能最好用在事件处理线程比物理内核数目还要小的时候。例如:在禁用超线程技术的时候。

原理:并行模式

单一写者模式

在并发系统中提高性能最好的方式之一就是单一写者原则,对 Disruptor 也是适用的。如果在你的代码中仅仅有一个事件生产者,那么可以设置为单一生产者模式来提高系统的性能。

public class singleProductorLongEventMain { public static void main(String[] args) throws Exception { //.....// Construct the Disruptor with a SingleProducerSequencer Disruptor<LongEvent> disruptor = new Disruptor(factory, bufferSize, ProducerType.SINGLE, // 单一写者模式, executor);//..... }
}

一次生产,串行消费

比如:现在触发一个注册 Event,需要有一个 Handler 来存储信息,一个 Hanlder 来发邮件等等。

/*** 串行依次执行* <br/>* p --> c11 --> c21* @param disruptor*/public static void serial(Disruptor<LongEvent> disruptor){disruptor.handleEventsWith(new C11EventHandler()).then(new C21EventHandler());disruptor.start();}

菱形方式执行

 public static void diamond(Disruptor<LongEvent> disruptor){disruptor.handleEventsWith(new C11EventHandler(),new C12EventHandler()).then(new C21EventHandler());disruptor.start();}

链式并行计算

 public static void chain(Disruptor<LongEvent> disruptor){disruptor.handleEventsWith(new C11EventHandler()).then(new C12EventHandler());disruptor.handleEventsWith(new C21EventHandler()).then(new C22EventHandler());disruptor.start();}

相互隔离模式

 public static void parallelWithPool(Disruptor<LongEvent> disruptor){disruptor.handleEventsWithWorkerPool(new C11EventHandler(),new C11EventHandler());disruptor.handleEventsWithWorkerPool(new C21EventHandler(),new C21EventHandler());disruptor.start();}

航道模式

串行依次执行,同时 C11,C21 分别有 2 个实例

/*** 串行依次执行,同时C11,C21分别有2个实例* <br/>* p --> c11 --> c21* @param disruptor*/public static void serialWithPool(Disruptor<LongEvent> disruptor){disruptor.handleEventsWithWorkerPool(new C11EventHandler(),new C11EventHandler()).then(new C21EventHandler(),new C21EventHandler());disruptor.start();}

无锁编程(Lock Free)框架 系列文章相关推荐

  1. 我是如何一步步的在并行编程中将lock锁次数降到最低实现无锁编程

    在并行编程中,经常会遇到多线程间操作共享集合的问题,很多时候大家都很难逃避这个问题做到一种无锁编程状态,你也知道一旦给共享集合套上lock之后,并发和伸缩能力往往会造成很大影响,这篇就来谈谈如何尽可能 ...

  2. 帖子如何实现显示浏览次数_我是如何一步步的在并行编程中将lock锁次数降到最低实现无锁编程...

    在并行编程中,经常会遇到多线程间操作共享集合的问题,很多时候大家都很难逃避这个问题做到一种无锁编程状态,你也知道一旦给共享集合套上lock之后,并发和伸缩能力往往会造成很大影响,这篇就来谈谈如何尽可能 ...

  3. 【C++】多线程与原子操作和无锁编程【五】

    [C++]多线程与原子操作和无锁编程[五] 1.何为原子操作 前面介绍了多线程间是通过互斥锁与条件变量来保证共享数据的同步的,互斥锁主要是针对过程加锁来实现对共享资源的排他性访问.很多时候,对共享资源 ...

  4. 【翻译】RUST无锁编程

    本文内容译自Lock-freedom without garbage collection,中间有少量自己的修改. 人们普遍认为,垃圾收集的一个优点是易于构建高性能的无锁数据结构.对这些数据结构进行手 ...

  5. 浅谈Linux内核无锁编程原理

    非阻塞型同步 (Non-blocking Synchronization) 简介 如何正确有效的保护共享数据是编写并行程序必须面临的一个难题,通常的手段就是同步.同步可分为阻塞型同步(Blocking ...

  6. Cpp / 无锁编程

    无锁编程,就是编译器不再使用系统中关于锁的 API,而是直接通过使用缓存一致性等算法达到锁的目的的一种编程. 可以使用 std::atomic<T> 系列和 __sync_val_comp ...

  7. 交易系统开发技能及面试之无锁编程(Lock-free)

    目录 概要 Q1 什么是atomic? Q2 alignas 关键字 Q3 C++的内存模型 relaxed ordering, release-acquire ordering, sequentia ...

  8. 【C++】原子操作(atomic)与无锁编程学习记录

    lambda std::bind 智能指针使用 深度库基于数据结构与算法的优化 atomic 操作与多线程 数据安全 原子内存操作 设计模式:单例模式 C++11原子操作与无锁编程 https://w ...

  9. 无锁编程与有锁编程的效率总结、无锁队列的实现(c语言)

    1.无锁编程与有锁编程的效率 无锁编程,即通过CAS原子操作去控制线程的同步.如果你还不知道什么使CAS原子操作,建议先去查看相关资料,这一方面的资料网络上有很多. CAS实现的是硬件级的互斥,在线程 ...

最新文章

  1. python调用所有函数_python打印所有函数调用以了解脚本
  2. python异常处理_汇总三大python异常处理、自定义异常、断言原理与用法分析
  3. jupyter跑Java,C++/C,R
  4. c语言中规定的标准文件,标准C语言
  5. 磊哥私藏书单分享,160买400的书!
  6. poj 1904 King's Quest 强连通分量+匹配
  7. homework2:根据已知代码,回答问题
  8. 【数据库】SQL语句大全
  9. 计算机鼠标游戏教学法,练习使用鼠标教案
  10. office 快捷键
  11. 成功的客户关系项目管理实施案例的共同特点
  12. 灵活无成本的ITSM系统|ServiceHot ITSOM
  13. 盘点苹果电脑上那些不错的cpu优化工具
  14. 电脑无法识别U盘的解决方式集锦_艾孜尔江撰稿
  15. 一套Java架构开发的电商系统要多少钱
  16. Java课程project(SMAC计算器)----基于JavaSE
  17. MATLAB绘制二维曲线-fplot函数
  18. 计算机系给未来的自己写信,给未来的自己写信
  19. JAVA毕业设计健康生活运动咨询系统计算机源码+lw文档+系统+调试部署+数据库
  20. 定位教程6---上下相机

热门文章

  1. We are 歪果仁带你灰
  2. TXT文件怎么转换成PDF这种格式?分享给大家三个方法!
  3. PHP大型电商网站秒杀思路
  4. c语言norm函数的作用,带有示例的C ++中的norm()函数
  5. 歇后语的趣味生活场景,笑料不断
  6. HLQ逆向坎坷路 之 首战 看我破解APK修改资源文件
  7. html文件转pdf
  8. Vivado经典案例:使用Simulink设计FIR滤波器
  9. 青龙面板--酷狗大字版-2022-05-07
  10. 巴比特 | 元宇宙每日必读:从“派对岛”到“抖音仔仔”,通过虚拟身份切入元宇宙社交赛道,字节这条路是否行得通?...