初识多线程

认识线程(Thread)

线程就是一个执行流 ,每个线程之间都可以按照顺序执行自己的代码,多个线程之间同时执行着多份代码

为什么要有线程

单核CPU的发展遇到了瓶颈,要想提高算力,就需要多核CPU ,而并发编程 能更充分利用多核CPU资源。有些任务场景需要等待磁盘IO,在这等待的时间能够让CPU去做一些其他的事情,也需要用到并发编程。

虽然多进程也能实现并发编程,但是线程比进程更加轻量(进程是工厂,线程是工人)

1. 进程是包含线程 的,每个进程至少有一个线程 存在,即主线程

2. 进程与进程之间不共享 内存空间,同一个进程的线程之间共享同一个内存空间

3. 进程是系统分配资源 的最小单位,线程是CPU调度和执行的最小单位。线程共享所属进程的系统资源

也就是说对于线程来说,只是第一个线程创建的时候,和进程一起申请资源 ,后续再创建线程,不涉及资源申请操作 。同理运行过程中销毁某个线程,也不会释放资源

4. 一个进程挂了一般不影响其他进程 ,但是一个线程挂了,可能把同进程的其他线程一同带走

Java线程

线程是操作系统 中的概念,操作系统内核实现了线程这样的机制,并且对用户成提供了一些API供用户使用。但是操作系统提供的原生线程API是C语言的,而且不同操作系统提供的API还不一样,所以Java对其进行了统一的封装------Thread类

创建线程

1. 继承Thread类 2. 实现Runnable接口

方法一:

创建一个继承于Thread方法的类,并重写run方法

java 复制代码
class MyThread extends Thread{
    public void run() {
        System.out.println("haha");
    }
}

在主函数创建MyThread实例并调用**start()**方法

java 复制代码
Thread t=new MyThread();
t.start();

方法二:

创建一个实现Runnable接口 的类,并实现**run()**方法

java 复制代码
class MyRunnable implements Runnable{
    @Override
    public void run() {
        System.out.println("aha");
    }
}

在主函数中创建Thread实例,将Runnable对象作为Thread构造方法的参数,调用start()方法

java 复制代码
        Runnable r=new MyRunnable();
        Thread t=new Thread(r);
        t.start();

实际生产环境中一般将方法二配合线程池 来使用,因为一个类只能继承一个父类,但可以实现多个接口。方法二还可将任务与任务执行器解耦 ,一个Runnable实例,可以传给不同的Thread实例

其他方法:

使用匿名内部类创建Runnable子类对象

java 复制代码
        Thread t=new Thread(){
            public void run(){
                    System.out.println("haha");
            }
        };
