【JavaEE】线程安全性问题,线程不安全是怎么产生的,该如何应对

news2024/11/14 18:53:49

产生线程不安全的原因

在Java多线程编程中,线程不安全通常是由于多个线程同时访问共享资源而引发的竞争条件。以下是一些导致线程不安全的常见原因:

  1. 共享可变状态:当多个线程对共享的可变数据进行读写时,如果没有适当的同步机制,可能导致数据的不一致性。例如,两个线程同时修改一个共享变量,最终的结果可能取决于线程的执行顺序。

  2. 缺乏同步:在没有使用synchronized关键字或其他同步机制(如Lock)进行保护的情况下,多个线程可以同时进入临界区,从而导致线程安全问题。

  3. 指令重排序:为了提高执行效率,Java虚拟机和处理器可能会对指令进行重排序,这种行为在多线程环境中可能导致不可预期的结果,尤其是在多个线程依赖某些变量的状态时。

  4. 原子性问题:某些操作在Java中并不是原子的,例如对对象属性的读-改-写操作。在多线程环境下,这类操作必须通过同步处理以确保原子性。

  5. 死锁:尽管死锁本身不直接导致线程不安全,但在复杂的同步情况下,死锁可能导致某些线程无法继续执行,从而影响整体程序的正确性与稳定性。

  6. 不可见性:当一个线程对共享变量的修改在其他线程中不可见时,可能导致一些线程读取到过时的值。这通常可以通过使用volatile关键字来解决。

产生线程不安全的案例以及应对方法

共享可变状态案例

我们将创建一个简单的银行账户类,多个线程并发访问该账户进行存款和取款操作。假设我们有两个线程同时对账户进行操作,可能会出现余额计算错误的情况。

class BankAccount {
    private int balance = 100; // 初始余额为100

    public void deposit(int amount) {
        balance += amount; // 存款
    }

    public void withdraw(int amount) {
        balance -= amount; // 取款
    }

    public int getBalance() {
        return balance; // 返回当前余额
    }
}

public class UnsafeBank {
    public static void main(String[] args) {
        BankAccount account = new BankAccount();

        // 创建两个线程同时操作
        Thread t1 = new Thread(() -> {
            account.withdraw(50);
            System.out.println("Thread 1 withdrew 50, balance: " + account.getBalance());
        });

        Thread t2 = new Thread(() -> {
            account.deposit(30);
            System.out.println("Thread 2 deposited 30, balance: " + account.getBalance());
        });

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

运行情况: 

我们期望的运行结果是:取款50,余额50、存款30,余额80。但是上述结果并不是我们想要的

分析

在上述代码中,两个线程同时对balance变量进行操作,可能导致不一致的余额输出。例如,假设Thread 1先读取了余额为100,然后进行了取款操作,但在它更新余额之前,Thread 2可能已经读取了余额并进行了存款操作。最终的结果可能不符合预期。

解决方法

为了解决这个线程不安全的问题,我们可以使用synchronized关键字来确保对共享资源的访问是线程安全的。我们可以对depositwithdraw方法加锁,使得同一时间只有一个线程能够执行其中一个方法。

以下是修改后的代码:

class BankAccount {
    private int balance = 100; // 初始余额为100

    // 存款操作
    public synchronized void deposit(int amount) {
        balance += amount; // 存款
    }

    // 取款操作
    public synchronized void withdraw(int amount) {
        balance -= amount; // 取款
    }

    // 返回当前余额
    public int getBalance() {
        return balance; // 返回当前余额
    }
}

public class SafeBank {
    public static void main(String[] args) throws InterruptedException {
        BankAccount account = new BankAccount();

        // 创建两个线程同时操作
        Thread t1 = new Thread(() -> {
            account.withdraw(50);
            System.out.println("Thread 1 withdrew 50, balance: " + account.getBalance());
        });

        Thread t2 = new Thread(() -> {
            account.deposit(30);
            System.out.println("Thread 2 deposited 30, balance: " + account.getBalance());
        });

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

        // 等待两个线程结束
        t1.join();
        t2.join();
        
        // 输出最终余额
        System.out.println("Final balance: " + account.getBalance());
    }
}

结果

在修改后的代码中,由于对depositwithdraw方法加了synchronized修饰,确保任何时刻只有一个线程可以执行这两个方法,从而避免了由于竞争条件导致的不一致性。最终输出的余额将与预期结果相一致。

指令重排序案例

指令重排序是指在编译、优化或CPU执行过程中,代码的执行顺序被改变。

count++ 操作并不是一个原子操作,它是由三个步骤组成的:

  1. 读取当前的值。
  2. 对值加1。
  3. 将新值写回。

在多线程环境中,多个线程可能会同时对同一变量进行 count++ 操作,导致结果不正确。这种情况下,指令重排序可能导致某些操作无法达到预期结果。

以下是一个示例代码,演示了这个问题:

class Counter {
    private int count = 0;

    public void increment() {
        count++; // 不安全的操作
    }

    public int getCount() {
        return count;
    }
}

public class CountExample {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        
        Thread[] threads = new Thread[10];
        
        // 创建10个线程
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter.increment(); // 增加计数
                }
            });
        }
        
        // 启动所有线程
        for (Thread thread : threads) {
            thread.start();
        }

        // 等待所有线程结束
        for (Thread thread : threads) {
            thread.join();
        }

        // 输出最终计数
        System.out.println("Final count: " + counter.getCount());
    }
}

