【Java多线程(3)】线程安全问题和解决方案

目录

一、线程安全问题

1. 线程不安全的示例

2. 线程安全的概念

3. 线程不安全的原因

二、线程不安全的解决方案

1. synchronized 关键字

1.1 synchronized 的互斥特性

1.2 synchronized 的可重入特性

1.3 死锁的进一步讨论

1.4 死锁的四个必要条件(重点)

2. volatile 关键字

3. wait 和 notify

4. wait 和 sleep 的对比(面试题)


一、线程安全问题

1. 线程不安全的示例

public class Demo {

    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        //线程不安全示例
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        t1.start();
        t2.start();
        //等t1和t2都结束再打印count
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

上述代码中,对一个count分别在两个线程中循环加加50000次,预期输出结果应该是100000。

但是会发现多次运行后的结果都不会是100000,且每次运行输出的结果都不一致。

2. 线程安全的概念

概念:想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:

如果多线程环境下代码运行结果是符合我们预期的,即在单线程环境应该的结果,则说这个线程是线程安全的。

3. 线程不安全的原因

(1)线程在系统中是随机调度的,是抢占式执行的,这是线程安全问题的罪魁祸首。这是系统内核的设计,是无法干预的。

(2)共享资源:多个线程同时修改同一份数据或资源时,如果没有进行适当的同步控制,就容易导致数据的不一致性和错误。前面的代码中,就是两个线程同时修改同一个变量。

(3)原子性:线程针对变量的修改操作,不是原子的。

(4)内存可见性问题

(5)指令重排序

二、线程不安全的解决方案

要想解决线程不安全示例代码的问题,就要从上述原因入手。

原因(1),无法干预。

原因(2),是一个切入点,但是在Java中,这种做法不是很普适,只是针对一些特定场景是可以做到的。因为上面的代码,就是要用多线程修改同一个变量。

原因(3),这是解决线程安全问题,最普适的方案。

比如上述代码中的count++,其实是由三步操作组成的:1.从内存把数据读到 CPU;2.进行数据更新;3.把数据写回到 CPU。因此这一个操作由于线程的抢占式执行,执行指令的相对顺序就会有很多可能:

这是正确的可能性。

在这种情况下,t1读到0并加加为1,这时t2抢到了CPU执行权,也读到0并加加为1,然后存了count=1到CPU,然后t1又抢到了CPU的执行权,还是存了count=1到CPU。最终导致两次++只有一次生效。

实际上,一个线程的 save 在另一个线程的load之前,就是ok的;反之,就都是有问题的。

1. synchronized 关键字

可以通过一些操作,把上述一系列“非原子”的操作,打包成一个“原子”操作,也就是锁。

锁,本质上也是操作系统提供的功能,通过api给应用程序了。Java(JVM)对于这样的系统api又进行了封装。即synchronized关键字。

1.1 synchronized 的互斥特性

synchronized 会起到互斥效果(也成为锁竞争锁冲突), 某个线程执⾏到某个对象的 synchronized 中时, 其他线程如果也执行到同⼀个对象的 synchronized,就会阻塞等待。
  • 进⼊ synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁

就比如在学校上厕所,有以下几步:

  1. 关上门并上锁。此时外面还有人想在这个坑上厕所,就得阻塞等待。
  2. 上厕所。
  3. 开门并解锁。
可以粗略理解成, 每个对象在内存中存储的时候, 都存有⼀块内存表⽰当前的 "锁定" 状态(类似于厕所的 "有⼈/⽆⼈"). 如果当前是 "⽆⼈" 状态, 那么就可以使⽤, 使⽤时需要设为 "有⼈" 状态. 如果当前是 "有⼈" 状态, 那么其他⼈⽆法使⽤, 只能排队。

Java中随便一个对象,都可以作为加锁的对象。

public class Demo1 {

    //两个线程同时修改同一个变量,会有线程安全问题
    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        //线程安全
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                //synchronized 会起到互斥效果, 某个线程执⾏到某个对象的 synchronized 中时,
                //其他线程如果也执⾏到同⼀个对象, synchronized 就会阻塞等待.
                //进⼊ synchronized 修饰的代码块, 相当于 加锁
                //退出 synchronized 修饰的代码块, 相当于 解锁
                synchronized (locker) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (locker) {
                    count++;
                }
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

如此,代码就能正确输出100000了。

这里的锁针对的是多个线程竞争同一把锁,如果多个线程使用的锁是不同的,不会产生互斥的。

注意:
  • 上⼀个线程解锁之后, 下⼀个线程并不是⽴即就能获取到锁. ⽽是要靠操作系统来 "唤醒". 这也就 是操作系统线程调度的⼀部分⼯作。
  • 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B ⽐ C 先来的, 但是 B 不⼀定就能获取到锁, ⽽是和 C 重新竞争, 并不遵守先来后到的规则。

再看一个示例:

class Counter {
    private int count = 0;