Thread t3 = new Thread(() -> System.out.println("使用匿名类创建 Thread 子类对
象"));

多线程在一些场景下可以提高程序的整体运行效率

Thread类及常见方法

构造方法

Thread的几个常见属性

1. ID是线程的唯一标识,不同线程不能重复

2. 名称是各种调试工具用到的

3. 状态 表示线程当前所处的状态

4. 优先级高的理论上 更容易被调度到,但也仅仅是理论上,因为Java的线程调度最终是交由操作系统 来决定的。你设置的Java优先级,只是给操作系统一个建议 ,操作系统完全可以不听。在实际中永远不要依赖调整线程优先级来控制业务逻辑的先后顺序

5. 关于后台进程,只需记住一句话:JVM会在一个进程中的所有非后台线程(用户线程)结束后,才会结束这个线程 ,编写代码中,程序员创建的线程默认是用户线程 。也就是说,后台线程存在的意义就是服务其他线程

6. 是否存活:是否调用了start()&&run()方法还没结束

启动一个线程(start)

创建一个Thread对象 相当于招募一个工人 ,覆写run()方法,相当于告诉这个工人具体的工作 。此时调用start()就相当于,给这个工人安排工位 ,让他开始干活。也就是说Java虚拟机这时才会向底层操作系统申请真正的线程资源(底层是调用系统API)

在Java中,线程对象本质上是一次性消耗品 ,也就是说,一个线程对象只能start()一次。如果再次调用start()会直接报错(IllegalThreadStateException 非法线程状态异常),只能是重新new 一个Thread

如果调用start()之前,直接调用run()方法。那么这和一个普通的Java方法调用 没有区别,此时Java虚拟机并不会 向操作系统申请线程资源,还是调用run()的线程来完成逻辑。

休眠当前线程

因为线程的调度是不可控的,所以,这个方法只能保证实际休眠时间是大于等于 参数设置的休眠时间的(休眠时间+CPU的调度时间+上下文切换时间 )。所以永远不要用sleep()来做高精度的计时器

sleep(0): 一种特殊的写法,指在让线程立刻放弃CPU资源,等待系统重新调度

终止一个线程

方法一: 通过共享标记来结束

方法二: 调用**interrupt()**方法

方法一

定义了一个成员变量isFinished,通过修改这个成员变量来控制线程的运行

java 复制代码
    public volatile static boolean isFinished=false;
    public static void main(String[] args) throws InterruptedException {
        Thread t=new Thread(){
            public void run(){
                while(!isFinished){
                    System.out.println("haha");
                }
            }
        };
        t.start();
        Thread.sleep(3000);
        isFinished=true;

volatile: 加一个强制刷新指令 ,子线程每次循环都必须去主内存读取最新的值

在Lambda里面使用外面的变量会触发**"变量捕获"** 这样的语法。因为Lambda是回调函数 ,调用的时机不确定,有可能调用函数的时候外面的变量已经销毁了,为了避免这种问题,Java的做法就是将这个变量拷贝一份到Lambda表达式 中,因为这个局部变量是拷贝过来的,所以在外部对其进行修改并不会同步 到Lambda表达式中。所以,这个变量不允许被修改 ,一般由final修饰。

如果将外部的局部变量改为成员变量 ,那么就不是"变量捕获"的语法,而是切换成**"内部类访问外部类成员"** 的语法(成员变量的生命周期是由GC管理 的,Lambda表达式不需要担心变量的生命周期失效的问题,也就不需要进行拷贝)。此时在外部就可以对成员变量进行修改,修改结果也会同步到Lambda表达式中

这种方法有个缺陷,当子线程在执行Thread.sleep()时,并不会对while条件进行判断,也就是说,此时修改isFinished并不会结束这个进程

方法二

使用Thread.interrupted() 或者**Thread.currentThread().isInterrupted()**代替共享标记。

Thread内部包含了一个boolean类型的变量作为线程是否被中断的标志

public void interrupt():用于通知线程结束

如果这个线程没有被阻塞,那么调用Interrupt方法就会修改isInterrupted方法内部的标志位为true。

如果这个线程正在被阻塞 ,那么会唤醒sleep ,这种提前唤醒 的情况下,sleep会将isInterrupted的标志位 重新设为false ,并抛出InterruptedException异常 。具体要不要结束线程取决于catch中的代码,可以选择结束线程,也可选择忽略,继续执行后续的逻辑。

java 复制代码
class MyThread extends Thread{
    public void run(){
        while(!Thread.interrupted()){
            System.out.println("haha");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                //线程被动唤醒后的逻辑
            }
        }
    }
}

public boolean isInterrupted(): 返回对象关联 的线程的标志位,调用后不重设标志位

public static boolean interrupted(): 判断当前线程 的中断标志位是否设置,返回结果后,将标志位重设为false

等待一个线程(join)

有时,我们需要等待一个线程完成他的工作后,才能进行下一步工作

获取当前线程对象的引用

线程的状态

1. NEW(新建): 仅仅在堆内存中new 一个Thread对象 ,但尚未调用start()方法,操作系统底层并未分配真实的线程资源

2. RUNNABLE(可运行): 已经调用了start()方法 。Java虚拟机的RUNNABLE状态涵盖了操作系统层面的两种状态:就绪(Ready)和运行中(Running) 。此状态的线程的线程可能正在CPU上执行代码 ,也可能正在就绪队列中等待操作系统调度器分配CPU时间片

**3. BLOCKED(阻塞):**线程试图获取一个内部的对象锁,但该锁正在被其他线程持有

4. WAITING(无限期等待): 线程主动调用了某些方法,进入无限期等待状态,直到被其他线程唤醒

5. TIMED_WAITING(限期等待): 与WAITING类似,但是增加了一个计时器

6. TERMINATED(终止): 线程的run()方法正常执行完毕,或者因抛出未捕获的异常而异常退出

线程安全问题

java 复制代码
    public static int count=0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            for(int i=0;i<20000;i++){
                count++;
            }
        });
        Thread t2=new Thread(()->{
            for(int i=0;i<20000;i++){
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }

这段代码执行结束,看起来count会变成40000。但是结果却是,count不仅不会变成40000,连输出的结果都是一个小于40000的随机值

count++看似是一行代码,实际上对应到CPU上,是三段指令

1. 将count的值加载到寄存器中,LOAD

2. 将count的值加一并存回寄存器,ADD

3. 将寄存器中的值存回内存,STORE

可是在并发执行的环境下这三步可能会被中断,并执行别的线程:

1. 假设此时count 的值为100t1线程100存入寄存器

2. 此时t1的时间片耗尽,发生上下文切换 ,t2开始执行。t2完整执行123步,将count=100从内存中取出,加一变成101,并将101存回内存。

3. 上下文切换 ,t1重新获得CPU资源。但是此时t1只会将寄存器中的100加一得到101 ,然后存回内存。而不是从内存中获取最新的数据,这就造成了数据错误

以上就是一个线程不安全的例子。如果多线程环境下代码运行的结果是符合我们预期的 ,即在单线程环境应该得到的结果,则说这个线程是安全

线程不安全的原因

1. 根本原因是操作系统对于线程的调度是随机的抢占式执行

2. 修改共享数据

3. 原子性

如果修改操作只对应到一个CPU指令 ,就可以认为是原子的,CPU就不会出现一条指令执行一半的情况。但上述示例就不满足原子性,因为一条代码对应了三条CPU指令,一个线程正在对变量进行操作,如果这个操作被打断了,结果就有可能是错误的。

这点也和线程的抢占式稠密程度密切相关,如果线程不是抢占式的,就算不满足原子性,也问题不大

4. 内存可见性

java 复制代码
    private static int flag=0;
    public static void main(String[] args){
        Thread t=new Thread(()->{
            while(flag==0){
                ;
            }
            System.out.println("t线程结束");
        });
        Thread p=new Thread(()->{
            Scanner sc=new Scanner(System.in);
            flag=sc.nextInt();
            System.out.println("p线程结束");
        });
        t.start();
        p.start();
    }

当p线程运行之后,用户从控制台输入1,将flag的值修改为1,并不会使循环退出。

很明显这是一个bug,一个线程读取另一个线程修改,修改线程修改的值,并没有被读线程读取到

JMM规定所有变量 都存储在主内存 中,每个线程 拥有自己的工作内存 (CPU高速缓存和寄存器 )。当线程t 启动时,将flag=0从主内存拷贝 到自己的工作内存 中。即使线程p将用户修改过的新值刷新回主内存,由于代码中没有任何同步机制,JMM规范不强制要求线程t去主内存重新读取最新值。

这段代码在编译器运行一段时间后,JIT编译器 会介入。出于极限性能优化 的目的,会认为flag 在循环期间绝对不会改变(单线程视角下确实会如此)。因此JIT会将这段机器码的逻辑改写为

java 复制代码
if (flag == 0) {
    while (true) {
        // 死循环,连寄存器都不读了,彻底将 flag 判断剥离出循环体
    }
}

5. 指令重排序

java 复制代码
    public static SingletonLazy getInstance(){
        //第一次检查,如果已经实例化,直接返回,避免性能损耗
        if(instance==null){
            //只有第一次实例化才会进来
            synchronized(SingletonLazy.class){
                //判断是否需要new 对象,防止多个对象同时通过第一条if,造成重复实例化
                if(instance==null){
                    instance =new SingletonLazy();//实例化对象
                }
            }
        }
        return instance;
    }

为了压榨性能,JIT和CPU会在不改变单线程最终执行结果 的前提下,打乱机器指令的执行顺序。比如说对象的实例化,在底层分为三步:

1) 分配内存空间

2)初始化对象,调用构造方法