运行结果: 

我们的预期结果是:10000

分析

在上述代码中,我们创建了10个线程,每个线程执行1000次 increment() 方法,从而期望最终的计数是10000。然而,由于 count++ 操作的非原子性,在多个线程并发执行时,可能会导致某些增量操作丢失,最终结果可能小于10000。

解决方法

为了解决这个问题,可以使用以下几种方法:

  1. 使用synchronized关键字:将increment方法同步,以确保同一时刻只有一个线程能执行该操作。

  2. **使用AtomicInteger**:Java提供了原子类AtomicInteger,能够保证对整数操作的原子性。

我们将采用第二种方法,即使用 AtomicInteger 来解决这个问题。

以下是修改后的代码:

import java.util.concurrent.atomic.AtomicInteger;

class Counter {
    private AtomicInteger count = new AtomicInteger(0); // 使用AtomicInteger

    public void increment() {
        count.incrementAndGet(); // 原子性增加
    }

    public int getCount() {
        return count.get(); // 获取当前值
    }
}

public class SafeCountExample {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        
        Thread[] threads = new Thread[10];
        
        // 创建10个线程
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter.increment(); // 增加计数
                }
            });
        }
        
        // 启动所有线程
        for (Thread thread : threads) {
            thread.start();
        }

        // 等待所有线程结束
        for (Thread thread : threads) {
            thread.join();
        }

        // 输出最终计数
        System.out.println("Final count: " + counter.getCount());
    }
}

不可见性案例 

我们使用了两个线程 t1 和 t2。线程 t1 负责不停地检查一个共享变量 fag,而线程 t2 则在休眠1秒后将 fag 设为1。

public class Main {
    public static int fag = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (fag == 0) {

            }
        });

        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            fag = 1;
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("主线程结束");
    }
}

 分析

在Java中,fag 是一个共享的静态变量,初始值为0。线程 t1 在一个循环中不断检查 fag 的值,而线程 t2 在休眠1秒后将 fag 更新为1。根据Java内存模型的规定,线程可以在运行过程中缓存某些变量,以提高性能。这意味着,线程 t1 可能在自己的工作内存中读取到fag的值,并且不会每次都去主内存中检查当其值变化时。

因此,虽然 t2 可能已经将 fag 设置为1,但如果 t1 线程没有看到这个变化,它仍然可能会在其循环中继续查看到 fag 为0,导致 t1 线程陷入死循环,程序执行不会继续下去。

解决方法

为了解决这个线程不可见性的问题,可以使用以下两种常见方法:

  1. 使用 volatile 关键字:将 fag 声明为 volatile,这样可以确保任何线程对 fag 的写入都会立即对其他线程可见。
  2. 使用同步机制:使用 synchronized 关键字来确保对 fag 的读取和写入操作是安全的。

