文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

万字图解工作面试必备,Java线程安全问题和解决方案

2024-11-30 17:56

关注

文章涵盖广而全,对工作和面试都有很大帮助,值得收藏认真阅读,不错的话记得点赞,关注支持哦!

线程运行机制

一旦调用start方法,线程处于runnable状态【可运行状态】。也就是可能正在运行也可能没有运行,这取决于操作系统给线程提供运行的时间。(Java的规范说明没有将它作为一个单独状态。一个正在运行中的线程仍然处于可运行状态。)

一旦一个线程开始运行,它不必始终保持运行。运行中的线程被中断,目的是为了让其他线程获得运行机会。线程调度的细节依赖于操作系统提供的服务。抢占式调度系统给每一个可运行线程一个时间片来执行任务。当时间片用完,操作系统剥夺该线程的运行权,并给另一个线程运行机会。当选择下一个线程时,操作系统考虑线程的优先级。

现在所有的桌面以及服务器操作系统都是用抢占式调度。但是,像手机这样的小型设备可能使用协作式调度,在这样的设备中,一个线程只有调用yield方法,或者被阻塞或等待时,线程才失去控制权。

在具有多个处理器的机器上,每一个处理器运行一个线程,可以有多个线程并行运行。当然,如果线程的数目多于处理器的数目,调度器依然采用时间片机制。

记住,在任何给定时刻,一个可运行的线程可能正在运行也可能没有运行(这就是为什么将这个状态称为可运行而不是运行)

Java的多线程可以充分利用CPU资源提高计算速度和处理后台任务等,而且线程之间的运行机制是抢占式,或者说是随机的,这就会导致多个线程对共享数据操作时可能出现错误的结果,对于多线程并发时会使程序出现bug的代码称作线程不安全的代码,这就是线程安全问题

存在线程安全问题程序

比如公司研发了一款手机,提供两个售货渠道卖10部手机,每一个线程就是一个售货渠道,当然最多只能卖出10部不能超卖

public class GoodsMain {
// 10部手机
private static int stocks = 10;
public static void main(String[] args) {
// 开启线程购买
new Thread(() -> {
// 判断,如果大于0就还可以继续售卖
while(stocks > 0) {
// 输出那个线程卖出第几部手机,并 -1
System.out.println(Thread.currentThread().getName() + "卖出第" + stocks--);
}
},"售卖渠道1").start();
new Thread(() -> {
while(stocks > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + stocks--);
}
},"售卖渠道2").start();
}
}

打印结果:

运行结果发现出现了0号手机,卖出了11部,明显是有问题的。你也可以试着运行,每次的运行结果不一样,而且出现这种BUG也是随机的,你可能运行十几二十次都不会出现这问题

问题分析

宏观分析

要明确一个前提是只有得到CPU的时间片线程才会被执行,而且CPU不保障一次将线程执行完,也就是说,CPU会在线程之间切换执行,上述例子出现超卖的原因也在这里

微观指令集层面分析

上边我们在操作共享变量stocks时使用了stocks--这样的语法,自减操作也有大学问

stocks--:会对变量进行-1操作,--在变量之后,所以是后--,意思是如果变量参与了运算,则先完成运算再进行-1操作,比如上述例子与字符串进行相加运算,所以stocks变量会先于字符串完成拼接输出数据之后再对变量进行 -1 操作

而且程序运行时需要交给CPU执行,系统在执行运算时会将代码转换为指令集进行运算,在指令集方面,stocks--这样的一个自减操作会被分成三个指令操作:

情况1:线程之间指令集无交叉,运行结果与预期相同,线程1从内存加载值,运算之后再将值存进内存,线程2获取值,发现值为0,while判断不成立

情况2:线程之间指令集存在交叉,结果可能存在问题,指令交叉计算后得知没有及时刷新进内存,导致另外的线程获取到的是旧值,就会存在少减情况

情况3:指令完全交叉,现象与情况2一样,出现库存少减现象

根据上边的几种情况分析,发现线程运行时没有出现指令交叉结果是预期的,如果出现指令交叉就会存在库存少减现象,是因为自减操作不是原子的是可以再分割的,线程之间独立,线程内计算的值并没有直接刷新进内存,导致别的线程并不会得到最新的数据,多线程并发执行时很可能出现指令交叉,导致线程安全问题,出现错误结果。

解决上述线程不安全问题,我们常用的方法就是加锁

什么是加锁

