ToB企服应用市场:ToB评测及商务社交产业平台
标题:
多线程 05:线程同步,三大不安全案例分析,synchronized 和 Lock 的应用,
[打印本页]
作者:
愛在花開的季節
时间:
2024-11-12 14:23
标题:
多线程 05:线程同步,三大不安全案例分析,synchronized 和 Lock 的应用,
一、概述
纪录时间 [2024-11-11]
前置知识
:Java 基础篇;Java 面向对象
多线程 01:Java 多线程学习导航,线程简介,线程相干概念的整理
多线程 02:线程实现,创建线程的三种方式,通过多线程下载图片案例分析异同(Thread,Runnable,Callable)
多线程 03:知识增补,静态代理与 Lambda 表达式的相干先容,及其在多线程方面的应用
多线程 04:线程状态,线程的五大根本状态及状态转换,以及线程利用方法、优先级、守护线程的相干知识
Java 多线程学习主要模块包括:线程简介;线程实现;线程状态;线程同步;线程通讯问题;拓展高级主题。
本文讲述
线程同步
相干知识,包括线程同步机制,线程同步涉及的
三大不安全案例
(不安全买票 / 取款 / 集合),及这些案例的美满方法。同时,通过 synchronized(同步)和 Lock(锁),我们能解决多线程
修改共享资源引起的访问冲突
,实现线程同步。
此外,文章还先容了
死锁
的知识,如死锁产生的条件,死锁的案例,以及如何克制死锁等。
二、线程同步机制
1. 相干概念
并发
(Concurrency)是指计算机系统中
多个使命在同一时间段内交织实行
的能力。固然这些使命大概不是真正同时举行的(即并行,Parallelism),但从宏观上看,它们似乎是在同一时间实行的。并发编程的目标是提高程序的服从和响应性,尤其是在处理 I/O 密集型使命或多用户环境时。
多线程是实现并发的一种常见方式
。每个历程可以包含多个线程,这些
线程共享历程的资源
(如内存地址空间),但每个线程有自己的栈和程序计数器。在带来方便的同时,也带来了
访问冲突
的问题。
线程同步
是多线程编程中的一个重要概念,它主要用于确保多个线程在访问共享资源(如变量或数据结构)时不会发生冲突。
简言之,
多个线程操作同一个资源
。
如果多个线程同时修改同一个资源而没有适当的
同步机制
,大概会导致数据损坏或其他不可预测的举动。
队列和锁
是多线程编程中非常重要的同步机制。联合利用队列和锁可以有用地管理和协调多个线程之间的使命分配和资源共享,提高程序的可靠性和性能。
在 Java 中,为了保证数据在方法中被访问时的
正确性
,在访问时加入
锁机制(Synchronized)
,当一个线程获取了某个对象的锁之后,它就拥有了对该资源的
独占
访问权,此时其他试图访问同一资源的线程将不得不等候。一旦该线程完成了对资源的操作并释放了锁,其他等候中的线程就可以继承尝试获取锁以访问资源。
需要注意的是,一个线程持有锁会导致其他所有需要此锁的线程挂起;且加锁 / 释放锁会导致比力多的上下文切换和调度延时,引起性能问题;如果优先级高的线程等候优先级低的线程释放锁,会导致性能倒置。属于是
牺牲部门性能来保证安全性
。
2. 举例说明
在现实生存中,多个线程同时操作同一个资源的例子有许多。
比方,抢票时,当后台仅剩 1 张票时,所有用户都能看到这张票。如果不对接入的抢票举动举行有用控制,每个用户都大概同时尝试抢票,从而导致超售现象。这种环境不仅会影响用户体验,还会给系统带来安全隐患。
在银行取款的场景中,当账户余额只剩下最后一笔资金时,所有持有账户的客户都能看到这笔余额。如果不对接入的取款哀求举行有用的控制,每个客户都大概同时尝试取款,从而导致账户余额不敷的问题。这种环境不仅会影响客户的体验,还大概引发金融风险和系统的不稳定性。
为了防止这种环境发生,银行系统通常会接纳各种
同步机制
来确保每次取款操作的原子性和同等性。比方,利用互斥锁来
保证在同一时间内只有一个取款哀求能够访问账户余额
,别的用户会进入等候队列,有序排队以免引起混乱。
抢票系统亦然。
三、不安全案例
下面例举三个多线程的并发问题案例,这些问题都指出了——线程同步机制的重要性。
分别是不安全买票、不安全取钱,以及不安全集合。
在多线程环境下,多个线程共享历程的资源,同时每个线程在自己的工作内存交互。它们会把历程共享的信息
复制一份到自己的工作内存
,然后处理这些东西,大概
同时
对历程的资源举行
修改
,从而导致了数据不同等问题。
1. 不安全买票
多线程抢票中存在的并发问题主要包括以下几点:
当多个线程试图同时更新同一份数据(比方剩余票数)时,如果没有适当的同步步调,大概导致数据的终极状态不符合预期。比方,两个线程险些同时检查到还有 1 张票可用,两者都尝试购买这张票,终极大概
导致这张票被卖出两次
。
在某些环境下,如果调度算法不公平,某些线程大概永久得不到实行时机,尤其是当其他线程总是优先得到资源时。在抢票场景中,这大概
导致部门用户永久无法成功购票
。
在多线程环境下,如果对共享数据的操作不是原子性的,大概没有正确利用同步机制,大概会
导致数据不同等
,如数据丢失或数据损坏。
通过编写测试代码来更好地理解。
买票操作类
设置系统中剩余的票数;
美满具体的买票操作逻辑:判定是否有余票,卖出一张票总数减一;
设置多线程买票的入口,以及线程停止标志位。
// 1. 买票操作类
class BuyTicket implements Runnable {
// 2. 设置票数,私有属性安全
private int ticketNums = 10;
// 4. 设置线程停止标志位
private boolean flag = true;
@Override
public void run() {
// 7. 多线程买票的入口
while (flag) {
try {
buy();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
// 3. 具体的买票操作逻辑
private void buy() throws InterruptedException {
// 5. 如果票卖完了就结束
if (ticketNums <= 0) {
flag = false;
return;
}
// 8. 设置延时,增加问题的发生性
Thread.sleep(100);
// 6. 有票就卖
System.out.println(Thread.currentThread().getName() + "买到了票" + ticketNums--);
}
}
复制代码
多线程启动类
创建三个线程,模拟三类用户抢票。
/*
不安全的买票
线程不安全,可能有负数,有拿到同一张票的情况
每个线程在自己的工作内存交互,内存控制不当会造成数据不一致
*/
public class UnsafeBuyTicket {
public static void main(String[] args) {
// 9. 创建三类多线程抢票用户
BuyTicket station = new BuyTicket();
new Thread(station,"小明").start();
new Thread(station,"元元").start();
new Thread(station,"黄牛党").start();
}
}
复制代码
测试结果
从他们的抢票结果中不难发现,有买到
重复票
的,也有买到
不正常的票
的,此时的抢票程序是不安全的。
# 不安全,结果不唯一
元元买到了票10
黄牛党买到了票9
小明买到了票10
黄牛党买到了票8
元元买到了票6
小明买到了票7
小明买到了票5
黄牛党买到了票3
元元买到了票4
元元买到了票2
黄牛党买到了票1
小明买到了票0
复制代码
2. 不安全取钱
在多线程环境中,不安全取钱的问题主要源于并发访问和修改共享资源(如银行账户余额)时缺乏适当的同步机制。这些问题大概
导致数据不同等、资金丢失或重复扣款
等严峻结果。
比方,一个线程正在更新账户余额,而另一个线程在此期间读取了旧的余额值,并基于这个旧值举行进一步的操作。
通过编写测试代码来更好地理解。
银行账户类
银行账户中包含了账户名、卡内余额。
// 1. 银行账户
class Account {
// 卡里余额
private int money;
// 卡名
private String name;
public Account(int money, String name) {
this.money = money;
this.name = name;
}
public int getMoney() {
return money;
}
public void setMoney(int money) {
this.money = money;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
复制代码
取钱业务类
银行取钱业务类,美满取款逻辑。
判定卡内余额是否富足,如果余额不敷,则不能取款;
取款成功后更新账户余额。
// 2. 银行取钱业务
class Drawing extends Thread {
// 银行账户
private Account account;
// 待取金额
private int drawMoney;
// 手里的金额
private int nowMoney;
public Drawing(Account account, int drawMoney, String name) {
// 调用父类的构造函数,传入子类的名字
// 而这里的父类是线程 Thread 类,所以获取的线程名就是子类传入的名字
// getName() 是父类 Thread 的方法,子类使用父类的方法
// this.getName() == Thread.currentThread().getName(),表示线程名
super(name);
this.account = account;
this.drawMoney = drawMoney;
}
// 3. 取钱的逻辑,多线程取钱的入口
@Override
public void run() {
// 判断余额
if (account.getMoney() - drawMoney < 0) {
// this.getName() == Thread.currentThread().getName(),表示线程名
System.out.println(Thread.currentThread().getName() + "取款失败,余额不足");
return;
}
// 延时操作
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 取钱
// 账户余额
account.setMoney(account.getMoney() - this.drawMoney);
// 手里的金额
this.nowMoney = this.nowMoney + this.drawMoney;
System.out.println(this.getName() + "取了" + this.drawMoney);
System.out.println(Thread.currentThread().getName() + "手里有" + this.nowMoney);
System.out.println(this.account.getName() + "账户余额为" + this.account.getMoney());
}
}
复制代码
多线程启动类
模拟多个用户同时同账户取款。如你和委托人同时取出一张卡里的余额。
/*
不安全的取钱
两个人对同一个银行账户取钱
*/
public class UnsafeBank {
// 4. 两个用户对同一个账户取钱
public static void main(String[] args) {
Account account = new Account(100, "基础账户");
Drawing you = new Drawing(account, 50, "你");
Drawing another = new Drawing(account, 100, "委托人");
you.start();
another.start();
}
}
复制代码
测试结果
观察取款后的到手金额、账户余额,不难发现金额总数对不上,是不安全的。
# 不安全,结果不唯一
委托人取了100
委托人手里有100
你取了50
基础账户账户余额为50
你手里有50
基础账户账户余额为50
复制代码
3. 不安全集合
在多线程环境中,线程不安全的集合类大概会导致各种并发问题,如数据不同等、死锁、竞态条件等。
比方,启动 10000 个线程,每个线程尝试将自身的名称存入某个集合中,但终极集合中的内容数目少于 10000 个。原因在于多个线程同时运行时,
存在重复写入的环境
,把内容添加到了同一个位置,导致部门线程的写入操作被覆盖或忽略。
/*
线程不安全的集合
启动 10000 个线程,往某个集合中存入线程名称,实际集合内容小于 10000 个
原因:线程同时运行,存在重复写入的情况
*/
public class UnsafeList {
public static void main(String[] args) throws InterruptedException {
// 1. 新建集合
List<String> list = new ArrayList<String>();
// 2. 启动 1000 个线程
for (int i = 0; i < 10000; i++) {
new Thread(()->{
// 往某个集合中存入线程名称,追加数据
list.add(Thread.currentThread().getName());
}).start();
}
// 延时
Thread.sleep(5000);
// 3. 计算集合实际容量
System.out.println(list.size());
}
}
复制代码
四、Synchronized(同步)
1. 相干概念
synchronized 是 Java 中用于线程同步的关键字。它提供了一种简朴且有用的方法来确保多个线程在访问共享资源时不会发生冲突。
synchronized 关键字
可以用于方法或代码块
,确保在同一时间只有一个线程可以实行被标志为 synchronized 的代码段。
被 synchronized 声名为
同步方法 / 同步代码块
后,每个对象有一把锁,当一个线程获取了某个对象的锁之后,它就拥有了对该资源的
独占
访问权,此时其他试图访问同一资源的线程将不得不等候(阻塞)。一旦该线程完成了对资源的操作并释放了锁,其他等候中的线程就可以继承尝试获取锁以访问资源。
相当于原来是一窝蜂上去抢,用了 synchronized 就要排队,等上一个人用完了才能用。
注意,方法中
需要修改的内容才需要锁
,只读部门不用。锁太多了会造成资源浪费。
2. 利用方式
给方法添加修饰符,变成同步方法
// 同步方法默认锁的是 this,就是这个对象本身,或者是 class
private synchronized void buy() {}
复制代码
用代码块确定被锁的对象,形成同步代码块
synchronized (obj) {
// obj 同步监视器,代表需要被监视的对象,推荐使用共享资源
// 中间包裹修改资源的代码(不安全的代码)
}
复制代码
同步监视器(obj)的实行过程:
第一个线程访问,锁定同步监视器,实行此中代码;
第二个线程访问,发现同步监视器被锁定,无法访问;
第一个线程访问完毕,解锁同步监视器;
第二个线程访问,发现同步监视器没有锁,然后锁定并访问。
3. 美满不安全案例
接下来,我们通过 synchronized 同步来美满一下上面的三大不安全案例。
安全买票
将买票方法 buy() 变成同步方法即可。
/*
synchronized 同步方法
同步方法无需指定同步监视器
同步方法的同步监视器是 this,就是这个对象本身,或者是 class
*/
private synchronized void buy() throws InterruptedException {
// 5. 如果票卖完了就结束
if (ticketNums <= 0) {
flag = false;
return;
}
// 8. 设置延时,增加问题的发生性
Thread.sleep(100);
// 6. 有票就卖
System.out.println(Thread.currentThread().getName() + "买到了票" + ticketNums--);
}
复制代码
安全的环境下的出票结果:
# 安全的情况(不唯一)
# 出票结果是正常的
元元买到了票10
元元买到了票9
小明买到了票8
小明买到了票7
小明买到了票6
黄牛党买到了票5
黄牛党买到了票4
小明买到了票3
小明买到了票2
小明买到了票1
复制代码
安全取款
先确定同步监视器,是共享资源,这里是银行账户 account
锁的是变化的对象,需要增删改操作的对象,这里是银行账户 account
利用同步代码块,包裹修改账户的操作代码 synchronized (account) {}
/*
synchronized (obj)
锁的对象默认是本身 synchronized (this)
实际需要锁的是变化的对象,需要增删改操作的对象
obj 可以是任何对象,推荐使用共享资源作为同步监视器
所以这里我们需要锁住账户,而不是银行
*/
// 3. 取钱的逻辑,多线程取钱的入口
@Override
public void run() {
synchronized (account) {
// 判断余额
if (account.getMoney() - drawMoney < 0) {
// this.getName() == Thread.currentThread().getName(),表示线程名
System.out.println(Thread.currentThread().getName() + "取款失败,余额不足");
return;
}
// 延时操作
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 取钱
// 账户余额
account.setMoney(account.getMoney() - this.drawMoney);
// 手里的金额
this.nowMoney = this.nowMoney + this.drawMoney;
System.out.println(this.getName() + "取了" + this.drawMoney);
// System.out.println(Thread.currentThread().getName() + "手里有" + this.nowMoney);
System.out.println(this.account.getName() + "账户余额为" + this.account.getMoney());
}
}
复制代码
安全集合
锁的是变化的对象,需要增删改操作的对象,这里是集合 list
利用同步代码块,包裹集合操作代码
new Thread(()->{
synchronized (list) {
// 往某个集合中存入线程名称,追加数据
list.add(Thread.currentThread().getName());
}
}).start();
复制代码
固然,Java 中提供了另一种方式(JUC),来确保多线程操作集合的安全性。
需要用到 Java 的并发包,前面线程创建利用的 Callable 接口也用到了这个包。
编写代码,测试 JUC 安全类型的集合。
import java.util.concurrent.CopyOnWriteArrayList;
// 测试 JUC 安全类型的集合
public class TestJUC {
public static void main(String[] args) throws InterruptedException {
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<String>();
for (int i = 0; i < 10000; i++) {
new Thread(()->{
list.add(Thread.currentThread().getName());
}).start();
}
Thread.sleep(3000);
System.out.println(list.size());
}
}
复制代码
五、Lock(锁)
从 JDK 5.0 开始,Java 提供了更强大的线程同步机制——通过
显式定义同步锁对象
来实现同步。同步锁利用 Lock 对象充当。
Lock 是 Java 中 java.util.concurrent.locks 包提供的一个接口,用于实现更灵活和高级的锁机制,
控制多个线程对共享资源举行访问
。锁提供了对共享资源的
独占访问
,每次只能有一个线程对 Lock 对象加锁,线程开始访问共享资源之前,应先得到 Lock 对象。
与传统的 synchronized 关键字相比,Lock 接口提供了更多的功能和更好的性能特性。以下是 Lock 接口的主要特性和利用方法。
1. Lock 接口的主要特性
可重入性
:Lock 支持可重入锁,即一个线程可以多次获取同一个锁而不会导致死锁。
公平锁
:可以指定锁是否为公平锁,公平锁会按照哀求锁的顺序来分配锁,而非公平锁则不肯定。
尝试锁定
:可以尝试获取锁而不被阻塞,如果锁不可用则立即返回。
锁中断
:可以中断一个正在等候锁的线程。
锁条件
:可以与条件变量一起利用,实现更复杂的同步机制。
2. 常用的 Lock 实现
ReentrantLock
:最常用的 Lock 实现,支持可重入锁、非公平锁 / 公平锁。它不仅拥有与 synchronized 雷同的并发性和内存语义,而且可以
显式地加锁、释放锁
。
ReentrantReadWriteLock
:读写锁,允许多个读取者同时访问资源,但只允许一个写入者独占访问资源。
利用方法
定义 Look 锁,加锁和解锁。
需要用到 try-catch-finally 代码块。
// 定义 look 锁
private final ReentrantLock lock = new ReentrantLock();
try {
// 加锁
lock.lock();
{
// 不安全代码块
}
} finally {
// 解锁
lock.unlock();
}
复制代码
3. 案例分析
给不安全的买票案例加上 Lock 锁,实现线程同步。
public class TestLock {
public static void main(String[] args) {
// 注意要给同一个对象创建多线程,不同对象用的资源可能不是同一份
TestLock2 testLock2 = new TestLock2();
for (int i = 0; i < 3; i++) {
new Thread(testLock2).start();
}
}
}
// 依旧是买票的例子
class TestLock2 implements Runnable {
int ticketNums = 10;
// 定义 look 锁
private final ReentrantLock lock = new ReentrantLock();
@Override
public void run() {
while (true) {
try {
// 加锁
lock.lock();
if (ticketNums > 0) {
System.out.println(ticketNums--);
Thread.sleep(1000);
} else {
break;
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 解锁
lock.unlock();
}
}
}
}
复制代码
4. Synchronized / Lock
Lock 是
显式锁
,需要手动开启和关闭(别忘记关锁);synchronized 是
隐式锁
,出了作用域主动释放。
Lock 只有代码块锁;synchronized 有代码块锁和方法锁。
利用 Lock 锁,JVM 将耗费较少的时间来调度线程,性能更好。且 Lock 提供更多的子类,扩展性更好。
优先利用顺序:Lock > 同步代码块 > 同步方法
同步代码块(已经进入了方法体,分配了相应资源)
同步方法(在方法体之外)
六、死锁
死锁(Deadlock)是指两个或多个线程在实行过程中由于竞争资源而造成的一种僵局,这些线程
都在等候对方释放资源
,结果导致所有涉及的线程都无法继承实行。
死锁是多线程编程中常见的问题之一,严峻影响程序的性能和可靠性。
1. 四个必要条件
发存亡锁需要同时满意四个条件,即
死锁发生的四个必要条件
:
互斥条件(Mutual Exclusion)
:资源不能被多个线程同时共享,即一个资源只能被一个线程占用。
占据并等候条件(Hold and Wait)
:一个线程已经持有一个资源,同时又申请新的资源,但新的资源已经被其他线程占用。
不可抢占条件(No Preemption)
:已经分配给线程的资源不能被强制释放,只能由占用它的线程自行释放。
循环等候条件(Circular Wait)
:存在一个线程等候链,形成一个闭环,即每个线程都在等候下一个线程持有的资源。
2. 案例分析
某一个同步块同时拥有两个以上对象的锁时,就有大概发存亡锁问题。
比方,模拟一下化妆过程,假设有两个线程
灰姑凉和白雪公主
,分别需要访问两个资源
口红和镜子
。
灰姑凉先获取了口红的锁,白雪公主先获取了镜子的锁。
然后灰姑凉在持有口红的同时,尝试获取镜子的锁;白雪公主在持有镜子的同时,尝试获取口红的锁。
结果两个线程都在等候对方释放资源,形成了死锁。
// 死锁:多个线程抱着对方需要的资源,然后形成僵持
public class DeadlockExample2 {
public static void main(String[] args) {
Makeup girl1 = new Makeup(0, "灰姑凉");
Makeup girl2 = new Makeup(1, "白雪公主");
girl1.start();
girl2.start();
}
}
// 1. 口红类
class Lipstick {
}
// 2. 镜子类
class Mirror {
}
// 3. 化妆类
class Makeup extends Thread {
// 资源只有一份,用 static 来保证只有一份
static Lipstick lipstick = new Lipstick();
static Mirror mirror = new Mirror();
// 选择
int choice;
// String girlName;
Makeup(int choice, String girlName) {
// 使用化妆品的人
super(girlName);
this.choice = choice;
}
@Override
public void run() {
try {
makeup();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 化妆方法,互相持有对方的锁,就是要拿到对方的资源
void makeup() throws InterruptedException {
if (choice == 0) {
synchronized (lipstick) {
System.out.println(this.getName() + "获得了口红的锁");
Thread.sleep(1000);
System.out.println(this.getName() + "等待镜子的锁......");
synchronized (mirror) {
System.out.println(this.getName() + "获得了镜子的锁");
}
}
} else {
synchronized (mirror) {
System.out.println(this.getName() + "获得了镜子的锁");
Thread.sleep(2000);
System.out.println(this.getName() + "等待口红的锁......");
synchronized (lipstick) {
System.out.println(this.getName() + "获得了口红的锁");
}
}
}
}
}
复制代码
观察测试结果:
两个线程各自持有一把锁;
两个线程都在等候对方持有的锁;
发生了死锁。
灰姑凉获得了口红的锁
白雪公主获得了镜子的锁
灰姑凉等待镜子的锁......
白雪公主等待口红的锁......
复制代码
3. 克制死锁的策略
只要粉碎四个必要条件中的任意一个或多个,就能克制死锁发生。
粉碎互斥条件
:尽量淘汰资源的互斥访问,利用不可变对象或线程安全的数据结构。
粉碎占据并等候条件
:要求线程在申请新资源之前释放已持有的资源。
粉碎不可抢占条件
:允许强制释放资源,但这通常很难实现。
粉碎循环等候条件
:对资源举行编号,要求线程按照编号顺序申请资源。
比方,我们对上述死锁案例举行修改,粉碎占据并等候条件,要求线程在申请新资源之前释放已持有的资源。
当灰姑凉释放了口红锁之后,才能哀求镜子的锁;当白雪公主释放了镜子锁之后,才能哀求口红的锁。
// 当灰姑凉释放了口红锁之后,才能请求镜子的锁;当白雪公主释放了镜子锁之后,才能请求口红的锁。
void makeup() throws InterruptedException {
if (choice == 0) {
synchronized (lipstick) {
System.out.println(this.getName() + "获得了口红的锁");
Thread.sleep(1000);
System.out.println(this.getName() + "等待镜子的锁......");
}
synchronized (mirror) {
System.out.println(this.getName() + "获得了镜子的锁");
}
} else {
synchronized (mirror) {
System.out.println(this.getName() + "获得了镜子的锁");
Thread.sleep(2000);
System.out.println(this.getName() + "等待口红的锁......");
}
synchronized (lipstick) {
System.out.println(this.getName() + "获得了口红的锁");
}
}
}
复制代码
结果如下,克制了死锁。
灰姑凉获得了口红的锁
白雪公主获得了镜子的锁
灰姑凉等待镜子的锁......
白雪公主等待口红的锁......
白雪公主得到了口红的锁灰姑凉得到了镜子的锁
复制代码
参考资料
狂神说 Java 多线程:https://www.bilibili.com/video/BV1V4411p7EF
TIOBE 编程语言走势: https://www.tiobe.com/tiobe-index/
Typora 官网:https://www.typoraio.cn/
Oracle 官网:https://www.oracle.com/
Notepad++ 下载地址:https://notepad-plus.en.softonic.com/
IDEA 官网:https://www.jetbrains.com.cn/idea/
Java 开辟手册:https://developer.aliyun.com/ebook/394
Java 8 帮助文档:https://docs.oracle.com/javase/8/docs/api/
MVN 堆栈:https://mvnrepository.com/
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/)
Powered by Discuz! X3.4