在这里,我们选择使用 volatile 关键字来解决这个问题。

public class Main {
    public static volatile int fag = 0; // 使用volatile关键字
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (fag == 0) {
                // Busy wait: 这里循环等待fag变为1
            }
        });

        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            fag = 1; // 将fag设置为1
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("主线程结束");
    }
}

结果

通过将 fag 声明为 volatile,确保了对该变量的写入会使得线程 t1 线程能看到 fag 的最新值。即使线程 t2 在将 fag 改为1后,其他线程(如 t1)也能及时看到这一变化,而不会出现不可见性的问题,从而避免了 t1 进入死循环的情况。

在程序运行结束后,您将看到"主线程结束"的输出,表明所有线程都能正常结束。使用 volatile 关键字有效地解决了线程间的可见性问题。

 死锁

在多线程编程中,死锁是一种非常严重的问题,它会导致程序无法继续执行。产生死锁的典型条件通常可以归纳为以下四个必要条件:

  1. 互斥条件:至少有一个资源必须被一个线程持有,并且在该资源被其他线程请求时,该线程不能被剥夺,即资源只能被一个线程使用。

  2. 保持并等待条件:一个线程至少持有一个资源,并且正在等待获取其他资源。在这个状态下,线程不会释放它已持有的资源。

  3. 不剥夺条件:一旦资源被分配给某个线程,其他线程不能强制剥夺该资源,只有线程在完成其任务后才能释放它所持有的资源。

  4. 循环等待条件:存在一个线程集合 {T1, T2, ..., Tn},其中 T1 等待 T2 持有的资源,T2 等待 T3 持有的资源,以此类推,直至 Tn 等待 T1 持有的资源。形成一种循环等待的关系。

死锁案例

假设有两个线程,线程A和线程B,它们分别需要获取两个锁,锁1和锁2。以下是代码示例:

class Lock {
    private final String name;

    public Lock(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

public class DeadlockExample {
    private static final Lock lock1 = new Lock("Lock1");
    private static final Lock lock2 = new Lock("Lock2");

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread A: Holding lock 1...");
                
                // Simulate some work
                try { Thread.sleep(100); } catch (InterruptedException e) {}

                System.out.println("Thread A: Waiting for lock 2...");
                synchronized (lock2) {
                    System.out.println("Thread A: Acquired lock 2!");
                }
            }
        });

        Thread threadB = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread B: Holding lock 2...");
                
                // Simulate some work
                try { Thread.sleep(100); } catch (InterruptedException e) {}

                System.out.println("Thread B: Waiting for lock 1...");
                synchronized (lock1) {
                    System.out.println("Thread B: Acquired lock 1!");
                }
            }
        });

        threadA.start();
        threadB.start();
    }
}

分析

在上面的代码中,线程A首先持有锁1,然后尝试去获取锁2。同时,线程B首先持有锁2,之后尝试获取锁1。这样就形成了循环等待,导致两个线程相互阻塞,从而发生死锁。

解决方法

为了避免这种死锁情况,可以使用以下解决方案:

  1. 按照固定顺序获取锁: 我们可以定义一个顺序,确保所有线程都按照相同的顺序获取锁,从而避免循环等待。
public class DeadlockPrevention {
    private static final Lock lock1 = new Lock("Lock1");
    private static final Lock lock2 = new Lock("Lock2");

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            Lock firstLock = lock1;
            Lock secondLock = lock2;
            acquireLocks(firstLock, secondLock);
        });

        Thread threadB = new Thread(() -> {
            Lock firstLock = lock1;
            Lock secondLock = lock2;
            acquireLocks(firstLock, secondLock);
        });

        threadA.start();
        threadB.start();
    }

    private static void acquireLocks(Lock firstLock, Lock secondLock) {
        synchronized (firstLock) {
            System.out.println(Thread.currentThread().getName() + ": Holding " + firstLock.getName() + "...");

            // Simulate some work
            try { Thread.sleep(100); } catch (InterruptedException e) {}

            synchronized (secondLock) {
                System.out.println(Thread.currentThread().getName() + ": Acquired " + secondLock.getName() + "!");
            }
        }
    }
}