在Java多线程中,当两个或以上线程对同一数据进行操作时,就会产生【竞争条件】的现象,这种现象产生的根本原因是因为多个线程在对同一个数据进行操作,此时对该数据的操作是非“原子化”的,可能前一个线程对数据的操作还没有结束,后一个线程又开始对同样的数据开始进行操作,这就可能会造成数据结果的变化未知。

为了解决由于【抢占式执行】导致的线程安全问题,我们可以对共享数据进行加锁,可以理解为给多线程操作的共享数据设置一个操作权限,谁拿到这个锁,谁就有权利操作共享数据,当一个线程拿到共享数据的锁后,就会把共享数据锁起来,其他线程如果也要操作这个共享数据,需要等待已经获取到锁的线程执行完之后释放锁,其他拿个线程得到这个锁,谁就可以操作共享数据。

举个例子:有一家饭店的包间非常不错,很多人都想在包间中就餐。当包间被顾客预定之后就相当于被上了锁,其他顾客必须等待上一个顾客享用完服务之后才可再预定使用,预定到的就会再次对包间上锁,其他顾客无法享用这个包间。这样就不会乱糟糟的了是吧,不然就跟没有秩序一样,谁都可以进包间里边就会发生冲突。这里的顾客就是一个一个的线程,这里的包间就是共享数据,预定包间成功就相当于加的锁

当你使用完之后,释放锁,其他线程竞争锁,当一个线程抢到锁之后,就会进入套房享用服务

当然,如果世界上只有一个客户,也就是只有一个线程就不需要加锁了,对吧!

如何加锁

Java中最常见的是使用 synchronized 加锁。synchronized 是互斥锁,有互斥效果,即同一时刻只能有一个线程操作共享数据,某个线程执行到 synchronized 中时, 其他线程如果也执行这块代码,就会阻塞等待。线程进入 synchronized 修饰的代码块, 相当于 加锁,退出 synchronized 修饰的代码块, 相当于 解锁

加锁也可以称为线程同步,同步也好理解,就是一个一个来嘛

方式1:使用synchronized关键字修饰方法,这样会使方法所在的对象加上一把锁

实现类:

public class Goods implements Runnable{
private static int stocks = 10;

@Override
public void run() {
// 调用卖手机方法
sellMobile();
}
// 共享方法
public synchronized void sellMobile() {
while(stocks > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + stocks-- + "部手机");
}
}
}

测试类:

public class GoodsMain {

public static void main(String[] args) {
// 1、创建Runnable实现类对象
Goods goods = new Goods();

// 开启线程购买
Thread t1 = new Thread(goods, "售卖窗口1");
Thread t2 = new Thread(goods, "售卖窗口2");

t1.start();
t2.start();
}
}

上边的代码可以解决线程安全问题,但是因为while条件中直接判断的共享资源,所以将while直接锁进嘞小房间,所以所有的手机都会被同一个线程售出,比如:线程1获取到锁资源后上锁,进入while循环,沉迷其中不可自拔,一口气消费完才释放锁。我们可以通过以下代码优化,实现线程交替运行:

定义 flag 变量标记是否还有库存,while循环判断库存标记,这样可以保障当线程1判断while之后挂起还没有调用售卖方法时仍然可能丢失CPU执行权,切换到其他线程执行。

public class Goods implements Runnable{
private static int stocks = 10;
// 是否卖完
private static boolean flag = true;
@Override
public void run() {
// 循环调用
while(flag) {
// 调用售卖方法,在调用该方法时可能出现线程切换
sellMobile();
}
}
// 同步方法,进入到该方法中就需要等待该线程执行完所有的操作还可能切换线程
public synchronized void sellMobile() {
// 判断是否卖完
if(stocks > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + stocks-- + "部手机");
}else {
flag = false;
}
}
}

运行结果:在运行结果截图中,发现写了一个sleep方法,这是为了让线程进入超时等待可以释放CPU执行权,来达到切换线程的目的,实际开发中是不会使用sleep方法的,所以上边贴出的代码中并没有sleep方法调用

为可看出效果,也可以将库存调为10万台,有充分的资源支撑线程切换,可以看出下图同样线程1和线程2之间切换,并且没有出现超卖现象

方式2:使用synchronized关键字对代码段进行加锁,但是需要显式指定加锁的对象。

public class Goods implements Runnable{
private static int stocks = 10;
private static boolean flag = true;
@Override
public void run() {
while(flag) {
// 加锁
synchronized (this) {
if(stocks > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + stocks-- + "部手机");
}else {
flag = false;
}
}
}
}
}

运行结果:

方式3:使用synchronized关键字修饰静态方法,相当于对当前类的类对象进行加锁

public class GoodsMain {

private static int stocks = 10;
private static boolean flag = true;

public static void main(String[] args) {
// 开启线程1
new Thread(() -> {
while (flag) {
sellMpbile();
}
},"售卖窗口1").start();
// 开启线程2
new Thread(() -> {
while (flag) {
sellMpbile();
}
},"售卖窗口2").start();
}
// 静态方法,售卖手机
public synchronized static void sellMpbile() {
if(stocks > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + stocks-- + "部手机");
}else {
flag = false;
}
}
}

常见的用法差不多就是这些,对于线程加锁(线程拿锁),如果两个线程同时拿一个对象的锁,就会产生锁竞争,两个线程同时拿两个不同对象的锁不会产生锁竞争。 对于synchronized这个关键字,它的英文意思是同步,但是同步在计算机中是存在多种意思的,比如在多线程中,这里同步的意思是“互斥”;而在IO或网络编程中同步则是另一个意思

三种方式锁对象区别:

synchronized 的工作过程:

  1. 获得互斥锁lock
  2. 从主内存拷贝变量的最新副本到工作的内存
  3. 执行代码
  4. 将更改后的共享变量的值刷新到主内存
  5. 释放互斥锁unlock

综上,synchronized关键字加锁有如下性质:互斥性,刷新内存性,可重入性。

所谓可重入,即一个线程已经获得了某个锁,当这个线程要再次获取这个锁时,依然可以获取成功,不会发生死锁的情况。synchronized就是一个可重入锁。

可重入的条件

可重入与线程安全

一般而言,可重入的函数一定是线程安全的,反之则不一定成立。在不加锁的前提下,如果一个函数用到了全局或静态变量,那么它不是线程安全的,也不是可重入的。如果我们加以改进,对全局变量的访问加锁,此时它是线程安全的但不是可重入的,因为通常的加锁方式是针对不同线程的访问(如Java的synchronized),当同一个线程多次访问就会出现问题。只有当函数满足可重入的四条条件时,才是可重入的

synchronized是可重入锁

从设计上讲,当一个线程请求一个由其他线程持有的对象锁时,该线程会阻塞。当线程请求自己持有的对象锁时,如果该线程是重入锁,请求就会成功,否则阻塞。

我们回来看synchronized,synchronized拥有强制原子性的内部锁机制,是一个可重入锁。因此,在一个线程使用synchronized方法时调用该对象另一个synchronized方法,即一个线程得到一个对象锁后再次请求该对象锁,是永远可以拿到锁的。

在Java内部,同一个线程调用自己类中其他synchronized方法/块时不会阻碍该线程的执行,同一个线程对同一个对象锁是可重入的,同一个线程可以获取同一把锁多次,也就是可以多次重入。原因是Java中线程获得对象锁的操作是以线程为单位的,而不是以调用为单位的。

synchronized可重入锁的实现

每个锁关联一个线程持有者和一个计数器。当计数器为0时表示该锁没有被任何线程持有,那么任何线程都都可能获得该锁而调用相应方法。当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个synchronized方法/块时,计数器会递减,如果计数器为0则释放该锁。

加锁后分析

当下成1获取到锁对象之后就会将共享资源锁起来【lock】,当线程1处理完之后释放锁【unlock】,其他线程来竞争这把锁,谁得到锁谁就将资源锁住【lock】,依次释放和获得锁,没有获取到锁的线程就会进入阻塞状态

加锁后线程就是串行执行,与单线程其实没有很大的区别,那多线程是不是没有用了呢?但是对方法加锁后,线程运行该方法才会加锁,运行完该方法就会自动解锁,况且大部分操作并发执行是不会造成线程安全的,只有少部分的修改操作才会有可能导致线程安全问题,因此整体上多线程运行效率还是比单线程高得多。

ReentrantLock可重入锁

从JDK5.0开始,Java提供了更强大的线程同步机制——通过显示定义同步锁对象来实现同步。同步锁使用Lock对象充当。该锁对象在Java的JUC包中

java.util.concurrent.locks.Lock接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问。每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象

ReentrantLock类实现了Lock,ta拥有与synchronized 相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是ReentrantLock,从名字上可以看出该所对象是可重入锁,可以显式加锁,释放锁

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Goods implements Runnable {
private int stocks = 1000;
private boolean flag = true;
// 创建可重入锁对象
private final Lock lock = new ReentrantLock();

@Override
public void run() {
while (flag) {
try {
// 加锁
lock.lock();
if (stocks > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + stocks-- + "部手机");
} else {
flag = false;
}
} catch (Exception e) {
System.out.println("发生异常:" + e);
} finally {
// 在finally中 解锁,避免线程意外终止没有解锁造成死锁
lock.unlock();
}
}
}
}

