JUC(三):JUC包下锁概念
线程不安全集合类
ArrayList
List是线程不安全的集合类,底层是Object数组实现,初始化容量是10(其实是一个空数组,第一次扩容时,将数组扩容为10),其后每次扩容大小为当前容量的一半(oldCapacity >> 1)。
初始化
/**
* Constructs an empty list with an initial capacity of ten.
*/
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
扩容
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
private void ensureCapacityInternal(int minCapacity) {
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
private static int calculateCapacity(Object[] elementData, int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
初次扩容,将底层数组容量设为10。
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
动态扩容,是将底层数组容量扩容当前容量的一半(oldCapacity >> 1)。
线程不安全示例
package com.chinda.juc.coll;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.IdUtil;
import java.util.ArrayList;
import java.util.List;
/**
* ArrayList线程不安全
* @author Wang Chinda
* @date 2020/5/10
* @see
* @since 1.0
*/
public class ListUnsafe {
public static void main(String[] args) {
List<String> list = CollUtil.newArrayList();
for (int i = 1; i <= 3; i++) {
new Thread(() -> {
list.add(IdUtil.simpleUUID());
System.out.println(list);
}, String.valueOf(i)).start();
}
}
}
本示例依赖包
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-core</artifactId>
<version>5.2.3</version>
</dependency>
控制台输出
[0b867a48ef73409885294f6e1e643ce3]
[0b867a48ef73409885294f6e1e643ce3]
[0b867a48ef73409885294f6e1e643ce3]
循环30次控制台输出异常
java.util.ConcurrentModificationException
导致原因
因是线程并发写入数据,当线程A正在写数据时,线程执行一半时,线程B抢到资源,开始执行。这就会导致线程A写入数据不正确。
比如现实中的花名册
单线程执行解释:班长将会依次的将全班所有的同学都写入花名册。
多线程执行解释:全班同学自己签自己名字,可能会出现李四刚写一个李字时,花名册被张三抢去接着写的情况。假如班长看着,必须一个人写完名字,才允许第二个人写名字,依次往复。这就是锁的概念。
解决不安全
第一种方案
添加元素方法中添加synchronized。Java已实现,Vector类。
第二种方案
集合操作工具类。
List<String> list = Collections.synchronizedList(CollUtil.newArrayList());
第三种方案
推荐使用此种方案。
List<String> list = new CopyOnWriteArrayList<>();
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
附带,HashMap初始化容量是16, 每次扩容是当前容量*2。HashSet底层数据结构就是HashMap,为什么HashMap是put存放数据而HashSet是add,是因为HashSet存放将add的元素存放为HashMap的key中,value一直是一个Object对象。
锁概念
公平锁和非公平锁
java.util.concurrent.locks包中ReentrantLock的创建可以指定构造函数的boolean类型指定是公平锁还是非公平锁,默认是非公平锁。非公平锁的有点在于吞吐量比公平锁大。synchronized也是一种非公平锁。
公平锁
多个线程按照申请锁的顺序来获取锁,遵循先申请到先得原则。
非公平锁
多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请锁的线程比先申请锁的线程优先获取锁,在高并发的情况下,有可能造成优先级反转(后申请锁的线程总是先得到锁)或者饥饿现象(先申请锁的线程一直没有获取到锁)。
可重入锁(递归锁)
同一线程外出函数获得锁之后,内存递归函数仍然能获取该锁得代码,同一线程在外层方法获取锁的时候,在进入内层方法时会自动获取锁,即线程可以进入任何一个它已经拥有的锁同步的代码块。可重入锁最大的作用时避免死锁。
可重入锁示例
package com.chinda.juc.coll;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @author Wang Chinda
* @date 2020/5/10
* @see
* @since 1.0
*/
public class ReenterLock {
public static void main(String[] args) {
Phone phone = new Phone();
new Thread(() -> {
phone.sendSMS();
}, "t1").start();
new Thread(() -> {
phone.sendSMS();
}, "t2").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println();
System.out.println();
System.out.println();
System.out.println();
Thread t3 = new Thread(phone, "t3");
Thread t4 = new Thread(phone, "t4");
t3.start();
t4.start();
}
}
class Phone implements Runnable {
public synchronized void sendSMS() {
System.out.println(Thread.currentThread().getName() + "\t invoked sendSMS()");
sendEmail();
}
public synchronized void sendEmail() {
System.out.println(Thread.currentThread().getName() + "\t *****invoked sendEmail()");
}
Lock lock = new ReentrantLock();
@Override
public void run() {
get();
}
private void get() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "\t invoked get()");
set();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
private void set() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "\t invoked set()");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
控制台打印
t1 invoked sendSMS()
t1 *****invoked sendEmail()
t2 invoked sendSMS()
t2 *****invoked sendEmail()
t4 invoked get()
t4 invoked set()
t3 invoked get()
t3 invoked set()
线程执行时,进入到嵌套方法时,不需要获取锁,可直接进入。线程执行嵌套方法时,没有被其余线程加塞。
注意:若加锁与解锁个数相匹配,编译不会失败,执行不会阻塞;若加锁比解锁多,线程会进入阻塞状态;若解锁比加锁多执行会抛出IllegalMonitorStateException异常
自旋锁
尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU资源。
public final int getAndSetInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var4));
return var5;
}
自旋锁示例
package com.chinda.juc.coll;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* 优点: 循环比较获取直到成功为止, 没有类似wait的阻塞
* <p>
* 通过CAS操作完成自旋锁, A线程先进来掉哦那个myLock方法自己持有锁5秒, B随后进来后发现当前线程持有锁, 不是null, 自选等待, 直到A释放锁后B才会抢到。
*
* @author Wang Chinda
* @date 2020/5/10
* @see
* @since 1.0
*/
public class SpinDemo {
AtomicReference<Thread> atomicReference = new AtomicReference<Thread>();
public void myLock() {
Thread thread = Thread.currentThread();
System.out.println(Thread.currentThread().getName() + "\t com in (*^_^*)");
while (!atomicReference.compareAndSet(null, thread)) {
}
}
public void myUnlock() {
Thread thread = Thread.currentThread();
atomicReference.compareAndSet(thread, null);
System.out.println(Thread.currentThread().getName() + "\t invoked myUnlock()");
}
public static void main(String[] args) {
SpinDemo spinDemo = new SpinDemo();
new Thread(() -> {
spinDemo.myLock();
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
spinDemo.myUnlock();
}, "A").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
spinDemo.myLock();
spinDemo.myUnlock();
}, "B").start();
}
}
控制台打印
A com in (*^_^*)
B com in (*^_^*)
A invoked myUnlock()
B invoked myUnlock()
线程A执行锁时,因为没有任何人获取锁,所以锁为null。线程B获取锁时,锁已经被线程A占用,线程B循环循环获取锁,直到线程A释放锁为止。
独占锁(写锁)/共享锁(读锁)/互斥锁
独占锁
一次只能被一个线程所持有。ReetrantLock和synchronized都是独占锁。
共享锁
可以被多个线程所持有。
ReentrantReadWriteLock其读锁时共享锁,写时独占锁。读锁的共享锁可保证高并发读时非常高效的。读写、写读、写写的过程时互斥的。
读写锁
线程不安全示例
package com.chinda.juc.coll;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.thread.ThreadUtil;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 多个线程同时读取一个资源类没有任何问题, 所以为了满足并发量, 读取共享资源应该可以同时进行。
* 但是如果有一个线程去写共享资源,就不应该再有其他线程可以对该资源进行读或者写。
* 即:
* 读-读 能共存
* 读-写 不能共存
* 写-写 不能共存
* 写操作: 原子+独占, 整个过程必须时完成的统一体,中间不允许被分割, 被打断。
* @author Wang Chinda
* @date 2020/5/11
* @see
* @since 1.0
*/
public class ReadWriteLockDemo {
public static void main(String[] args) {
MyCache myCache = new MyCache();
for (int i = 1; i <= 10; i++) {
String finalI = i + "";
new Thread(() -> {
myCache.put(finalI, finalI);
}, String.valueOf(i)).start();
}
for (int i = 1; i <= 10; i++) {
String finalI = i + "";
new Thread(() -> {
myCache.get(finalI);
}, String.valueOf(i)).start();
}
}
}
class MyCache {
private volatile Map<String, Object> map = CollUtil.newHashMap();
public void put(String key, Object value) {
System.out.println(Thread.currentThread().getName() + "\t 正在写入: " + key);
ThreadUtil.sleep(300, TimeUnit.MILLISECONDS);
map.put(key, value);
System.out.println(Thread.currentThread().getName() + "\t 写入完成");
}
public void get(String key) {
System.out.println(Thread.currentThread().getName() + "\t 正在读取: " + key);
ThreadUtil.sleep(300, TimeUnit.MILLISECONDS);
Object result = map.get(key);
System.out.println(Thread.currentThread().getName() + "\t 读取完成:" + result);
}
}
线程安全示例
package com.chinda.juc.coll;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.thread.ThreadUtil;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* 多个线程同时读取一个资源类没有任何问题, 所以为了满足并发量, 读取共享资源应该可以同时进行。
* 但是如果有一个线程去写共享资源,就不应该再有其他线程可以对该资源进行读或者写。
* 即:
* 读-读 能共存
* 读-写 不能共存
* 写-写 不能共存
* 写操作: 原子+独占, 整个过程必须时完成的统一体,中间不允许被分割, 被打断。
* @author Wang Chinda
* @date 2020/5/11
* @see
* @since 1.0
*/
public class ReadWriteLockDemo {
public static void main(String[] args) {
MyCache myCache = new MyCache();
for (int i = 1; i <= 10; i++) {
String finalI = i + "";
new Thread(() -> {
myCache.put(finalI, finalI);
}, String.valueOf(i)).start();
}
for (int i = 1; i <= 10; i++) {
String finalI = i + "";
new Thread(() -> {
myCache.get(finalI);
}, String.valueOf(i)).start();
}
}
}
class MyCache {
private volatile Map<String, Object> map = CollUtil.newHashMap();
private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
public void put(String key, Object value) {
rwLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + "\t 正在写入: " + key);
ThreadUtil.sleep(300, TimeUnit.MILLISECONDS);
map.put(key, value);
System.out.println(Thread.currentThread().getName() + "\t 写入完成");
} catch (Exception e) {
e.printStackTrace();
} finally {
rwLock.writeLock().unlock();
}
}
public void get(String key) {
rwLock.readLock();
try {
System.out.println(Thread.currentThread().getName() + "\t 正在读取: " + key);
ThreadUtil.sleep(300, TimeUnit.MILLISECONDS);
Object result = map.get(key);
System.out.println(Thread.currentThread().getName() + "\t 读取完成:" + result);
} catch (Exception e) {
e.printStackTrace();
} finally {
rwLock.readLock().unlock();
}
}
}
CountDownLatch
CountDownLatch这个类使一个线程等待其他线程各自执行完毕后再执行。是通过一个计数器来实现的,计数器的初始值是线程的数量。每当一个线程执行完毕后,计数器的值就-1,当计数器的值为0时,表示所有线程都执行完毕,然后在闭锁上等待的线程就可以恢复。
示例
实现效果:图书馆有6个人,等6个人离开图书馆,门卫大爷锁门。
反面示例
package com.chinda.juc.coll;
/**
* @author Wang Chinda
* @date 2020/5/11
* @see
* @since 1.0
*/
public class CountDownLatchDemo {
public static void main(String[] args) {
for (int i = 1; i <= 6; i++) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 离开图书馆");
}, String.valueOf(i)).start();
}
System.out.println(Thread.currentThread().getName() + "\t ************* 门卫大爷锁门!");
}
}
可能会出现人没有全部离开,门就被锁。
CountDownLatch示例
package com.chinda.juc.coll;
import lombok.SneakyThrows;
import java.util.concurrent.CountDownLatch;
/**
* @author Wang Chinda
* @date 2020/5/11
* @see
* @since 1.0
*/
public class CountDownLatchDemo {
@SneakyThrows
public static void main(String[] args) {
CountDownLatch downLatch = new CountDownLatch(6);
for (int i = 1; i <= 6; i++) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 离开图书馆");
downLatch.countDown();
}, String.valueOf(i)).start();
}
downLatch.await();
System.out.println(Thread.currentThread().getName() + "\t ************* 门卫大爷锁门!");
}
}
天下一统
大秦帝国统一天下,前提灭六国,六国被灭顺序:韩国,赵国,魏国,楚国,燕国,齐国。
示例
package com.chinda.juc.coll;
import lombok.Getter;
/**
* @author Wang Chinda
* @date 2020/5/12
* @see
* @since 1.0
*/
public enum SengokuEnum {
ONE(1, "韩国"),
TWO(2, "赵国"),
THREE(3, "魏国"),
FOUR(4, "楚国"),
FIVE(5, "燕国"),
SIX(6, "齐国");
@Getter
private Integer code;
@Getter
private String name;
SengokuEnum(Integer code, String name) {
this.code = code;
this.name = name;
}
public static SengokuEnum eachSengKu(int index) {
SengokuEnum[] sengokus = SengokuEnum.values();
for (SengokuEnum sengoku : sengokus) {
if (index == sengoku.getCode()) {
return sengoku;
}
}
return null;
}
}
package com.chinda.juc.coll;
import lombok.SneakyThrows;
import java.util.concurrent.CountDownLatch;
/**
* @author Wang Chinda
* @date 2020/5/12
* @see
* @since 1.0
*/
public class ChinDemo {
@SneakyThrows
public static void main(String[] args) {
CountDownLatch downLatch = new CountDownLatch(6);
for (int i = 1; i <= 6; i++) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "被灭。");
downLatch.countDown();
}, SengokuEnum.eachSengKu(i).getName()).start();
}
downLatch.await();
System.out.println("天下一统, 秦国统一天下。");
}
}
CyclicBarrier
集齐7颗龙珠,召唤神龙。
示例
package com.chinda.juc.coll;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
/**
* @author Wang Chinda
* @date 2020/5/12
* @see
* @since 1.0
*/
public class CyclicBarrierDemo {
public static void main(String[] args) {
CyclicBarrier cyclicBarrier = new CyclicBarrier(7, () -> {
System.out.println("***********召唤神龙***********");
});
for (int i = 1; i <= 7; i++) {
int finalI = i;
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 收集到第: " + finalI + "龙珠。");
try {
cyclicBarrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
}, String.valueOf(i)).start();
}
}
}
Semaphore
信号量主要用于两个目的,一个是用于多个共享资源的互斥使用,另一个用于并发线程数的控制。
示例
实现效果:6辆车争抢3个车位,只允许一辆车开走,第二辆才允许进入车位。
package com.chinda.juc.coll;
import cn.hutool.core.thread.ThreadUtil;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
/**
* @author Wang Chinda
* @date 2020/5/12
* @see
* @since 1.0
*/
public class SemaphoreDemo {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(3);
for (int i = 1; i <= 6; i++) {
new Thread(() -> {
try {
semaphore.acquire();
System.out.println(Thread.currentThread().getName() + "\t抢到车位");
ThreadUtil.sleep(3, TimeUnit.SECONDS);
System.out.println(Thread.currentThread().getName() + "\t离开车位");
} catch (Exception e) {
e.printStackTrace();
} finally {
semaphore.release();
}
}, String.valueOf(i)).start();
}
}
}
JUC(三):JUC包下锁概念的更多相关文章
- JUC原子操作类与乐观锁CAS
JUC原子操作类与乐观锁CAS 硬件中存在并发操作的原语,从而在硬件层面提升效率.在intel的CPU中,使用cmpxchg指令.在Java发展初期,java语言是不能够利用硬件提供的这些便利来提 ...
- Java并发包下锁学习第一篇:介绍及学习安排
Java并发包下锁学习第一篇:介绍及学习安排 在Java并发编程中,实现锁的方式有两种,分别是:可以使用同步锁(synchronized关键字的锁),还有lock接口下的锁.从今天起,凯哥将带领大家一 ...
- 多线程爬坑之路-J.U.C.atomic包下的AtomicInteger,AtomicLong等类的源码解析
Atomic原子类:为基本类型的封装类Boolean,Integer,Long,对象引用等提供原子操作. 一.Atomic包下的所有类如下表: 类摘要 AtomicBoolean 可以用原子方式更新的 ...
- [Java多线程]-J.U.C.atomic包下的AtomicInteger,AtomicLong等类的源码解析
Atomic原子类:为基本类型的封装类Boolean,Integer,Long,对象引用等提供原子操作. 一.Atomic包下的所有类如下表: 类摘要 AtomicBoolean 可以用原子方式更新的 ...
- Java:concurrent包下面的Map接口框架图(ConcurrentMap接口、ConcurrentHashMap实现类)
Java集合大致可分为Set.List和Map三种体系,其中Set代表无序.不可重复的集合:List代表有序.重复的集合:而Map则代表具有映射关系的集合.Java 5之后,增加了Queue体系集合, ...
- fiddler教程:抓包带锁的怎么办?HTTPS抓包介绍。
点击上方↑↑↑蓝字[协议分析与还原]关注我们 " 介绍Fiddler的HTTPS抓包功能." 这里首先回答下标题中的疑问,fiddler抓包带锁的原因是HTTPS流量抓包功能开启, ...
- 全面了解Java中的15种锁概念及机制!
在读很多并发文章中,会提及各种各样锁如公平锁,乐观锁等等,这篇文章介绍各种锁的分类.介绍的内容如下: 1.公平锁 / 非公平锁 2.可重入锁 / 不可重入锁 3.独享锁 / 共享锁 4.互斥锁 / 读 ...
- Java并发包下锁学习第二篇Java并发基础框架-队列同步器介绍
Java并发包下锁学习第二篇队列同步器 还记得在第一篇文章中,讲到的locks包下的类结果图吗?如下图: 从图中,我们可以看到AbstractQueuedSynchronizer这个类很重要(在本 ...
- 深入理解Java并发框架AQS系列(二):AQS框架简介及锁概念
深入理解Java并发框架AQS系列(一):线程 深入理解Java并发框架AQS系列(二):AQS框架简介及锁概念 一.AQS框架简介 AQS诞生于Jdk1.5,在当时低效且功能单一的synchroni ...
随机推荐
- [原题复现+审计][0CTF 2016] WEB piapiapia(反序列化、数组绕过)[改变序列化长度,导致反序列化漏洞]
简介 原题复现: 考察知识点:反序列化.数组绕过 线上平台:https://buuoj.cn(北京联合大学公开的CTF平台) 榆林学院内可使用信安协会内部的CTF训练平台找到此题 漏洞学习 数组 ...
- SHEIN:Java开发面经
SHEIN面经 我觉得除技术外,自信是一个非常关键的点. 一面 自我介绍: 谈谈实习经历: 讲讲你实习的收获: 如何设计规范的接口?(简历上有写,所以问到) 当你需要修改两个月前的代码时,如何去整理以 ...
- 关于Folx一些使用方面的问题详细解答
Folx作为一款的专业的Mac系统文件下载工具,相信大家或多或少都对它的主打功能,如智能限速.制定计划任务.直链文件下载等功能有所了解,但是对于它的一些相对少见.冷门的功能,却不太熟悉. 下面小编将通 ...
- Camtasia中对录制视频进行编辑——视觉效果
视频剪辑对很多人来说是一件很头痛的事,因为对着屏幕一下一下的进行调整会让人十分的心烦,导致花费了时间但是剪辑出来的视频质量却并不高.或许是因为你没有选择一款合适的软件,因为一款高质量的软件往往会给人带 ...
- 【Vue】VUE源码中的一些工具函数
Vue源码-工具方法 /* */ //Object.freeze()阻止修改现有属性的特性和值,并阻止添加新属性. var emptyObject = Object.freeze({}); // th ...
- 2017年第八届蓝桥杯【C++省赛B组】B、C、D、H 题解
可能因为我使用暴力思维比较少,这场感觉难度不低. B. 等差素数列 #暴力 #枚举 题意 类似:\(7,37,67,97,127,157\) 这样完全由素数组成的等差数列,叫等差素数数列. 上边的数列 ...
- native关键字是干什么的?
目录 1.怎么调用到native方法的呢? 2. java调用自定义native方法步骤 3.使用native的缺点 今天一不小心跟进Object的源码中,发现一个native关键字,一脸蒙蔽,怎么我 ...
- Java + maven + httpclient + testng + poi实现接口自动化
一.maven中引入httpclient.testng.poi依赖包 <project xmlns="http://maven.apache.org/POM/4.0.0" x ...
- 16个非常有趣的HTML5 Canvas动画特效集合
HTML5技术正在不断的发展和更新,越来越多的开发者也正在加入HTML5阵营,甚至在移动开发上HTML5的地位也是越来越重要了.HTML5中的大部分动画都是通过Canvas实现,因为Canvas就像一 ...
- 抖音短视频爆火的背后到底是什么——如何快速的开发一个完整的直播app
前言 今年移动直播行业的兴起,诞生了一大批网红,甚至明星也开始直播了,因此不得不跟上时代的步伐,由于第一次接触的原因,因此花了很多时间了解直播,今天我来教你从零开始搭建一个完整的直播app,希望能帮助 ...