在这个示例中,无论线程A还是线程B,都会按照同样的顺序(首先获取lock1,然后是lock2)来请求锁,由此避免了死锁情况的发生。

通过这些方法,可以有效减少多线程程序中的死锁风险,保证程序的稳定性。

为了有效避免死锁,可以考虑以下策略:

  • 资源有序分配:为所有资源定义一个全局的获取顺序,线程在请求资源时,按照这个顺序获取,从而避免循环等待的情况。

  • 使用超时机制:在尝试获取锁时,可以设定一个超时时间,若超时则放弃锁的请求,减少潜在的死锁情况。

  • 避免保持并等待:可以在开始线程时一次性请求所有所需资源,成功则继续执行,失败则释放所有已获得的资源。

  • 检测与恢复:定期检查系统中是否存在死锁,如果发现可以中断某些线程或者释放某些资源来解除死锁。

通过合理的设计与计划,可以有效减少死锁的可能性,提高系统的稳定性和可靠性。

wait 和 notify 的使用

在Java中,wait 和 notify 是用于线程间通信的重要方法。这些方法被定义在 Object 类中,因此所有的对象都可以利用这些方法进行线程协调。它们通常用于同步块(synchronized block)中,以实现线程的等待和通知机制。

wait 方法

wait() 方法使当前线程等待,直到其他线程调用 notify() 或 notifyAll() 方法来唤醒它。调用 wait() 方法的线程会释放持有的对象锁。

notify 方法

notify() 方法用于唤醒一个正在等待该对象监视器的线程。如果有多个线程在等待,则其中一个线程会被唤醒,具体被哪个线程唤醒是不确定的。使用 notifyAll() 可以唤醒所有在等待该对象监视器的线程。

使用示例

下面是一个简单的示例,展示了如何使用 wait 和 notify 来实现生产者-消费者模式。

class SharedResource {
    private int data;
    private boolean available = false;

    // 生产者方法
    public synchronized void produce(int value) throws InterruptedException {
        while (available) {
            wait(); // 如果数据可用,生产者就等待
        }
        data = value;
        available = true;
        System.out.println("Produced: " + value);
        notify(); // 通知消费者数据可用
    }

    // 消费者方法
    public synchronized int consume() throws InterruptedException {
        while (!available) {
            wait(); // 如果没有数据可用,消费者就等待
        }
        available = false;
        System.out.println("Consumed: " + data);
        notify(); // 通知生产者可以生产新数据
        return data;
    }
}

class Producer extends Thread {
    private SharedResource resource;

    public Producer(SharedResource resource) {
        this.resource = resource;
    }