    //2.由于调用当前方法都需要上锁 可使用方法锁(锁对象是this)
    public synchronized void add() {
        count++;
    }

    //如果是静态方法,锁对象是类名.class(类对象)
    public synchronized static void func() {

    }

    public int getCount() {
        return count;
    }
}

public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                //1.直接将counter对象当作锁对象使用
                /*
                synchronized (counter) {
                    counter.add();
                }*/
                counter.add();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                /*
                synchronized (counter) {
                    counter.add();
                }*/
                counter.add();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.getCount());
    }
}

上述代码中,由于调用add方法都需要上锁,就可以在add方法中加上synchronized关键字,将add()设成一个同步方法(方法锁)。这样代码结果一样是正确输出100000.

  • 如果是普通方法,它的锁对象是this,也就是调用这个方法的对象。
  • 如果是静态方法,它的锁对象是类对象,也就是类名.class 。

1.2 synchronized 的可重入特性

synchronized 同步块对同⼀条线程来说是可重⼊的,不会出现自己把自己锁死的问题。
理解 "把自己锁死"
⼀个线程没有释放锁, 然后⼜尝试再次加锁.
//第⼀次加锁, 加锁成功
lock();
//第⼆次加锁, 锁已经被占⽤, 阻塞等待.
lock();
按照之前对于锁的设定, 第⼆次加锁的时候, 就会阻塞等待. 直到第⼀次的锁被释放, 才能获取到第⼆个锁. 但是释放第⼀个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就⽆法进行解锁操作,这时候就会死锁。

Java 中的 synchronized 是 可重入锁, 因此没有上面的问题.  

在可重⼊锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息.
  • 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
  • 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
class Counter2 {

    private int count;

    public void add() {
        count++;
    }

    //锁对象是this
    public synchronized void func() {
        System.out.println("调用了func这个同步方法(方法锁)");
    }

    public int getCount() {
        return count;
    }
}

public class Demo3 {
    public static void main(String[] args) {
        Counter2 locker = new Counter2();
        //嵌套锁示例
        Thread t1 = new Thread(() -> {
            synchronized (locker) {
                locker.func(); //由于该方法是一个普通的同步方法,因此锁对象也是locker
            }
        });

        t1.start();

        //可重入锁
        //如果某个线程加锁的时候, 发现锁已经被⼈占⽤, 但是恰好占⽤的正是⾃⼰, 那么仍然可以继续获取到锁, 并让计数器⾃增.
        //解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
    }
}

根据以上解释,该代码并不会造成死锁。

1.3 死锁的进一步讨论

死锁的三种比较典型的场景:

  1. 锁是不可重入锁,并且一个线程针对一个锁对象,连续加锁两次,通过引入可重入锁,问题就解决了,因此Java并没有这个问题。
  2. 两个线程两把锁。
  3. N个线程,M把锁。

先看场景2,两个线程两把锁:

有线程1和线程2,以及有锁A和锁B,现在,线程1和2都需要获取到 锁A 和 锁B。线程1拿到锁A,不释放A,继续获取锁B。即先让两个线程分别拿到一把锁,然后再尝试获取对方的锁。

public class Demo4 {
    public static void main(String[] args) {
        //循环等待造成的死锁示例
        //线程一拥有锁1,线程二拥有锁2,双方在拥有自身锁的同时尝试获取对方的锁,
        //最终两个线程就会进入无限等待的状态,这就是死锁。
        Object locker1 = new Object();
        Object locker2 = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                //确保t2线程拿到另一个锁对象了
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2) {
                    System.out.println("t1线程正在执行");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (locker2) {
                //确保t1线程拿到另一个锁对象了
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker1) {
                    System.out.println("t1线程正在执行");
                }
            }
        });

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

在这个示例中,线程1拿到锁1,线程2拿到锁2,此时线程1要等待锁2释放才能继续执行,线程2也要等待锁1释放才能继续执行,就造成死锁了。

场景3,N个线程M把锁:

哲学家就餐问题,也就是在场景2的基础上更复杂了一些。

 5个哲学家吃面,只有5只筷子。这个模型大部分情况是可以正常工作的。

如果出现极端情况,比如同一时刻,所有人拿起右手的筷子,并且每个人都不肯让出自己手里的筷子,此时所有人都吃不到面条了。

1.4 死锁的四个必要条件(重点)

  • 1. 锁具有互斥特性。(基本特点)
  • 2. 锁不可抢占:一个线程拿到锁之后,除非它自己主动释放锁,否则别人抢不走。(基本特点)
  • 3. 请求和保持:一个线程拿到一把锁之后,不释放这个锁的前提下,再尝试获取其他锁。(代码结构)
  • 4. 循环等待:多个线程获取多个锁的过程中,出现了循环等待。如前面的场景2。(代码结构)

必要条件,意味着只要上述条件缺少一个,就不会构成死锁。

基本特点无法改变,因此我们要从后两点解决死锁问题。

  • 请求和保持,就是尽量不要让锁嵌套。
  • 循环等待,如果就会有锁的嵌套,例如场景2中,约定必须先获取locker1后获取locker2,这里即使出现锁嵌套,也不会死锁。即它们都先抢锁1,另一个没抢到的就阻塞等待了,等锁1执行完,锁2也更早释放了,自然就不会死锁了。

场景3中,约定每个哲学家必须先获取编号小的筷子,后获取编号大的筷子,一样能够有效避免死锁。

2. volatile 关键字

这里我们解决线程不安全的原因4:内存可见性问题。

先看一段代码:

import java.util.Scanner;

public class Demo5 {

    private static int count = 0;