3) 将内存地址赋值给引用变量

如果不加volatile:CPU可能将顺序重排为1->3->2。

1) 线程A执行完 1,3还没来得及执行2 ,此时线程A的CPU时间片耗尽,发生上下文切换。

2) 线程B正巧调用getInstance() ,经过第一个if发现instance已经不是null ,误以为已经实例化完成 。可是此时还没有初始化 ,线程B拿到的只是一个半成品

死锁

构成死锁的场景

情况一:

但是由于Java中synchronized和ReentrantLock的可重入性 ,这种情况并不会在Java中形成死锁

情况二:

但是如果我们实现两个线程两把锁 ,第一个线程在第一把锁的内部第二个线程的锁,第二个线程同理,那么就会出现死锁。

java 复制代码
        Thread t1=new Thread(()->{
            synchronized(lock1){
                Thread.sleep(1000);//这里为了简化,sleep并未被try-catch包裹
                //需要等待线程1拿到锁1,线程2拿到锁2,再加第二把锁
                synchronized(lock2){
                    count++;
                }
            }
        });
        Thread t2=new Thread(()->{
            for(int i=0;i<200000;i++){
                synchronized(lock2){
                    Thread.sleep(1000);
                    synchronized(lock1){
                        count++;
                    }
                }
            }
        });