    public void run() {
        try {
            for (int i = 0; i < 5; i++) {
                resource.produce(i);
                Thread.sleep(1000); // 暂停一秒
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

class Consumer extends Thread {
    private SharedResource resource;

    public Consumer(SharedResource resource) {
        this.resource = resource;
    }

    public void run() {
        try {
            for (int i = 0; i < 5; i++) {
                resource.consume();
                Thread.sleep(1500); // 暂停一秒半
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

public class Main {
    public static void main(String[] args) {
        SharedResource resource = new SharedResource();
        Producer producer = new Producer(resource);
        Consumer consumer = new Consumer(resource);

        producer.start();
        consumer.start();
    }
}

代码说明

  1. SharedResource:这是共享资源类,其中包含一个数据字段和一个标志,表示数据是否可用。
  2. **produce() 和 consume()**:生产者和消费者的方法,使用 wait() 和 notify() 进行协调。
  3. Producer 和 Consumer 类:分别代表生产者和消费者线程,调用各自的方法以执行生产或消费操作。
  4. Main 类:创建共享资源和启动生产者和消费者线程。

通过这种方式,生产者和消费者可以高效地协同工作,避免数据竞争和不必要的轮询。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2068654.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

目标 CDC实例数据库更改密码,预定启动报错SQL 错误代码为“-30082”。SQL 状态为:08001。

更改完CDC目标端实例密码后&#xff0c;登录MC更新存储器密码&#xff0c;存储器可正常连接&#xff0c;启动预定报错如下&#xff1a; 源 IBM Data Replication 未获授权&#xff0c;无法复制到该目标。 登录认证失败。 发生 SQL 异常。SQL 错误代码为“-30082”。SQL 状态…

区块链知识体系fisco-bcos实战

文章目录 一、区块链发展概述及类型和特征1.1 区块链的概念1.2 区块链的起源1.3 区块链的发展历程1.4 区块链的类型和特征 二、区块链的常见技术架构2.1 技术架构2.2 核心技术 三、区块链的常见应用3.1 生态环境监测3.2 医疗废弃物追踪解决3.3 区块链在电子政务领域的应用3.4 在…

前端宝典十三:node网络详解Tcp/IP/Http及网络安全防御

讨论网络相关的问题前&#xff0c;我们首先看一下从浏览器输入 URL 到显示前端页面的流程&#xff0c;首先从TCP的应用层、传输层、网络层、数据链路层开始看&#xff1a; 一、应用层、传输层、网络层、数据链路层 以下是从浏览器输入 URL 到显示前端页面的流程顺序解析&…

android 实现简易音乐播放器

音乐App 源代码 &#xff1a; 简易音乐APP源代码 1、简介 一个简易的音乐APP&#xff0c;主要练习对四大组件的应用。感兴趣的可以看看。 播放界面如下&#xff1a; 歌曲列表界面如下&#xff1a; 项目结构如下&#xff1a; 接下来将对代码做详细介绍&#xff1a; 2、Musi…

class_3:数据类型/交互模式/ input输入

获取字符串长度 #获取字符串长度 s "hello world!" print(len(s)) print(len("hello")) #根据索引取字符 print(s[0]) print(s[11]) #type a 10 b True c 1.0 d None #空值类型 print(type(a)) print(type(b)) print(type(c)) print(type(d))命令行模…

大型物流运输无人机技术详解

随着科技的不断进步和电子商务的蓬勃发展&#xff0c;大型物流运输无人机作为未来物流体系的重要组成部分&#xff0c;正逐步从概念走向实际应用。这类无人机以其超大的载重能力、高效的运输效率和广泛的覆盖范围&#xff0c;为解决远距离、大批量货物运输难题提供了创新方案。…

【Datawhale AI夏令营第五期】 CV方向 Task01学习笔记 YOLO方案baseline

【Datawhale AI夏令营第五期】 CV方向 Task01学习笔记 YOLO方案baseline 第四期给我的体验相当不错&#xff0c;于是我又冲动报名了第五期。这次比第四期的AIGC和大模型应用都要上强度。 CV这边进度拉得快&#xff0c;Task01都发布了。一看&#xff0c;好家伙&#xff0c;老朋…

微知-PCIe配置空间中哪个字段表示设备类型?有哪三种类型?哪个字段表示厂商ID

pcie配置空间早期是246字节。由头部和设备相关部分两个region组成。其中头部64B&#xff0c;设备相关192B。 其中64B是header叫做预定义头部&#xff0c;存储设备基本信息和通用控制部分&#xff0c;比如说pcie的venderid就存在byte0和byte1。这个vendor id是PCI SIG组织统一管…

【mars3d-heatLayer】热力图在相机视角缩放时按新的raduis进行渲染

地图放大 地图缩小 代码&#xff1a; import * as mars3d from "mars3d"export let map // mars3d.Map三维地图对象// 需要覆盖config.json中地图属性参数&#xff08;当前示例框架中自动处理合并&#xff09; export const mapOptions {scene: {center: { lat: 25…

cloud compare 学习利用CC代码加快插件开发与总结(三)

建议看过前面的文章后&#xff0c;再开始本文的学习 cloud compare二次插件化功能开发详细步骤&#xff08;一&#xff09;_cloudcompare插件开发-CSDN博客 cloud compare PCA插件开发详细步骤&#xff08;二&#xff09;附代码-CSDN博客 本文完成一个点云变换的插件&#x…

HighConcurrencyCommFramework c++通讯服务器框架 :TCP粘包解决

服务器设计&#xff1a;原则综述&#xff1a; 通用服务器框架&#xff1a;游戏&#xff0c;网络交易&#xff0c;通讯框架&#xff0c;聚焦在业务逻辑上&#xff1b; 收发包&#xff1a;格式问题提出&#xff1b; 例子&#xff1a;第一条命令出拳【1abc2】&#xff0c;第二条…

Linux高性能服务器编程 总结索引 | 第1章:TCP/IP协议族

现在 Internet&#xff08;因特网&#xff09;使用的主流协议族是 TCP/IP 协议族&#xff0c;它是一个分层、多协议的通信体系。本章简要讨论 TCP/IP 协议族各层 包含的主要协议&#xff0c;以及它们之间是 如何协作完成网络通信的 1、TCP/IP 协议族体系结构 以及主要协议 1.1…

区块链变革:Web3时代的数字化前沿

随着科技的飞速发展&#xff0c;数字化正在深刻影响着我们生活的方方面面。区块链技术作为一种新兴的去中心化技术&#xff0c;正成为推动这一变革的重要力量。特别是在Web3时代&#xff0c;区块链的作用不仅仅局限于加密货币&#xff0c;而是延伸到了各个领域&#xff0c;成为…

代码随想录训练营 Day37打卡 动态规划 part05 完全背包理论基础 518. 零钱兑换II 377. 组合总和 Ⅳ 卡码70. 爬楼梯(进阶版)

代码随想录训练营 Day37打卡 动态规划 part05 一、完全背包理论基础 有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i]&#xff0c;得到的价值是value[i] 。每件物品都有无限个&#xff08;也就是可以放入背包多次&#xff09;&#xff0c;求解将哪些物品装…

Postman【使用总结】--SpringBoot的Controller规范【重修】

【企业规范&#xff01;&#xff01;&#xff01;】 【响应数据】

提升学术论文质量的智能助手:ChatGPT

提升学术论文质量的智能助手&#xff1a;ChatGPT 前言ChatGPT的核心功能ChatGPT的优势具体应用案例局限性与最佳实践结语 前言 在这个知识爆炸的时代&#xff0c;学术研究已成为推动社会进步和科技发展的重要力量。每一篇论文的撰写&#xff0c;都是对人类知识边界的一次探索和…

攻防世界-web题型-2星难度汇总-个人wp

command_execution 典型的SSRF&#xff0c;先用命令找一下flag在哪里 xff_referer 修改一下xff和refere就可以了 php_rce 经典的thinkphp框架&#xff0c;闭着眼睛拿工具梭 这款工具无法直接getshell换一个 拿蚁剑直接连 Web_php_include 先分析代码 while (strstr($page,…

搜索二叉树进阶之AVL树

前言 二叉搜索树&#xff08;BST&#xff09;是一种基础的数据结构&#xff0c;能够高效地进行搜索、插入和删除操作。然而&#xff0c;在最坏的情况下&#xff0c;普通的BST可能会退化成一条链表&#xff0c;导致操作效率降低。为了避免这种情况&#xff0c;出现了自平衡二叉…

C语言-输出菱形

题目要求&#xff1a; 输出以下图形 程序&#xff1a; #define _CRT_SECURE_NO_WARNINGS 1 #include<stdio.h> int main() {int i, j;for (i 0; i < 4; i){for (j i 1; j < 4; j)printf(" ");for (j 0; j < 2 * i 1; j)printf("*");…

虽迟但到:Midjourney推出网页端并限时免费!

大家好&#xff0c;我是木易&#xff0c;一个持续关注AI领域的互联网技术产品经理&#xff0c;国内Top2本科&#xff0c;美国Top10 CS研究生&#xff0c;MBA。我坚信AI是普通人变强的“外挂”&#xff0c;专注于分享AI全维度知识&#xff0c;包括但不限于AI科普&#xff0c;AI工…