    public static void main(String[] args) {
        //内存可见性示例 - volatile
        Thread t1 = new Thread(() -> {
            System.out.println("t线程开始执行");
            while (count == 0) {
                //
            }
            System.out.println("t线程结束");
        });

        Thread t2 = new Thread(() -> {
            //控制t2线程执行在t1之后
            Scanner scanner = new Scanner(System.in);
            count = scanner.nextInt();
        });

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

上述代码中,我们的预期效果应该是:

t1首先会进行循环,t2抢到CPU后,用户输入非0整数,就会使t1线程退出循环,结束线程。

 而实际上,t1并没有真正出现退出的情况,这个问题产生的原因,就是“内存可见性”。

 上述代码站在指令的角度来理解,使一个线程写,一个线程读。

while循环中,首先load从内存读取数据到CPU寄存器;然后cmp比较(同时会产生跳转),如果比较的条件成立,继续顺序执行,不成立,就跳转到另外一个地址执行。

当前循环中的执行速度是很快的,短时间内出现大量的load和cmp反复执行,而load执行消耗的时间会比cmp快很多很多。

由于load的执行速度很慢,且JVM还发现,每次在t2修改之前,load执行的结果其实是一样的,因此,JVM就把上述load操作优化了,只是第一次真正进行load,这就导致后续t2修改count,此时t1也感知不到了。

如果上述代码中,循环体存在IO操作或者阻塞操作(sleep),这就会使循环执行速度大幅度降低,此时就不会优化load,也就不会有上述问题了。

但是JVM到底优不优化,是不能确定的,此时就需要通过一些方式来控制,不让它触发优化。

Java就引入了volatile关键字,给变量修饰上这个关键字之后,编译器就不会按上述优化策略进行优化了,其作用主要有以下两个:

  1. 保证内存可见性:基于屏障指令实现,即当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
  2. 保证有序性:禁止指令重排序。编译时 JVM 编译器遵循内存屏障的约束,运行时靠屏障指令组织指令顺序。
什么是代码重排序?⼀段代码是这样的:
  1. 去前台取下 U 盘
  2. 去教室写 10 分钟作业
  3. 去前台取下快递
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以少跑⼀次前台。这种叫做指令重排序.
编译器对于指令重排序的前提是 "保持逻辑不发⽣变化". 这⼀点在单线程环境下比较容易判断, 但是 在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的 执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.
重排序是⼀个比较复杂的话题, 涉及到 CPU 以及编译器的⼀些底层工作原理, 此处不做过多讨论.

在上述代码中,变量count加上volatile关键字,问题就解决了。 

 

有一个注意点,volatile关键字保证的是内存可见性,但不能够保证原子性。

因此在目录一的第一个示例中,不使用synchronized而用volatile还是一样会出现同样的问题,原因就是volatile不能保证原子性。

3. wait 和 notify

由于线程之间是抢占式执行的,因此线程之间的执行的先后顺序是难以预知的,但实际开发中有时候我们希望合理协调多个线程之间的执行先后顺序。这里就涉及到了线程的等待通知机制,通过等待的方式,能够让线程一定程度按照我们预期的顺序来执行,我们无法主动让某个线程被调度,但是可以主动让某个线程等待。

假设你去一家繁忙的餐厅用餐,餐厅只有几张桌子供应,但人们排队等待就餐。这里的顾客可以比作线程,桌子可以比作共享资源,比如餐具。餐厅经理就是一个控制资源访问的调度程序。

  1. 等待就餐的顾客:顾客排队等待就餐,他们是等待状态的线程。他们不会一直站在那里,而是会进入一个等待区域,表示线程被挂起。

  2. 顾客就餐:当有桌子空出来时,经理会通知等待区域的顾客,告诉他们可以进入餐厅就餐了。这个通知过程就相当于唤醒了一个或多个等待状态的线程。

  3. 桌子资源:餐厅的桌子是共享资源,多个顾客(线程)需要共享这些资源。当一个顾客就餐时,这个桌子就被占用了,其他顾客需要等待它被释放。

  4. 餐厅经理:餐厅经理就是控制资源访问的调度程序。他会检查哪些桌子空闲,然后通知等待区域的顾客。

在这个场景中,等待区域就相当于等待池,经理的通知就相当于线程的唤醒操作,桌子就相当于共享资源,顾客就相当于线程。通过这个生活场景的类比,可以更好地理解线程的等待通知机制。

等待通知机制,就是通过条件,判定当前逻辑是否能够执行,如果不能执行,就主动wait(主动进行阻塞),把执行的机会让给别的线程,避免该线程进行一些无意义的重试。等到后续条件满足了(其他线程通知了),再让阻塞的线程被唤醒。

wait 做的事情:
  • 使当前执行代码的线程进行等待. (把线程放到等待队列中)
  • 释放当前的锁
  • 满足⼀定条件时被唤醒, 重新尝试获取这个锁.

即wait内部做的事情不仅仅是阻塞等待,还要解锁。要想解锁,就得先加上锁。

因此wait 要搭配 synchronized 来使⽤. 脱离 synchronized 使⽤ wait 会直接抛出异常.

wait 结束等待的条件:

  • 其他线程调用该对象的 notify 方法. 如果是不同锁对象就没有联系了.
  • wait 等待时间超时 (wait 方法提供⼀个带有 timeout 参数的版本, 来指定等待时间).
  • 其他线程调⽤该等待线程的 interrupted ⽅法, 导致 wait 抛出 InterruptedException 异常.
import java.util.Scanner;

public class Demo6 {
    public static void main(String[] args) throws InterruptedException {
        //等待唤醒机制的示例
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (locker) {
                System.out.println("t1等待之前");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("t1等待之后");
            }
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            synchronized (locker) {
                System.out.println("t2唤醒之前");
                //通过scanner控制阻塞,用户输入之前,都是阻塞状态
                scanner.next();
                locker.notify();
                System.out.println("t2唤醒之后");
            }
        });

        t1.start();
        Thread.sleep(100);
        t2.start();
    }
}

上述代码中,t1线程启动后先睡眠一会,保证t1先抢占到CPU,执行到wait进入等待状态。然后t2抢到CPU执行权,输入之后,notify就会唤醒上述wait操作,从而使t1回到RUNNABLE状态,并参与调度。

t1唤醒,并不是立即执行的,要先重新获取到锁,由于t2此时还没释放锁,意味着t1会从WALTING->RUNNABLE->BLOCKED。因此唤醒后,会等t2执行完毕,t1才继续执行。因此这段代码中的执行顺序是固定的。

如果这里并没有控制t1先执行,有可能t2会先抢到CPU执行权,从而先执行了notify,此时t1还没wait,locker上也没有其它任何线程wait,此时之间t2的notify就不会有任何效果(也不会抛异常),但是后续t1进入wait之后,就无法唤醒了。

还有其他的注意点:

  • 当有多个线程等待时,notify只能唤醒多个等待线程中的一个,且这个唤醒的线程是随机的。
  • wait操作和join一样也提供了带超时时间的版本,指定时间内如果没有被notify,就自动唤醒。

如果要用时唤醒所有等待的线程,可以使用notifyAll方法:

public class Demo7 {
    public static void main(String[] args) throws InterruptedException {
        //多个线程等待,notify()只能随机唤醒一个,,notifyAll()则可以唤醒所有等待线程
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (locker) {
                System.out.println("t1等待之前");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("t1等待之后");
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (locker) {
                System.out.println("t2等待之前");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("t2等待之后");
            }
        });

        Thread t3 = new Thread(() -> {
            synchronized (locker) {
                System.out.println("t3唤醒之前");
                locker.notifyAll();
                System.out.println("t3唤醒之后");
            }
        });

        t1.start();
        t2.start();
        Thread.sleep(100);
        t3.start();
    }
}

同样的,t1和t2被唤醒后,也需要t3执行完毕。

4. wait 和 sleep 的对比(面试题)

其实理论上 wait 和 sleep 完全是没有可⽐性的,因为⼀个是用于线程之间的通信的,⼀个是让线程阻塞⼀段时间。
  • 唯一的相同点:都可以让线程放弃执行⼀段时间。

不同点:

  1. wait是Object类中的一个方法,sleep是Thread类中的一个方法。
  2. wait必须在synchronized修饰的代码块或方法中使用,sleep方法可以在任何位置使用。
  3. wait被调用后当前线程进入BLOCK状态并释放锁,并可以通过notify和notifyAll方法进行唤醒;sleep被调用后当前线程进入TIMED_WAIT状态,不涉及锁相关的操作。

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Golang 内存管理和垃圾回收底层原理(一)

一、这篇文章我们来聊聊Golang内存管理和垃圾回收&#xff0c;主要注重基本底层原理讲解&#xff0c;进一步实战待后续文章 1、这篇我们来讨论一下Golang的内存管理 先上结构图 从图我们来讲Golang的基本内存结构&#xff0c;内存结构可以分为&#xff1a;协程缓存、中央缓存…

vue3+eachrts饼图轮流切换显示高亮数据

<template><div class"charts-box"><div class"charts-instance" ref"chartRef"></div>// 自定义legend 样式<div class"charts-note"><span v-for"(items, index) in data.dataList" cla…

jdbc连SQL server,显示1433端口连接失败解决方法

Exception in thread "main" com.microsoft.sqlserver.jdbc.SQLServerException: 通过端口 1433 连接到主机 localhost 的 TCP/IP 连接失败。错误:“connect timed out。请验证连接属性。确保 SQL Server 的实例正在主机上运行&#xff0c;且在此端口接受 TCP/IP 连接…

移动WEB开发之rem适配布局

一、rem 基础 rem 单位 rem (root em)是一个相对单位&#xff0c;类似于em&#xff0c;em是父元素字体大小。不同的是rem的基准是相对于html元素的字体大小。比如&#xff0c;根元素&#xff08;html&#xff09;设置font-size12px; 非根元素设置width:2rem; 则换成px表示就是2…

页面自适应

后续整理下自适应的集中方法 地址

【数据库】MySQL InnoDB存储引擎详解 - 读书笔记

MySQL InnoDB存储引擎详解 - 读书笔记 InnoDB 存储引擎概述InnoDB 存储引擎的版本InnoDB 体系架构内存缓冲池LRU List、Free List 和 Flush List重做日志缓冲&#xff08;redo log buffer&#xff09;额外的内存池 存储结构表空间系统表空间独立表空间通用表空间undo表空间临时…

如何彻底删除node和npm

如何彻底删除node和npm 前言&#xff1a; 最近做个项目把本地的node更新了&#xff0c;之前是v10.14.2更新至v16.14.0 &#xff0c;想着把之前的项目起来下&#xff0c;执行npm install 结果启动不了&#xff0c;一直报npm版本不匹配需要更新本地库异常… 找了几天发现是npm 和…

基于JAVA的汽车售票网站论文

摘 要 互联网发展至今&#xff0c;无论是其理论还是技术都已经成熟&#xff0c;而且它广泛参与在社会中的方方面面。它让信息都可以通过网络传播&#xff0c;搭配信息管理工具可以很好地为人们提供服务。针对汽车售票信息管理混乱&#xff0c;出错率高&#xff0c;信息安全性差…

从零到百万富翁:ChatGPT + Pinterest

原文&#xff1a;Zero to Millionaire Online: ChatGPT Pinterest 译者&#xff1a;飞龙 协议&#xff1a;CC BY-NC-SA 4.0 在社交媒体上赚取百万美元 - 逐步指南&#xff0c;如何在线赚钱版权 献给&#xff1a; 我将这本书&#xff0c;“从零到百万富翁在线&#xff1a;Chat…