使用 ReentrantLock 的时候,建议把 Lock 和 方法体 放在 try{} 代码块中,然后释放锁 unlock() 放在 finally{} 代码块中保证锁释放成功~,如果线程发生异常意外终止,锁没有释放成功,别的线程也获取不到锁,就会出现死锁,也就是谁都拿不到锁,谁都运行不了程序

synchronized和Lock加锁区别

Lock锁

以下是Lock接口的源码,简单翻译如下:

public interface Lock {


void lock();


void lockInterruptibly() throws InterruptedException;


boolean tryLock();


boolean tryLock(long time, TimeUnit unit) throws InterruptedException;


void unlock();

}

lock方法:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockTest {
// 创建可重入锁对象
private final Lock lock = new ReentrantLock();

// 需要同步方法
private void method(){
// 加锁
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "获得了锁");
}catch(Exception e){
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放了锁");
// 解锁
lock.unlock();
}
}

public static void main(String[] args) {
LockTest lockTest = new LockTest();
// 1、创建线程1
Thread t1 = new Thread(() -> {
lockTest.method();
},"线程1");
// 2、创建线程2
Thread t2 = new Thread(() -> {
lockTest.method();
},"线程2");
// 3、启动线程
t1.start();
t2.start();
}
}

运行结果:

tryLock方法:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockTest {
private final Lock lock = new ReentrantLock();

// 需要同步方法
private void method(){
// 尝试获取锁,并加锁
if(lock.tryLock()){
try {
System.out.println(Thread.currentThread().getName() + "获得了锁");
}catch(Exception e){
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放了锁");
// 解锁
lock.unlock();
}
}else{
System.out.println("我是【"+Thread.currentThread().getName()+"】有人占着锁,我就不要啦");
}
}

public static void main(String[] args) {
LockTest lockTest = new LockTest();
Thread t1 = new Thread(() -> {
lockTest.method();
},"线程1");

Thread t2 = new Thread(() -> {
lockTest.method();
},"线程2");

t1.start();
t2.start();
}
}

tryLock就是尝试获取锁,如果所被别的线程获取,则直接放弃获取,不阻塞,好比追一个小姐姐,人家有对象了,直接放弃,而lock则是等着【分手接盘】

而tryLock(long time, TimeUnit unit),则是锁被别的线程拿到,会等待指定时间,如果还没获取到就放弃,好比给小姐姐一段时间分手,如果没分就拉到,分了就接盘

运行结果:

ReentrantLock源码分析

下方代码是从JDK源码中摘录出来的,对部分代码做了注释,可以细品一下

ReentrantLock在创建对象时可以选择是否为公平锁,默认为非公平锁。面试时Java中的锁分类也是高频问点!在下边也为大家介绍到:


public ReentrantLock() {
sync = new NonfairSync();
}


public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}



static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;


final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}

protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}


static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;

final void lock() {
acquire(1);
}


protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}

锁分类

公平锁/非公平锁

可重入锁

private void method(){
// 获取this对象锁
synchronized (this) {
// 再次获取统一对象的锁,仍然可以
synchronized (this) {

}
}
}

独享锁/共享锁

互斥锁/读写锁

乐观锁/悲观锁

分段锁

偏向锁/轻量级锁/重量级锁

自旋锁

在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU

两种锁的底层实现方式

synchronized:Java是用字节码指令来控制程序(这里不包括热点代码编译成机器码)。在字节指令中,存在有synchronized所包含的代码块,那么会形成2段流程的执行

如下代码:

public class LockTest {

public void method(){
// 获取this对象锁
synchronized (this) {
System.out.println(Thread.currentThread().getName());
}
}
}

通过 javap -c LockTest.class 指令获取该类的class字节码数据如下:

如上就是这段代码段字节码指令,我们可以清晰段看到,其实synchronized映射成字节码指令就是增加来两个指令:monitorenter和monitorexit。当一条线程进行执行的遇到monitorenter指令的时候,它会去尝试获得锁,如果获得锁那么锁计数+1(因为它是一个可重入锁,所以需要用这个锁计数判断锁的情况),如果没有获得锁,那么阻塞。当它遇到monitorexit的时候,锁计数器-1,当计数器为0,那么就释放锁。