情况三:n个线程n把锁,哲学家问题

一个圆桌上有n个哲学家(线程)和n支筷子(锁) ,每支筷子放在两位哲学家的中间 ,圆桌中间有一碗面。约定:拿到两双筷子 才能吃面(嵌套锁),吃完面才能把筷子放下。此时如果n位哲学家同时拿左边 的筷子,那么每位哲学家都只能拿一根筷子没有人能够吃面,也没人放下筷子。这就形成了死锁。

使用JVM 自带的jconsole或jvisualvm工具检测死锁

构成死锁的四个必要条件

1. 锁是互斥的: 一个线程拿到锁之后,另一个线程再尝试获取此锁,必须要阻塞等待

2. 锁是不可抢占的: 线程1先拿到锁1,线程2不能从线程1中抢锁,只能等到线程1主动释放锁

3. 请求和保持:资源请求者在请求其他资源时,不放下手中的资源

4. 循环等待A等待B,B等待C,C等待A

上述四个条件都满足时,便构成死锁

如何破解死锁

锁是互斥的和锁不可抢占是锁的两大特性,不能违背。想要破除死锁,只能从请求\保持和循环等待下手

请求和保持:避免使用嵌套锁

循环等待: 将锁进行编号规定锁获取的次序 ,如获取编号较小的锁。

从一号哲学家开始,第一次只能拿小编号的筷子。最后七号哲学家就会有两双筷子。

synchronized关键字 - 监视器锁monitor lock

既然线程不安全其中的一个原因是不满足原子性 ,那么我们就可以想办法将不是原子的操作打包成一个原子的操作 ,这就是加锁。synchronized花括号包裹的代码也称为同步代码块

synchronized的特性

互斥

synchronized 会起到互斥 的效果,某个线程执行到某个对象的synchronized 中时,其他线程如果也执行到同一个对象的synchronized 就会发生阻塞等待

进入synchronized修饰的代码块 相当于加锁 ,退出为解锁

synchronized 用的锁时存在Java对象 里面的,每个对象 在内存中存储的时候,都存在一块内存表示当前的锁定状态 。针对每一把锁,操作系统内部都维护了一个等待队列 ,当这个锁被某个线程占有的时候,其他线程尝试加锁,就加不上,就会发生阻塞等待 。直到之前的线程解锁之后,由操作系统唤醒一个新线程再来获取到这个锁。

注意:

1.上一个线程解锁之后,下一个线程不是立刻就能获取到锁,而是要靠操作系统来唤醒:

1) JVM通知操作系统内核 ,现在这个锁空出来了

2) 操作系统内核查找正在等待该锁的线程列表 ,将其中一个从"阻塞"修改为"就绪"

3) 线程进入就绪队列 ,等待CPU时间片的下一次轮转

  1. 假设有ABC三个线程,A先获取到锁,B、C依次尝试获取锁。此时BC都会在阻塞队列中等待,当A释放锁,JVM会根据**特定的算法(不一定绝对公平)**从等待队列中挑一个线程来获得这把锁

注意: 锁只能控制互斥边界,但绝不能控制CPU的物理调度 。也就说,对于一段代码对应多个CPU指令的情况,即使加锁也无法保证 CPU可以一口气执行完。只要当前线程的CPU时间片耗尽 ,就会发生上下文切换 。只不过这个线程此时仍手握着这把锁,直到执行完同步代码块锁才释放

可重入

synchronized同步块对同一条线程 来说是可重入的,不会出现把死锁(自己锁死)的问题

实现原理: 1. 在第一次加锁时,锁内部记录是哪个线程持有的锁 ,后续加锁时进行判定 2. 锁内部包含一个计数器 ,每加一次锁,计数器加一; 每释放一个锁,计数器减一。 计数器减为0 时,才真正释放锁