Netty经典32连问

文章目录 1、Netty是什么&#xff0c;它的主要特点是什么&#xff1f;2、Netty 应用场景了解么&#xff1f;3、Netty 核心组件有哪些&#xff1f;分别有什么作用&#xff1f;4、Netty的线程模型是怎样的&#xff1f;如何优化性能&#xff1f;5、EventloopGroup了解么?和 Event…

【QT入门】 无边框窗口设计之实现圆角窗口

往期回顾&#xff1a; 【QT入门】对无边框窗口自定义标题栏并实现拖动和拉伸效果-CSDN博客 【QT入门】 自定义标题栏界面qss美化按钮功能实现-CSDN博客 【QT入门】 无边框窗口设计之实现窗口阴影-CSDN博客 【QT入门】 无边框窗口设计之实现圆角窗口 我们实际用到的很多窗口&am…

装饰工程管理系统|基于Springboot的装饰工程管理系统设计与实现(源码+数据库+文档)

装饰工程管理系统-项目立项子系统目录 目录 基于Springboot的装饰工程管理系统设计与实现 一、前言 二、系统功能设计 三、系统实现 1、管理员功能实现 &#xff08;2&#xff09;合同报价管理 &#xff08;3&#xff09;装饰材料总计划管理 &#xff08;4&#xff0…

基本线段树以及相关例题

1.线段树的概念 线段树是一种二叉树&#xff0c;也就是对于一个线段&#xff0c;我们会用一个二叉树来表示。 这个其实就是一个线段树&#xff0c;我们会将其每次从中间分开&#xff0c;其左孩子就是左边的集合的和&#xff0c;其右孩子就是右边集合的和&#xff1b; 我们可以…

前端返回 List<Map<String, Object>>中的vaue值里面包含一个Bigdecimal类型,序列化时小数点丢失,如何解决?

&#x1f3c6;本文收录于「Bug调优」专栏&#xff0c;主要记录项目实战过程中的Bug之前因后果及提供真实有效的解决方案&#xff0c;希望能够助你一臂之力&#xff0c;帮你早日登顶实现财富自由&#x1f680;&#xff1b;同时&#xff0c;欢迎大家关注&&收藏&&…

Windows 11安装kb5035853补丁时,提示错误0x800f0922,并且弹出“某些操作未按计划进行,不必担心,正在撤消更改。请不要关机”

Windows 11安装kb5035853补丁时&#xff0c;提示错误0x800f0922&#xff0c;并且还在重启后弹出“某些操作未按计划进行&#xff0c;不必担心&#xff0c;正在撤消更改。请不要关机”&#xff0c;按微软官方的作法是&#xff1a;https://learn.microsoft.com/zh-cn/windows/rel…

基于SpringBoot和Vue的音乐在线交流网站的设计和实现【附源码】

1、系统演示视频&#xff08;演示视频&#xff09; 2、需要交流和学习请联系

【PLC一体机】GX Works2编程控制步进电机正反转

今天博主和大家分享一下在GX Works2 中对PLC一体机编程&#xff0c;实现步进电机自动正反转的程序。 程序如下&#xff1a; 该程序有几个重要的地方和大家分享一下&#xff1a; 1、程序以中间寄存器M4开头。 博主想通过PLC一体机上的触摸屏控制程序是否运行&#xff0c;因此…

多类别分类器(Machine Learning研习十八)

多类别分类器 二元分类器可以区分两个类别&#xff0c;而多类别分类器&#xff08;也称为多叉分类器&#xff09;可以区分两个以上的类别。 一些 Scikit-Learn 分类器&#xff08;如 LogisticRegression、RandomForestClassifier 和 GaussianNB&#xff09;能够原生处理多个类…

各类系统业务功能架构图整理

一、前言 很多软件系统一直经久不衰&#xff0c;主要这些系统都是一些生产工作经营不可或缺的系统。比如财务系统&#xff0c;商城系统&#xff0c;支付系统&#xff0c;供应链系统&#xff0c;人力资源管理系统&#xff0c;ERP系统等等。这些系统不管大公司还是小公司往往都需…

VSCode美化

今天有空收拾了一下VSCode&#xff0c;页面如下&#xff0c;个人觉得还是挺好看的~~ 1. 主题 Noctis 色彩较多&#xff0c;有种繁杂美。 我使用的是浅色主题的一款Noctis Hibernus 2. 字体 Maple Mono 官网&#xff1a;Maple-Font 我只安装了下图两个字体&#xff0c;使…