有的朋友看到这里就疑惑了,为什么有2个monitorexit呀?马上回答这个问题:synchronized锁释放有两种机制,一种就是执行完释放;另外一种就是发送异常,虚拟机释放。图中第二个monitorexit就是发生异常时执行的流程。而且,从图中我们也可以看到在第18行,有一个goto指令,也就是说如果正常运行结束会跳转到26行执行。

Lock:Lock实现和synchronized不一样,后者是一种悲观锁,它胆子很小,它很怕有人和它抢吃的,所以它每次吃东西前都把自己关起来。而Lock呢底层其实是CAS乐观锁的体现,它无所谓,别人抢了它吃的,它重新去拿吃的就好啦,所以它很乐观。具体底层怎么实现,如果面试问起,你就说底层主要靠volatile和CAS操作实现的。

尽可能去使用synchronized而不要去使用LOCK,jdk1.6~jdk1.7中对 synchronized 进行优化:

1、线程自旋和适应性自旋

Java线程其实是映射在内核之上,线程的挂起和恢复会极大的影响开销。并且jdk官方人员发现,很多线程在等待锁的时候,在很短的一段时间就获得了锁,所以它们在线程等待的时候,并不需要把线程挂起,而是让他无目的的循环,一般设置10次。这样就避免了线程切换的开销,极大的提升了性能。 而适应性自旋,是赋予了自旋一种学习能力,它并不固定自旋10次一下。它可以根据它前面线程的自旋情况,从而调整它的自旋,甚至是不经过自旋而直接挂起

2、锁消除【Lock Elimination】

锁消除就是把不必要的同步在编译阶段进行移除,惊讶!我自己写的代码我会不知道这里要不要加锁?需要你教我做事?我加了锁就是表示这边会有同步呀? 并不是这样,这里所说的锁消除并不一定指代是你写的代码的锁消除,而是根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁,我打一个比方: 在jdk1.5以前,我们的String字符串拼接操作其实底层是StringBuffer实现,而在jdk1.5之后,是用StringBuilder来拼接。我们考虑前面的情况,比如如下代码:

String str1="qwe";
String str2="asd";
String str3=str1+str2;

底层实现会变成这样:

StringBuffer sb = new StringBuffer();
sb.append("qwe");
sb.append("asd");

StringBuffer是一个线程安全的类,也就是说两个append方法都会同步,通过指针逃逸分析(就是变量不会外泄),我们发现在这段代码并不存在线程安全问题,这个时候就会把这个同步锁消除

3、锁粗化

在用synchronized的时候,我们都讲究为了避免大开销,尽量同步代码块要小。Hotspot 确实进行了锁粗化优化,可以有效合并几个相邻同步块,从而降低锁开销。能够把下面的代码

synchronized (obj) {  
// 语句 1
}
synchronized (obj) {
// 语句 2
}

转换为:

synchronized (obj) {  
// 语句 1
// 语句 2
}

Hotspot 能否对循环进行这种优化?例如,把

for (...) {  
synchronized (obj) {
// 一些操作
}
}

转换为

synchronized (this) {  
for (...) {
// 一些操作
}
}

理论上,没有什么能阻止我们这样做,甚至可以把这种优化看作只针对锁的优化,像 loop unswitching 一样。然而,缺点是可能把锁优化后变得过粗,线程在执行循环时会占据所有的锁

小贴士:Loop unswitching 是一种编译器优化技术。通过复制循环主体,在 if 和 else 语句中放一份循环体代码,实现将条件句的内部循环移到循环外部,进而提高循环的并行性。由于处理器可以快速运算矢量,因此执行速度得到提升。

4、轻量级锁和偏向锁

轻量级锁和偏向锁在上边锁分类中已经解释,不再复述,JDK将 synchronized 升级成了这两种特性的锁

总结

这里对Java多线程的运行机制,线程安全问题产生的原因和解决方案,锁分类,并对 synchronized 和 Lock的底层原理进行分析。多线程是一门比较深的学问,不同的场景使用方法都不同,但是本质几乎一样,如果您对本文有什么疑问或者问题欢迎在评论区指出。

Java的多线程仅仅是开始远没有结束,比如多线程的8锁问题,JUC中的原子类,volatile关键字,ThreadLocal,分布式锁,线程通信,JDK中各个线程安全类如何实现线程安全的等等都会陆续更新出来,欢迎持续关注!

文章出自:​石添的编程哲学​,如有转载本文请联系【石添的编程哲学】今日头条号。

来源:今日头条内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