synchronized使用示例

锁的本质是对象,而不是代码块

java 复制代码
synchronized(对象){ // 进入代码块,相当于加锁,对应字节码指令monitorenter
    //代码块
} // 退出代码块,相当于解锁,对应字节码指令monitorexit

1. 括号里填的是用来加锁的对象 ,在Java中任何一个对象,都可以用作锁 。这个对象的类型不重要,重要的是是否有多个线程尝试针对同一个对象 进行加锁(是否在竞争同一把锁)。因为多个线程,针对同一个对象加锁,才会产生互斥效果。

  1. JVM保证每一个monitorenter 都有一个对应的monitorexit 。即使代码中间抛出异常,JVM也会自动加上monitorexit

方式一:修饰代码块明确指定要锁的对象

java 复制代码
        //锁任意对象
        Object lock=new Object();
        Thread t1=new Thread(()->{
            synchronized(lock){
                count++;
            }
        });

        //锁当前对象,
       class Computer{
         private int num=0;
         public void Add(){
             synchronized(this){
             num++;
             }
         }
       }//锁的是Computer实例化时候的对象

方法二:

1) 修饰普通方法 ,相当于锁类所实例化的对象 (this),不同实例之间不会互斥

2) 修饰静态方法 ,相当于锁类对象该类的所有实例在调用此方法时会竞争同一把锁

Java标准库中的线程安全类

vector、HashTable、ConcurrentHashMap、StringBuffer、String(没有加锁,但不涉及修改)

StringBuffer的核心方法中都带有synchronized

使用了线程安全类也未被绝对安全 ,因为线程安全类仅保证了单个方法线程安全 ,实际中我们会执行很多复合操作 。对于复合操作,依旧应该手动加锁来保证整体逻辑的原子性

虽然vector、HashTable、StringBuffer有synchronized,但是代码中一旦加了锁,意味着代码可能因为锁的竞争产生线程阻塞 ,导致线程从CPU上调度走,而且调度回来的时间不确定。实战中更推荐使用ConcurrentHashMap ,因为其内部有很多优化策略。

volatile关键字

保证可见性

volatile 修饰的变量,能够保证**"内存可见性"** 。上文提高过JMM,所有的变量都存储在主内存中,每个线程都有自己的工作内存 。如果修改volatile修饰的值 ,会触发一个硬件指令。这个指令不仅会将新值刷新回主内存 ,还强制让所有CPU核心里缓存的该变量的副本瞬间失效 。其他线程下次再去读取这个变量时,发现自己的缓存失效了 ,就会被迫从主内存中拉取最新的值

注意: voaltile并不能保证原子性

有序性

加上volatile之后,JIT编译器会在汇编指令层面 ,在修改volatile变量指令前后各插入一道内存屏障

1. 写在指令前面: 禁止上面的普通写和下面的volatile写重排序

2. 写在指令后面: 防止后面可能出现的读操作 ,与volatile写发生重排

wait和notify

由于线程之间是抢占式执行 的,因此线程之间执行的先后顺序难以预知 。但是实际开发中,我们希望可以灵活协调多个线程之间的执行先后顺序

wait() \ wait(long timeout): 让当前线程进入等待状态

notify() \ notifyAll(): 唤醒当前对象上等待的线程

注意: wait、notify、notifyAll都是Object类的方法

wait

java 复制代码
            synchronized(lock){
                //代码逻辑,加锁状态
                try {
                    lock.wait();//解锁,开始阻塞等待
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                //wait()唤醒之后,重新获取锁,执行后续逻辑
            }

wait做的事情:

1. 线程释放锁 (清空_Owner),状态由RUNNABLE变为WAITING ,进入Monitor的等待集合 (_WaitSet)中休息。此时它不参与锁的竞争

2. 满足以下唤醒条件时,唤醒该线程

1) 其他线程调用该对象的notify方法

2) wait等待时间超时

3) 其他线程调用该线程的interrupted 方法,导致wait抛出InterruptedException异常

notify

即JVM从**_WaitSet中挑出一个线程** ,这个线程被唤醒之后,并不是立即执行。它的状态会由WAITING变为BLOCKED ,并转移到阻塞队列 中。等到执行notify的线程释放锁之后,和其他线程一起竞争锁。

**示例:**输入数字之后,开始唤醒t1线程

java 复制代码
        Object lock=new Object();
        Thread t1=new Thread(()->{
            try {
            synchronized(lock){
                System.out.println("wait之前");
                lock.wait();
                System.out.println("wait之后");
            }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        Thread t2=new Thread(()->{
            synchronized(lock){
                Scanner sc=new Scanner(System.in);
                sc.nextInt();
                lock.notify();
            }
        });

注意: 如果有多个线程同时等待,那么使用notifyAll()会同时唤醒多个线程,但具体哪个线程抢到锁,那不一定。

避免虚假唤醒

总结一句话: wait()绝不能if条件语句包裹 ,应该使用while循环语句

线程被notify唤醒的瞬间,到他真正重新执行业务代码之间,存在一个不受控制的锁竞争和时间差 ,举个例子:有一个盘子 (锁)盘子里放个包子 。有两个消费者A和B :看到盘子空了等待有包子吃掉生产者P: 负责放包子

java 复制代码
synchronized (plate) {
    if (plate.isEmpty()) { 
        plate.wait(); // 盘子空了,交出锁,睡觉
    }
    plate.remove(0); // 醒来后,直接吃包子
}

1. 初始状态下,盘子是空的 ,此时消费者A获得锁 ,发现盘子是空的,释放锁线程等待

2. 生产者P获得锁,放一个包子,然后调用notifyAll(),释放锁

3. 此时消费者A进入阻塞队列 ,和其他消费者一同竞争锁

4. 消费者B,这时也来争抢锁,锁首先被消费者B 获得,消费者B看到盘子里有包子,将其吃掉 ,盘子变空。B执行完毕,释放锁

5. 此时消费者A获得锁 ,由于A 使用的是if语句,不会重新判断条件而是直接选择吃包子。造成程序异常

更改过后,使用while语句,只要不满足条件,就会再次进入线程等待

java 复制代码
synchronized (plate) {
    while (plate.isEmpty()) { 
        plate.wait(); // 盘子空了,交出锁,睡觉
    }
    plate.remove(0); // 醒来后,直接吃包子
}

wait和sleep的区别

wait用于线程之间的通信 ,sleep用于线程阻塞(控制执行时机)

1. wait会释放锁,sleep不会

2. wait需要搭配synchronized使用,sleep不需要

3. wait是Object的方法 ,sleep是Thread的静态方法

对比进程和线程

线程的优点

1. 创建一个新线程的代价 以及比创建一个新进程小得多

2. 与进程之间的切换相比,线程之间的切换 需要操作系统做的工作要少很多

3. 线程占用的资源 比进程很多

4. 能充分利用多核处理器并行数量

5. 等待慢速I/O操作结束的同时,程序可以执行其他任务

6. 计算密集型应用 ,为了能在多核处理器系统上运行,将计算分解到多个线程中实现

I/O密集型应用 ,为了提高性能,将I/O操作重叠 。线程可以同时等待不同的I/O操作

进程与线程的区别

1. 进程是系统分配资源 的最小单位,线程是CPU调度和执行的最小单位

2. 进程有自己的内存地址空间 ,线程只独享指令流执行的必要资源,如寄存器和栈

3. 由于同一进程的各线程间共享内存和文件资源 ,可以不通过内核 进行直接通信

4. 线程的创建、切换以及终止效率更高

相关推荐
工边页字1 小时前
为什么 RAG系统里,Embedding成本往往远低于 LLM成本,但很多公司仍然疯狂优化 Embedding?
前端·人工智能·后端
暮冬-  Gentle°1 小时前
用Python批量处理Excel和CSV文件
jvm·数据库·python
m0_736914221 小时前
服务器上pip install spacy卡住解决方法
开发语言·python
冰暮流星1 小时前
javascript之回调函数
开发语言·前端·javascript
二哈赛车手1 小时前
新人笔记---责任链模式
后端
hongtianzai1 小时前
Laravel9.X核心特性全解析
android·java·数据库
qq_417695051 小时前
基于C++的区块链实现
开发语言·c++·算法
电商API_180079052471 小时前
电商平台公开数据采集实践:基于合规接口的数据分析方案
开发语言·数据库·人工智能·数据挖掘·数据分析·网络爬虫
小陈工1 小时前
2026年3月22日技术资讯洞察:数据库优化进入预测时代,网络安全威胁全面升级
java·开发语言·数据库·python·安全·web安全·django