Java多线程(初阶)

【亲测】ubuntu20.4 安装CUDA11.8 一步一步搭建C++ CUDA CUDNN TENSORRT OPENCV DNN ONNXRUNTIME推理环境 阅读详情

在进入多线程的学习之前,我们要先明确进程和线程之间的概念。

一.进程

进程的概念

一个运行起来的程序,就可以称为一个进程。进程是操作系统分配资源的最小单位。每一个进程都有一个用来描述该进程的一个结构体(底层是用C语言写的),即进程控制块(PCB),里面包含了很多很多关于该线程的属性。

PCB中的一些关键属性:

1.pid,即进程id,是进程的身份标识。

2.内存指针,一个进程的执行需要知道要执行的指令在哪里,指令依赖的数据又在哪里,所以,就有了内存指针这个概念。内存指针可以定位读写该线程在内存中存储的数据

3.文件描述符表,进程在运行过程中有时要与硬盘进行交互,而硬盘上的数据则是以文件的形式进行组织的。所以,进程在读取硬盘上的数据的时候,要先打开文件,而每打开一个文件,就会把这个文件的信息保存到文件描述符表中,表中的每一个项都代表了一个打开的文件。

4.进程状态,进程有多个状态,在此,我们先介绍最典型的两个:就绪状态和阻塞状态

就绪状态:即该线程已经准备好,随时都可以到cpu上执行

阻塞状态:即该线程此时并不可以到cpu上进行执行

5.进程的优先级,在一台电脑上,往往都会有着好多线程在执行,而它们到cpu上运行的机会也不是一摸一样的,有些进程的优先级高,cpu的资源就会优先给这个进程,让这个线程先执行,有些进程的优先级低,cpu的资源的就不会优先给它。

注:优先级的高低并不能用来绝对衡量各个进程的执行的先后顺序,它只能作为一个参考,即cpu资源分配的一个倾向,但这并不代表一定要完全按照这样执行。在实际进程的执行中,优先级低的进程也有可能在优先级高的进程之前执行

6.进程的上下文,在cpu的进程调度中,一个进程可能在执行到一半时cpu资源被另一个进程抢走,当cpu资源重新回到这个执行到一半的进程上时,这个进程肯定是要沿着上次运行的状态继续往下执行而不能是从头执行,那么,要怎么保存进程执行到哪里了呢?此时,我们便引入了进程上下文的概念。

进程上下文:即操作系统保存的进程运行的全部现场数据,其是靠PC寄存器来记录执行位置的

记录上下文:把cpu中的PC寄存器的值保存到内存中(PCB的对应属性中)

恢复上下文:把PCB中保存的对应属性的值重新填回对应的PC寄存器中

7.进程的记账信息,是一个记录功能,统计每个进程在cpu上运行了多久,如果发现某个进程好久没有吃到cpu分配的资源了,就会给这个进程倾斜一定的资源。

其中,以上的 4~7 点也并称为进程调度。

二.线程

1.线程的概念

在Java中,进程整体是一个比较“ 重 ”的概念,即创建或销毁一个进程时消耗的cpu资源比较大,并不适合频繁的创建与销毁。为了解决上述问题,我们便引入了线程(Thread)的概念,也可以理解为一个轻量级的进程,创建和销毁消耗的资源更少。

进程和线程的概念辨析:

进程是操作系统资源分配的基本单位

线程是操作系统调度执行的基本单位(即上述的进程调度也可以用于线程当中)

一个进程当中往往包含多个线程,而一个进程中的多个线程之间,共用同一份系统资源(内存,文件资源等)

2.创建线程的几个方法

1.直接创建一个实例类继承 Thread类 并重写run 方法

class MyThread extends Thread{
    @Override
    public void run() {
        System.out.println("Hello Thread");
    }
}
public class Demo1 {
    public static void main(String[] args) {
        MyThread t = new MyThread();
        t.start();
    }
}

2.自定义类实现 Runnable 接口,重写 run () 方法;实例化该自定义类对象作为任务,将其传入 Thread 构造方法创建线程对象,调用 start () 启动线程执行任务

class MyRunnable implements Runnable{

    @Override
    public void run() {
        System.out.println("Hello Thread");
    }
}
public class Demo2 {
    public static void main(String[] args) {
        MyRunnable mr = new MyRunnable();
        Thread t = new Thread(mr);
        t.start();
    }
}

3.创建 Thread 类 的匿名内部类 来创建线程

public class Demo3 {
    public static void main(String[] args) {
        Thread t = new Thread(){
            @Override
            public void run() {
                System.out.println("Hello Thread");
            }
        };
        t.start();
    }
}

4.创建 Runnable 接口 的匿名内部类 来创建线程

public class Demo4 {
    public static void main(String[] args) {
        Runnable r = new Runnable() {
            @Override
            public void run() {
                System.out.println("Hello Thread");
            }
        };
        Thread t = new Thread(r);
        t.start();
    }
}

5.使用 Lambda 表达式实现 Runnable 接口,将线程任务以 lambda 形式传入 Thread 构造方法来创建线程

public class Demo5 {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            System.out.println("Hello Thread");
        });
        t.start();
    }
}

6.使用 Callable 与 FutureTask 实现带有返回值的线程任务,将其传入 Thread 构造方法创建线程对象,在调用 start 方法之后,可以通过调用 task.get() 方法获得执行结果

public class Demo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        //Callable 相当于定义了一个 “带有返回值” 的任务
        //和 Runnable 一样需要交给 Thread对象 去执行
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int result = 0;
                for (int i = 1; i <= 100; i++) {
                    result += i;
                }
                return result;
            }
        };
        //Thread 并不适配 Callable 
        //使用 FutureTask 将 Callable 包装成 Runnable 类,适配 Thread
        FutureTask task = new FutureTask<>(callable);
        Thread t = new Thread(task);
        t.start();
        //get 操作是获取到 task 的返回值,这个返回值来自 Callable 中的 call 方法
        //要等到执行任务的线程执行完毕 get方法 才会得到返回结果,否则会发生阻塞
        System.out.println(task.get());
    }
}

3.守护线程的概念

守护线程,我们也可以将其理解为后台线程,后台线程往往是JVM自带的线程(比如说垃圾回收线程),它们的存在与否并不影响进程的结束,当整个进程结束时,即使这些线程还没有运行结束,也会随着进程的结束而强制结束。我们自己创建的线程默认都是前台线程(这些线程的存在会影响进程的执行,前台线程不结束整个进程也一般不会结束),可以通过setDeamon 方法进行修改

4.线程等待

有些时候,我们希望一个线程在另一个线程运行结束之后才会接着进行自己的下一步工作,就比如说在生活中商人要在别人付完钱后才会把对应的商品给别人,虽然说我们可以通过 sleep 休眠的时间来控制线程结束的顺序,但这种方法并不科学,有的时候我们是希望一个线程结束,另一个线程就紧接着结束了,此时通过设定休眠时间的方式就不一定靠谱。

为了达成以上目的,线程当中就引入了join这个方法

举个例子,看如下代码:

public class Demo6 {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                System.out.println("hello thread");
            }
            System.out.println("thread线程结束!");
        });
        t.start();
        System.out.println("main线程结束!");
    }
}

此时,我们希望的是 t 线程先结束,main 线程再紧接着结束,但结果真的会和我们想的一样吗?当我们运行代码之后,就出现了以下运行结果

根据运行结果来看,这个代码的运行结果和我们预期的结果并不相符,这明明是 main 线程先结束了吧?那么,为了达成我们想要的 t 线程先结束的预期,我们就要用到上面所说的 join 方法来实现,于是,我们便完成了以下的改进代码(注意,join 方法会抛出 InterruptedExceptiono 异常,要进行抛出或 try catch 捕获处理)

public class Demo6 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                System.out.println("hello thread");
            }
            System.out.println("thread线程结束!");
        });
        t.start();
        t.join();
        System.out.println("main线程结束!");
    }
}

此时的运行结果如下

可见,main 线程这次确实是在 t 线程执行完毕后紧接着执行结束的

在 main 线程中,调用 t.join 方法,就可以让 main 线程等待 t 线程先结束后再接着执行 main 线程,当 main 线程执行到 t.join 这条语句时,main 线程就会 "阻塞等待" ,一直等到 t 线程执行完毕后再接着执行剩下的代码

join 的空参方法并没有设定等待的最大时间,此时若 t 线程一直不结束,那么 main 线程也要一直等待下去。有时候,我们并不希望这样的效果发生,此时,我们就可以使用 join 带一个整型参数的方法(单位是 ms),规定好该线程等待另一个线程的最大时间,当超过这个时间但另一个线程还没结束时,该线程就会放弃等待,接着执行剩下的代码。

5.线程存活

Java代码创建的 Thread 对象和系统中的线程是一一对应的关系,但是,Thread 对象的生命周期和系统中线程的生命周期是不同的(可能存在系统中的线程已经销毁但 Thread 对象仍然存活的情况)

以如下代码为例

public class Demo7 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                System.out.println("hello thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t.start();
        for (int i = 0; i < 10; i++) {
            System.out.println(t.isAlive());
            Thread.sleep(1000);
        }
    }
}

运行结果如下


以上述代码的逻辑来说,t 线程的 run 方法 应该 3s 之后就结束了,而当线程入口方法(run 方法)的逻辑结束之时,系统中的对应的线程就随之销毁了,当但我们调用 t.isAlive 方法来查看 t 对象的存活状态时,发现其打印了四次 true !也就是说,在第四次打印之时,t 对象还是存活的!当然,因为系统随机调度的存在,我们并不能保证每次都会打印四个 true ,这是不一定的。但这并不影响其证明了上述括号内容的存在性与真实性。

6.线程终止

线程终止,即让一个线程能够结束,我们只需要让该线程的入口方法(即 run 方法)执行完毕,线程就随之结束了。

在Java中,Thread对象提供了现成的变量,用来直接判定该线程是否终止,并不需要自己进行创建,可以通过 isInterrupted 方法进行判定(线程还未终止结果为 true,已经终止结果为 false,默认为true),可以通过 interrupt 方法进行修改(将 true 改为 false)。

示例

public class Demo8 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()){
                System.out.println("hello thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    //代码执行到这里有三种选择
                    //1. 直接退出    
                    //2. 再执行一些语句之后再退出
                    //3. 忽略 interrupt 方法,接着执行循环内容

                    //当 interrupt 方法唤醒 sleep 方法时会抛出该异常导致进程终止
                    //throw new RuntimeException(e);
                    //若不想导致进程终止可以不抛出异常直接 break 出循环即可
                    break;
                }
            }
            System.out.println("t 线程结束");
        });
        t.start();

        Thread.sleep(3000);
        System.out.println("main 线程尝试终止 t 线程");
        t.interrupt();
    }
}

注意点:

1.在该示例 Demo 中,创建线程的方法为用 lambda 表达式创建,有一个问题是 lambda 表达式是在线程创建之前就创建好的,所以说,在 lambda 表达式中调用 isInterrupt 方法不能直接通过对象名进行调用(此时该对象 t 还未实例化),要通过 currentThread方法进行调用(哪个线程调用该方法返回的就是哪个线程)

2.在用 interrupt 方法唤醒 被 sleep 等方法让线程进入阻塞状态的线程时会抛出 InterruptedException

3.调用 interrupt 方法会修改 isInterrupted 方法内部的标志位,将其设为 true,但是,在我们这样提前唤醒 sleep 的情况下,sleep 又会在唤醒之后又将 isInterrupted 方法内的标志位重新设置回 false,因此,在这样的情况下,如果上述代码中没有 break 语句在捕获异常后退出循环,就会发现线程还会继续执行。像这样 "非强制性" 的线程终止操作可以让Java中的代码更加灵活,不是 main 线程中让 t 线程终止 t 线程就会立马终止,选择权还是在 t 线程的手上(具体看 t 线程内的代码怎么写)

7.线程休眠

在 Thread 类中,让线程进入休眠的方法是 sleep。

sleep 方法的特性:

1.静态方法,让当前线程主动进入阻塞(休眠)状态,可以指定休眠的时间,休眠时间结束后自动进入就绪状态,等待 cpu 的调度执行

2.因为 cpu 的调度是不可控的,所以,这个方法只能保证实际的休眠时间大于设定的休眠时间(cpu 重新调度到此线程上需要一定的时间)

3.sleep 方法不会释放锁,如果一个线程中持有 synchronized 同步锁,sleep 方法会使线程带着锁一起休眠

4.sleep 方法在休眠过程中被 interrupt 方法提前唤醒会抛出 InterruptedException 异常,且将 isInterrupted 方法内的标志位重新修改为 false,由该线程内部的代码逻辑决定是否真正终止线程(可见第六点线程终止处的解释)

8.线程状态

在上文中,我们提到,线程是一种 "轻量级" 进程,即线程的状态和进程的状态的表示是一样的,也是有 就绪 和 阻塞 两种状态。不过,这是在操作系统的角度上进行看待的,而 Java 线程对操作系统线程进行了封装,而对线程状态而言,Java 中有更细致的表示

六种线程状态如下:

1.NEW :new 了一个 Thread 对象但还没 start,即安排了线程任务但还没开始执行

2.TERMINATED : 线程任务已经执行完毕,内核中的线程已经结束但 Thread 对象仍然存活

3.RUNNABLE : 可工作的,又可以分为正在执行工作或即将开始工作,即就绪状态

以下三种状态都表示线程在阻塞等待其他事情

4.TIMED_WAITING : 指定时间的等待,有时间上限

5.WAITING :未指定时间的等待,死等

6.BLOCKED :由于 锁 导致的阻塞等待

9.线程安全

由于多线程的并发执行代码引起的 bug 就叫做 "线程安全问题",反之,如果一个代码不存在这样的 bug 则称这样的代码是线程安全的

引起线程安全问题的原因:

1.cpu 的随机调度,线程的抢占式执行(根本原因)

2.多个线程同时针对同一个变量进行修改

3.修改操作不是原子的(修改操作可以分为多步执行)

4.内存可见性

5.指令重排序

在我们写代码时,肯定是不希望代码出现 bug 的,那么,我们应该怎么解决上述的 "线程安全问题" 呢?很简单,我们只需要从线程安全问题形成的原因入手,破坏满足这些原因的条件,就可以解决线程安全问题

其中,原因1 是操作系统方面的问题,我们并不能进行修改(当然,如果大佬们有能力重写一套操作系统来避免这个问题的话那就是另一回事了~~),所以,我们要从原因 2 ~ 5 来解决线程安全问题

9.1 使多个线程串行执行(解决原因2)

示例代码如下:

public class Demo9 {
    public 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.join();
        t2.join();
        System.out.println("count : " + count);
    }
}

如上述代码所示,t1 线程 和 t2 线程都对 count 变量自增了 50000 次,按照我们的预期,该程序的输出结果应该是 100000,可当我们运行程序时,发现结果如下图所示

由运行结果可知,count 的值远小于预期的 100000,这也就是我们上文所说的线程安全问题,这个代码的线程安全问题是由原因 1 ~ 3 导致的

由原因2 可知,这是由线程安全问题是由多个线程同时修改一个变量的值导致的,所以,只要我们不让这多个线程 "同时" 修改而是一个接着一个进行修改,即使这多个线程串行执行,就可以解决该线程安全问题。修改后代码如下:

public class Demo9 {
    public 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();
        t1.join();//在 t1 线程执行完毕后再开始 t2 线程
        t2.start();
        t2.join();
        System.out.println("count : " + count);
    }
}

如上,我们只需要让 t2 线程在 t1 线程执行完毕后再开始执行,就可以打破原因2的形成条件,从而解决线程安全问题,得到正确的结果

可是,在实际运用中,我们往往不会使多个线程穿行化执行来解决线程安全问题,因为这样就完全失去了多线程的并发执行的意义,使多线程退化为单线程,执行效率低下

9.2 使修改操作变为原子的(加锁,解决原因3)

在介绍这个解决线程安全问题的方法之前,我们先了解一下 Java 中锁的概念与特性

9.2.1 锁的概念

锁是控制多线程访问共享资源的同步机制,可以保证同一时间,最多只有一个线程可以对共享资源进行修改,可以用来解决由原子性带来的线程安全问题

9.2.2 锁的特性

1.互斥性 : 一把锁同一时刻只能被一个线程持有,其他线程想要获取锁,只能阻塞等待

2.可重入性 : 同一个线程已经持有这个锁,再次获取这把锁不会发生阻塞,不会发生死锁问题

3.阻塞等待 : 抢锁失败的线程进入阻塞队列,让出 CPU,不消耗算力,等锁释放后 JVM 唤醒竞争

通过上述对 锁 的概念和特性的介绍,我们同学们已经对锁有了一个初步的认知,接下来,我们就来演示如何用锁来解决该线程安全问题

我们已知,该线程安全问题是由于 count++ 操作的非原子性造成,即 count++ 还可以分为以下三步:

1.load :从内存中读取 count 的值到寄存器中

2.add :在寄存器中对 count 的值进行自增

3.save :将寄存器中自增后的值写回内存之中

而由于 cpu 的资源调度是随机的,在 t1 线程执行到 count++ 中的 add 操作时(假设是第一次自增操作,load 读取到的值为 0),cpu 的执行权可能就到了 t2 线程上,当 t2 线程从内存中读取 count 的值时,读取到的还是未修改过的值(值为 0),add 操作自增后为 1,再写回内存中。此时 cpu 的执行权又给到 t1 线程,而 t1 线程接着执行未完成的 save 操作,也将 1 写入内存中。像这样,虽然 t1 和 t2 线程都执行了一次自增操作,但 内存中的 count 的值却是 1,相当于只执行了一次自增,而另一次自增操作则被覆盖掉了。这也就是由于自增操作的非原子性导致的线程安全问题。

利用锁来解决由于自增操作的非原子性而导致的线程安全问题也很简单,只要将希望变成原子性的操作(此时为自增操作)放在锁中(synchronized)即可,修改后的代码如下:

public class Demo9 {
    public 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 < 50000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count : " + count);
    }
}

在 synchronized 后面的小括号中的参数为锁对象(可以传入任意类型的变量作为锁对象),不同的锁对象对应着不同的锁,而要解决多个线程之间的线程安全问题,我们要用同一把锁(即要传入的锁对象要相同)将其中导致线程不安全的非原子性操作锁起来,使其变为原子性的操作。像这样,我们就能得到正确的结果。

9.2.3 死锁问题

死锁问题分为以下两种:

1.一个线程,一把锁,连续加锁多次

2.两个线程,两把锁,每个线程获取到一把锁之后,接着尝试区获取对方的锁

死锁形成的原因

1.互斥性: 一把锁同一时刻只能被一个线程持有,其他线程想要获取锁,只能阻塞等待

2.不可剥夺、不可抢占性:当一个线程持有锁时,这个锁就不能被其他线程剥夺与抢占

3.请求与等待:未持有锁的线程只能等待持有锁的线程主动释放锁后才能尝试获取锁

4.循环等待:对应上述第二种情况,在两个线程分别持有一把锁的情况下在去尝试获取对方的锁,就回造成循环等待,谁都获取不到对方的锁,进而一直处于等待状态

解决死锁问题的方法

1.避免锁的嵌套 -> 打破原因3 (在Java中由于锁的重入性已经帮助我们解决了这第一种情况的死锁问题,不过在C++等编程语言中要注意避免该问题)

2.规定加锁的顺序 -> 打破原因4

9.3 添加 volatile 关键字(解决原因4 和 原因5)

内存可见性问题形成的原因

每个线程都有一个属于自己的 "工作内存"(并不是实际意义上的内存,实际上指的是 cpu 的寄存器),同时这些线程又都共享这同一个 "主内存"(实际意义上的内存)。当一个线程循环从主内存读取变量时,会同时将主内存中读取到的数据拷贝到自己的工作内存(寄存器)中。当线程发现循环读取的变量长时间都是同一个值时,就不会再去主内存中读取数据,而是直接在自己的工作内存(寄存器)中读取。而如果另一个线程修改主内存的数据时,也是先修改自己工作内存(寄存器)中的数据再拷贝到主内存中。而此时由于第一个线程仍然在读取自己的工作内存(寄存器)中的数据,也就感知不到主内存的数据已经发生了变化,这个问题就是内存可见性问题

解决内存可见性问题的方法很简单,只需要我们在可能发生内存可见性问题的变量的前面添加上 volatile 关键字 即可,使用这个关键字修饰的变量,就属于 "易变" 、 "易失" 类型的变量,当线程读取这类型变量时,就必须到主内存中读取数据了(不会优化为从工作内存中读取数据)。

指令重排序问题形成的原因

在介绍指令重排序问题之前,我们先来讲解一下该问题的引子——单例模式

单例模式其实就是程序员大佬们编写好的一种设计模式,就类似于围棋棋谱类的东西,针对一些典型的场景的代码的书写给出了一些指导方案。当我们学会了这些设计模式之后,在面对相同或相似的场景之时,我们便能快速上手,写出较为不错的代码

两种单例模式
1.饿汉模式

饿汉模式,顾名思义,就代表了急切,就要尽早地对其中的成员变量进行初始化,例如以下代码所示

class Singleton{
    private static Singleton instance = new Singleton(100);

    public static Singleton getInstance(){
        return instance;
    }
    private Singleton(){

    }
    private Singleton(int n){

    }
}
public class Demo10 {
    public static void main(String[] args) {
        Singleton t1 = Singleton.getInstance();
        Singleton t2 = Singleton.getInstance();
        System.out.println(t1 == t2);
    }
}

我们可以看到,该代码中的 Singleton 类在加载时就完成了成员变量的初始化,而且,其中的构造方法都是以 private 修饰的,外部无法创建新的 Singleton 对象,只能通过 Singleton 类提供的 getInstance 方法来获取实例对象

2. 懒汉模式

懒汉模式,顾名思义,就代表了懒惰,与饿汉模式急切地初始化成员变量相对,懒汉模式就是尽量晚地创建实例甚至是不创建实例了(当没有代码调用这个实例的时候就不会创建),例如以下代码所示

class SingletonLazy{
    private static SingletonLazy instance = null;

    public static SingletonLazy getInstance(){
        if(instance == null){
            instance = new SingletonLazy();
            return instance;
        }
        return instance;
    }
    private SingletonLazy(){
        
    }
}
public class Demo11 {
    public static void main(String[] args) {
        SingletonLazy s1 = SingletonLazy.getInstance();
        SingletonLazy s2 = SingletonLazy.getInstance();
        System.out.println(s1 == s2);
    }
}

我们通过上述代码可以发现,只有我们第一次去创建一个 SingletonLazy 实例时,静态成员变量 instance 才会被赋值(而不是在程序刚启动的时候),这也就是尽可能晚地创建实例对象。

那么,说到现在,肯定就有同学要疑惑了,这单例模式和本文要讲的多线程又有什么关系呢?别急别急,且听我娓娓道来。

现在,我们先来思考一个问题,上述的两份代码(饿汉模式代码和懒汉模式代码),是否是线程安全的呢?如果不是,我们又该怎么让它们变成线程安全的呢?

通过对上述两份代码的分析我们可以知道,饿汉模式中的 instance 成员变量是程序一加载时就已经创建好的,后续的代码只涉及 return 的读操作,不涉及变量修改,也就是说饿汉模式的代码是线程安全的。

而懒汉模式呢?它是否是线程安全的呢?我们分析代码发现,懒汉模式的代码中的 instance 成员变量是当我们第一次调用 getInstance 方法时才创建的, 这就有可能涉及到了多线程的修改,也就是说,懒汉模式的代码是线程不安全的。这时,我们就可以通过加锁来使其线程安全。加锁代码如下

class SingletonLazy{
    private static volatile SingletonLazy instance = null;
    private static Object locker = new Object();

    public static SingletonLazy getInstance(){
        synchronized (locker){
            if(instance == null){
                instance = new SingletonLazy();
                return instance;
            }
            return instance;
        }
    }
    private SingletonLazy(){

    }
}

此时,这份懒汉模式的代码确实是线程安全了,但是,这样的代码是否又会引发新的问题呢?

通过对代码的分析我们发现,当我们将 instance 实例创建好了之后,后续我们再调用 getInstance 方法时,都是直接执行 return 操作,也就是说,此时的 getInstance 方法变成了纯粹的 "读" 操作,也就不会涉及到线程安全问题。但是,当我们每次调用 getInstance 方法时,都会触发一次加锁操作,这就会导致线程之间的相互阻塞,影响程序的执行效率。为了解决因为过度加锁而导致的效率降低问题,我们就要做到按需加锁,在真正涉及到线程安全问题时再加锁,不涉及的时候就不加锁。那么,在上述的代码中,我们要怎么做到按需加锁呢?其实也很简单,我们只需要在加锁之前先判断 instance 是否已经被创建实例,如果已经创建,那么就直接执行 return 操作。优化后的代码如下所示:

  public static SingletonLazy getInstance(){
        if(instance == null){
            synchronized (locker) {
                if (instance == null) {
                    instance = new SingletonLazy();
                    return instance;
                }
            }
        }
            return instance;
    }

此时,我们只有在 instance 未创建实例时才会进行加锁并创建实例的操作,也就避免了非必要情况下加锁所带来的效率降低问题。

也许有的同学会有疑问,这个代码此时的两个 if 判断的条件是一样的啊,这难道不是代码重复,没意义的吗?这就是因为我们之前接触的都是单线程的代码所带来的疑惑。确实,在单线程代码中,连续两个相同的 if 条件判断是无意义的(因为单线程中只有一个执行线程,连续两个 if 条件判断的结果是一样的)。不过,我们此时的代码是多线程代码,在两个 if 条件判断之间,就可能存在其他线程将 instance 变量的值给修改了,也就导致了两次 if 条件判断的结果不同。

诚如上述代码,虽然说这两个 if 条件判断的语句是一样的,但它们所起的作用是截然不同的。第一个 if 条件判断是判断 instance 变量是否已经被实例化从而判断接下来是否进行加锁操作,保证了程序不会因没意义的加锁所导致的执行效率降低问题。而第二个 if 条件判断则是判断是否有其他线程在第一个 if 条件判断 和 加锁操作 之间创建了 instance 实例从而导致 instance 实例被重复创建所导致的线程安全问题。

到这里还没完,我们再仔细分析,上述的代码仍然还是存在问题的。我们可以思考一下,这代码是否会出现 "内存可见性" 问题呢?这完全是有可能存在的,因为编译器的优化是非常复杂的操作,为了稳妥起见,我们可以用 volatile 关键字来修饰 instance 成员变量,从根本上杜绝 "内存可见性" 问题。

但是,这里最重要的问题是可能存在的 "指令重排序" 问题。皇天不负有心人,我们兜兜转转,终于回到了我们这小标题中的 "指令重排序" 问题。

指令重排序问题

"指令重排序" 问题也是编译器优化的一种体现形式,编译器会在保持原有代码逻辑不变的前提下,调整代码的执行顺序,以达到提升性能的效果。

就比如说上述的 instance = new SIngletonLazy(); 操作,就会在 JVM 分为以下三步:

1.申请内存空间  2.在空间上构造对象  3.将内存空间的首地址赋值给引用变量

在正常情况下,这三个步骤按照 1 -> 2 -> 3 的顺序来执行的。但是,在指令重排序下,就有可能按照 1 -> 3 -> 2 这样的顺序来执行。在单线程中,这三个步骤的执行顺序是无所谓的,但是在多线程的情况下,就可能会出现 bug !

举个例子,一个线程(线程1)按照 1 -> 3 -> 2 这样的顺序执行时,当这个线程执行完 步骤3 之后,cpu资源就被调度到了另一个线程(线程2)上,在线程2眼中,instance 变量的值已经不为空了,那么当线程2 用 instance 变量去执行相关操作时,就有可能引发一系列问题,因为此时的 instance虽然已经指向了一个地址,但这地址指向的对象却还是未初始化过的!这也就是由于 "指令重排序" 而引起的线程安全问题。

而要解决 "指令重排序" 带来的问题也很简单,想必有的同学已经想到了,没错!就是你们所想的那样,就是用 volatile 关键字来修饰可能出现 "指令重排序" 问题的变量,这样操作之后,关于该变量的读取和修改操作就不会触发 "指令重排序"。

volatile 关键字的两个功能小结:

1. 确保每次读取变量操作都是从内存中读取

2.关于该变量的读取和修改操作不会触发重排序

10. 生产者和消费者模型

在介绍生产者和消费者模型之前,我们要先了解两个线程方法:wait 和 notify。这两个方法是用来协调线程之间的执行逻辑的顺序的。

10.1 线程的等待和唤醒

10.1.1 wait 等待方法

说到线程的等待方法,或许,已经有同学回想起来了,我们在前面不是已经学过一个线程等待方法 join了吗?怎么这里又来一个线程等待方法 wait 呢?同学们先别着急,我们这就来说明他两的区别。

wait 和 join 虽然都是线程等待方法,但它们的使用时机和用途却是截然不同的。join 方法的功能是等待目标线程执行完毕,其中并不涉及到锁的操作。而 wait 方法的功能则是让当前线程释放锁,进入等待队列,等待被其他线程唤醒,wait 方法的 使用前提 是必须处于必须处于 锁 的synchronized 代码块中。

和 join 类似,wait 方法也提供了 "死等" 和 "超时时间" 的版本,wait 引入超时时间后,就和 join 方法一样,最多等那么久,等了这么久如果还没 notify 就不等了,就会接着往下执行。

10.1.2 notify 唤醒方法

notify 方法 :随机唤醒一个正在 等待(wait)的线程

notifyAll 方法 :唤醒正在 等待(wait)的全部线程

注意:notify 方法只能唤醒 "同一个锁对象" 的等待线程!notify 方法的使用要在 wait 方法之后,否则就是无效代码。

注:wait 方法通常都要搭配着 while 循环 (为了进行二次确认) 进行使用,在每次 wait 返回都重新循环校验条件是否成立,可以有效避免 虚假唤醒、多线程竞争后唤醒条件已经失效 等问题

接下来,我们再来学习下阻塞队列

10.2 阻塞队列

在之前的数据结构中,我们已经学习过了 "队列" 和 "优先级队列" ,而所谓的阻塞队列,其实也就是一种更复杂的队列。

阻塞队列的特性

1.线程安全:阻塞队列内部通过加锁和两个条件变量(notEmpty,notFull)来实行并发控制,是线程安全的

2.阻塞特性:

当队列为空时,尝试出队列,出队列操作就会被阻塞,直到其他线程添加元素为止

当队列为满时,尝试入队列,入队列操作就会被阻塞,直到其他线程弹出元素为止

阻塞队列一个最主要的应用场景就是用来实现 "生产者消费者模型"

10.3 生产者消费者模型的两个重要优势

1.解耦合(不一定是两个线程之间,也可能时两个服务器之间)

例如两个服务器之间直接相互请求与相应的示例图如下图所示

像这样两个服务器之间直接相互访问的场景下,如果服务器A直接访问服务器B,那么此时服务器A与服务器B的耦合就比较高,因为为了实现 A 直接访问 B,在编写 A 的代码时,多多少少会涉及到一些与 B 相关的逻辑。同理,在编写 B 的代码时,也多多少少会有一些与 A 相关的逻辑。

那么,如果在这两台服务器之间引入了阻塞队列来进行中转又会怎样呢?示例图如下所示

像这样,服务器A和服务器B通过阻塞队列进行交互,就变成了 A与队列交互 和 B与队列交互,此时 A 和 B 就不再直接交互了,A的代码中就不会再有关于B的逻辑,B的代码中也不会有A的逻辑了,我们这就实现了 A 与 B 的解耦合

但是,此时就会有同学有有疑问了,你这样通过阻塞队列来进行服务器A和服务器B的交互时,你 A 与 B 之间的耦合是降低了,但是你 A 与 阻塞队列 、B 与 阻塞队列的耦合度上升了啊?这样也算降低耦合吗?在这里,我想说的是,降低耦合的目的,是为了让后续对代码修改更方便,而阻塞队列作为一个 A 与 B 之间的交互桥梁,一般不会进行修改,也就是说 服务器A 与 阻塞队列、服务器B 与 阻塞队列的耦合不会影响后续代码的修改操作。

之前服务器A 与 服务器B 直接交互带来的耦合问题就是,在后续修改 A 的代码的时候也要同时去修改 B 的代码

2.削峰填谷

像这样 A 与 B直接交互的代码

当 A 这边遇到流量激增时,A 会将每一个请求都转发给 B,B 也会同时承担相应的流量激增,服务器B 就很有可能因为这个而崩溃

而像 A 与 B 通过阻塞队列进行交互的代码

则是在 A 遇到流量激增时,会先把每一个请求放入阻塞队列中,当阻塞队列放满了服务器A 放入请求的操作就会被阻塞起来,而相应的,服务器B 依旧是可以按照自己的节奏去处理在阻塞队列中的请求,也就不会出现服务器B 崩溃的情况

10.4 生产者消费者模型付出的代价

1.在引入阻塞队列之后,代码的整体结构会变得更加复杂,管理起来会更加麻烦

2.代码的执行效率会受到影响

10.5 用阻塞队列实现生产者消费者模型

阻塞队列的使用

在Java标准库中,提供了现成的阻塞队列 -- BlockingQueue 

 接下来,我们就来演示以下阻塞队列的用法,演示代码如下:

public class Demo12 {
    public static void main(String[] args) throws InterruptedException {
        BlockingDeque<String> queue = new LinkedBlockingDeque<>(100);
        for (int i = 0; i < 100; i++) {
            queue.put("aaa");
        }
        System.out.println("队列已经满了");
        System.out.println("再次尝试 put 元素");
        queue.put("aaa");
        System.out.println("put 成功");
    }
}

该代码中创建 BlockingQueue 实例时传入的参数是指定该阻塞队列的最大容量,即该队列最多只能放 100 个元素

当我们运行该代码时,发现执行结果如下图所示

我们可以发现,该代码的运行并没有结束,而是停留在了打印 "put 成功" 之前,这是为什么呢?这又不得不说到阻塞队列的阻塞特性了。我们知道,该阻塞队列的最大容量是 100 ,但在 for 循环中已经向该阻塞队列放入了 100 个元素,此时这个阻塞队列就已经被放满了,再执行 put 操作时,就会发生阻塞,直到该阻塞队列有元素被 take 出去才会接着执行 put 操作

阻塞队列的实现

在了解了阻塞队列的功能之后,我们就可以自己去试着实现一个自己的阻塞队列,实现代码如下所示

class MyBlockingQueue {
    private String[] data = null;
    private int head = 0;//队头
    private int tail = 0;//队尾
    private int size = 0;//存储的元素个数

    public MyBlockingQueue(int capacity){
        data = new String[capacity];
    }
    public void put(String elem) throws InterruptedException {
        synchronized (this){
            //队列是满的,生产者就要进行等待
            while (size >= data.length){
                this.wait();
            }
            //计算新的队尾下标
            data[tail] = elem;
            tail++;
            if(tail >= data.length){
                tail = 0;
            }
            size++;
            //生产者生产完元素后唤醒消费者接着去消费元素
            this.notify();
        }
    }
    public String take() throws InterruptedException {
        synchronized (this){
            //队列是空的,消费者就要进行等待
            while (size == 0){
                this.wait();
            }
            //记录返回元素并计算新的队头下标
            String ret = data[head];
            head++;
            if(head >= data.length){
                head = 0;
            }
            size--;
            //消费者消费元素后唤醒生产者去接着生产元素
            this.notify();
            return ret;
        }
    }
}
生产者消费者模型代码实现

生产者消费者模型的原理就是 生产者往阻塞队列中生产元素,消费者从阻塞队列中取出元素进行消费,基于此,我们可以写出以下代码

public class Demo13 {
    public static void main(String[] args) {
        BlockingDeque<Integer> queue = new LinkedBlockingDeque<>(1000);
        Thread producer = new Thread(() -> {
            int n = 0;
            while (true){
                try {
                    queue.put(n);
                    System.out.println("生产者生产元素"+n);
                    n++;
                    //Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        },"producer");
        Thread consumer = new Thread(() -> {
            while (true){
                Integer n = null;
                try {
                    n = queue.take();
                    System.out.println("消费者消费元素"+n);
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        },"consumer");
        producer.start();
        consumer.start();
    }
}

像以上代码所示,生产者线程往 阻塞队列 queue 中放元素,而消费者线程则从 阻塞队列 queue 中消费元素,这就是最基础的生产者消费者模型,运行结果如下图所示

因为我们在消费者线程中加了 sleep 休眠 ,所以生产者线程会在消费者线程休眠期间快速生产1000个元素放满 阻塞队列 queue ,然后发生阻塞。接下来就是消费者线程消费一个元素,生产者线程就生产一个元素。这也就是所谓的生产者消费者模型。

11. 线程池

线程池,与常量池类似,线程池中的线程也是在 Java 程序最初构建的时候就已经准备好的,等到程序运行的时候,这些线程也就加载到内存中了。最初我们因为频繁创建和销毁进程消耗了大量的cpu 资源,所以引入了线程的概念,然而,随着互联网的发展,我们对于性能的要求越来越高,频繁创建和销毁线程而产生的开销,也慢慢变得难以接受了。发明线程池的目的,就是为了让我们能够节省出创建和销毁线程的开销。

在 Java 标准库中提供了可以直接使用的线程池 ThreadPoolExcutor,该类的创建所需要的参数比较多且较为重要,下面我们来一一讲解

ThreadPoolExcutor 类 参数的含义

1. int corePoolSize 核心线程数                                                                                                    

这表明这个线程池中至少有多少个线程(线程的最少数目),线程池一创建,这些线程也就随之创建,直到整个线程池销毁,这些线程才会销毁

2.int maximumPoolSize 最大线程数                                                                                               

这表明这个线程池中最多能创建多少个线程(核心线程数 + 非核心线程数),其中非核心线程数在任务繁忙时就创建,任务不繁忙时就销毁

3.long keepAliveTime 非核心线程允许空闲的最大时间                                                               

非核心线程并不是一空闲下来就立马销毁,在其中还有一个允许空闲的最大时间,如果一个非核心线程空闲超过最大时间之后,这个非核心线程就会被销毁

4.TimeUnit unit                                                                                                                             

 枚举类型,在其中将各个时间单位通过枚举保存

5.BlockingQueue<Runnable> workQueue 工作队列                                                                     

线程池本质上就是一个生产者消费者模型,调用 submit 方法就是在往工作队列中生产任务,我们可以通过这个参数来指定要使用的工作队列的数据结构(如数组、链表等),并指定容量大小(capacity),指定是否要带有优先级/比较规则

6. ThreadFactory threadFactory 工厂模式                                                                                   

工厂模式(也是一种设计模式,与单例模式并列),主要是用来统一地构造并初始化线程,用来弥补构造方法的缺陷(有时候不同的构造方法之间并不能构成重载,但这两种构造方法又的确是不同的版本时,就要用到工厂方法进行解决)。工厂方法的核心就是通过静态方法去把构造对象(new) 的过程(各种属性初始化的过程)进行了封装,通过提供多组静态方法来实现不同的构造方法。提供工厂方法的类,就称为工厂类。

7. RejectedExecutionHandler handler 拒绝策略(整个线程池的七个参数中最重要最复杂的参数)

任务队列是一个阻塞队列,当队列满了时,我们再调用 submit 方法往里面放入新的任务时,就会产生阻塞,而我们一般是不希望程序阻塞太多的(线程就没办法去做别的事情了,用户就会迟迟拿不到相应,导致使用体验不好)。对于线程池来说,当调用 submit 方法发现任务队列已经满了时,并不会真的阻塞,而是执行拒绝策略相关的代码。

四种拒绝策略

        ThreadPoolExecutor.AbortPolicy :线程直接抛出异常(线程可能无法继续工作)

        ThreadPoolExecutor.CallerRunsPolicy :让调用 submit 方法的线程自行执行该任务

        ThreadPoolExecutor.DiscardOldestPolicy :丢弃队列中最老(最先放入)的任务

        ThreadPoolExecutor.DiscardPolicy :丢弃队列中最新的任务,即当前 submit 的这个任务

12. 定时器

计时器的定义

 定时器就和闹钟一样,时间到了,就会去执行一些逻辑。在 Java 标准库中提供有已经定义好的定时器 Timer 。当我们创建好一个 Timer 实例后就可以通过该实例调用 schedule 方法来添加任务(TimerTask)并指定在多长时间之后(单位为毫秒)执行。其中,描述任务的方式稍有不同,正常描述任务是用 Runnable ,而在定时器中则是将 Runnable 封装成了 TimerTask 来进行使用。

简单实用定时器的示例代码

public class TimerDemo {
    public static void main(String[] args) {
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("hello 3000");
            }
        },3000);
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("hellp 2000");
            }
        },2000);
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("hello 1000");
            }
        },1000);
        System.out.println("hello main");
    }
}

输出结果如下:

注:此时 Timer 工作线程(前台线程)会持续阻塞等待任务,控制台不会停止运行。

自己实现一个简单计时器

实现一个计时器可以简单分为四个步骤:

1.创建一个类来表示任务

2.使用优先级队列(小根堆,仅根据任务的执行时间来比较大小,要实现 Comparable 接口并重写compareTo 方法 或 传入一个比较器)来管理多个任务

3.实现 schedule 方法,将任务添加到队列里面去

4.在构造方法中创建一个线程,负责执行队列中的任务

所完成的代码和注意事项如下:

class MyTimerTask implements Comparable<MyTimerTask>{
    private Runnable task;
    private long time;
    public MyTimerTask(Runnable task,long time){
        this.task = task;
        this.time = time;
    }

    @Override
    //重写比较方法,依靠等待时间来进行排序并放入小根堆中
    public int compareTo(MyTimerTask o) {
        return (int)(this.time - o.time);
    }
    //获取一个任务的等待时间
    public long getTime(){
        return time;
    }
    public void run(){
        task.run();
    }
}
//自己实现的定时器
class MyTimer{
    //小根堆,用来存储任务(依靠等待时间的大小进行排序)
    private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();
    private Object locker = new Object();

    public void schedule(Runnable task,long time){
        //涉及到修改堆,加锁防止线程安全问题
     synchronized (locker){
         MyTimerTask timerTask = new MyTimerTask(task,System.currentTimeMillis()+time);
         queue.offer(timerTask);
         //添加任务后唤醒线程
         locker.notify();
     }
    }
    public MyTimer(){
        Thread t = new Thread(() -> {
                try {
                    while (true){
                        synchronized (locker){
//                            如果堆为空,那么进行等待
//                            如果使用if条件判断并在不符合条件时continue退出本次循环,
//                            就会触发“忙等”,会大量消耗cpu资源
//                            if(queue.isEmpty()){
//                                continue;
//                            }
                            while(queue.isEmpty()){
                                locker.wait();
                            }
                            //从堆顶取出最早执行的任务
                            MyTimerTask task = queue.peek();
                            //如果还没到该任务执行的时间,那么就进行等待
                            if(System.currentTimeMillis() < task.getTime()){
                                //continue; //和上文一样,会触发“忙等”问题
                                //不能使用sleep方法进行等待
                                // 一是因为sleep只能等待完对应的时间如果有时间更早的任务被添加进来的话不能及时执行。
                                // 二是因为sleep休眠是不会释放锁的,这就会导致其他线程调用schedule方法时会产生阻塞
                                locker.wait(task.getTime() - System.currentTimeMillis());
                            }else {
                                //反之则运行该任务并从堆中弹出该任务
                                task.run();
                                queue.poll();
                            }
                        }
                    }
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

        });
        t.start();
    }
}
public class MyTimerDemo {
    public static void main(String[] args) {
        MyTimer timer = new MyTimer();
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello 3000");
            }
        }, 3000);
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello 2000");
            }
        }, 2000);
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello 1000");
            }
        }, 1000);
    }
}

以上就是本片文章的全部要点,欢迎各位读者阅读与指正 !!!

基于多模态融合的反无人机视觉跟踪算法研究 本研究提出了一种基于多模态融合的反无人机视觉跟踪算法,通过整合可见光、红外、深度等多种视觉信息源,提高了对无人机的跟踪精度和可靠性。设计并实现了多模态特征提取方法,针对不同模态图像的特点提取了有效的特征信息,为多模态融合提供了基础。研究了多种多模态融合策略,包括特征级融合、决策级融合和混合融合,并通过实验对比分析了它们的优缺点,确定了适合反无人机视觉跟踪的融合方法。选择并改进了跟踪算法,将多模态融合信息融入到跟踪过程中,提高了算法对复杂环境和无人机运动变化的适应性。 阅读详情

相关推荐

德国冯哈勃 Faulhaber2342L012空心杯减速电机带编码器

为了制作平衡车,我在淘宝上面买了一个 Faulhaber2342L012空心杯减速电机电机,具体参数如下: 一:电机参数 型    号:德国冯哈勃Faulhaber带编码器空心杯减速电机2342L012  12V每分 钟 120转 工作电压: 12V 空载转速:8100RPM(转每分钟) 减速后速:120RPM(转每分钟) 直     径:30mm

u011511430的专栏 1万+

【Java EE初阶 --- 多线程(初阶)】多线程的基本内容

既然有多线程,那么是不是也应该有单线程?答案是肯定的。其实单线程也叫进程,由上篇博客知道,进程中有多个线程,那么只有一个线程的进程就是单线程。我们的main主线程,之前的认知,main方法执行完毕,程序(进程)就结束了,那是因为之前没有接触到多线程程序,这篇博客将为我们认识多线程。。。

optimistic_chen的博客 1260

汉字跨越三千年的生命力与数字时代适应性

文字作为文明载体,其演变规律始终遵循实用性与适应性的平衡。汉字作为现存最古老的文字系统,凭借形义结合的六书构造原理,实现了超方言的文化统一功能。在计算机处理方面,五笔输入法与Unicode支持证明了其技术兼容性,而视觉识别效率的先天优势使其在信息时代更具传播效力。从甲骨文识别AI到现代书法设计,汉字系统持续展现强大的文化承载力与技术适应性,这种活态传承为数字人文研究提供了独特样本。

weixin_29058331的博客 355

Java多线程编程初阶指南

实现方式代码行数可复用性灵活性推荐指数继承Thread5-10低差★☆☆☆☆常规Runnable6-8高中★★★☆☆匿名内部类3-5低高★★★★☆Lambda表达式1-3中极高★★★★★​核心定义​​:当多个线程并发访问共享资源时,程序仍能保持正确行为的状态​​黄金准则​原子性(Atomicity)保证可见性(Visibility)保证有序性(Ordering)保证使用没有共享资源的模型适用共享资源只读,不写的模型不需要写共享资源的模型。

2301_80720675的博客 1569

Ollama模型路径迁移实战:Windows/Mac/Linux三系统保姆级教程(附常见问题排查)

本文提供了一份详细的Ollama模型路径迁移教程,涵盖Windows、macOS和Linux三大操作系统。针对默认安装路径占用系统盘空间的问题,文章指导用户如何安全移动模型文件、配置环境变量、重启服务,并附有迁移后的验证步骤与常见问题排查方法,帮助用户高效管理本地AI模型存储。

gray5的博客 543

从零构建NVENC编码器:一个游戏开发者的实战手册

本文详细介绍了如何从零构建NVENC编码器,特别针对游戏开发者提供实战指南。内容涵盖NVENC基础概念、DirectX纹理捕获、异步编码优化及高级特性配置,帮助开发者高效集成硬件编码技术到游戏引擎中,实现高性能游戏录制和直播功能。重点解析了NVIDIA专用编码芯片的优势与API使用技巧。

weixin_29292431的博客 365

java初阶多线程2

String- 不可变性:String是不可变对象,创建后自身不能修改。设计为不可变的原因之一是天然的线程安全 ,且不可变对象方便存放到常量池中,其hashCode是固定值,便于和哈希表结合作为hash的key。- 示例: new String 创建的对象本身不能修改。- StringBuffer:本身可被修改,但通过加锁等机制解决线程安全问题。- StringBuilder:线程不安全,适用于单线程环境下的字符串操作。

j202312101422的博客 1052

SAP MM模块后续借记、后续贷记、贷方凭证简介

在SAP系统中,后续借记、后续贷记和贷方凭证是处理供应商发票、采购和付款过程中常见的财务交易。

我小时候很黑的博客 3270

java初阶多线程3

问题描述:N个哲学家围绕圆桌就餐,每个哲学家需要拿起左右两根筷子才能吃面条。假设某一时刻,所有哲学家都同时拿起左手筷子,都在等待右手筷子(在别人手中 ),且都不会放弃左手筷子,就会陷入无限等待,造成死锁。这是N个线程争用M把锁形成死锁的典型场景。- 死锁的危害:线程一旦出现死锁,就会“卡死”,无法继续执行后续逻辑,导致程序出现严重bug。虽然死锁发生概率可能较低,但一旦发生在高访问量系统中(如每天有大量访问的服务器 ),即使概率为万分之一,也会影响众多用户,所以必须重视。- 死锁的四个必要条件。

j202312101422的博客 866

使用geneHapR进行基因单倍型分析(以vcf文件为例)

如下图所示,可以看到,用户需要准备的包括:基因型文件、注释文件和样本信息文件,以及后续会用的表型文件。输出的主要内容有:变异位点的可视化、单倍型网络、地理分布、表型比较和连锁不平衡热图。geneHapR的工作流程。

沉香best的博客 5941

《Android最全面试题-Offer直通车》目录

如今Android求职是僧多粥少,竞争非常激烈,企业招聘时要求很高,本专栏汇聚android面试各种考点,各大公司Android面试题,《Android 最全面试题》将助你全方位提升,助您脱颖而出,早日拿到满意Offer。本专栏汇聚了海量优质内容,文章数量超过100 篇。凭借过硬的质量,每月新增订阅用户数以百计,如今仅需特惠价 9.9 元,限时抢购,后续将逐步恢复原价,早订阅早开启逆袭之旅。精心筹备,整理出一系列超万字精华篇章,累计数十万的 Android 面试题深度剖析。

大模型大数据攻城狮的专栏 2867

java初阶多线程1

讲解Java多线程知识,从基础创建方式、常用属性方法,到线程状态转换,再深入到线程安全核心问题。理解这些内容,有助于开发者在Java多线程编程时,正确创建和管理线程,合理处理线程间协作与同步,规避线程安全风险,编写出高效、稳定的多线程程序。

j202312101422的博客 402

90. 将 SAP ABAP 内表内容本地导出成 Excel 文件

这里文件扩展名为 csv,全称是 Comma-Separated Values,意思是这种类型的文件内容,由逗号分隔。除了 ASC 之外,另一个可用的选项是 BIN,适用于 PDF,EXE 等二进制文件的导出。支持海量的输入和输出参数,我们这个简单的本地导出场景里,大部分参数都用不上。这个分隔符的添加逻辑,编写在上图红色高亮矩形框内,使用的语法是。本教程有学习者向我提问,询问如何将 ABAP 某某函数的某某输出参数的值,导出成本地 Excel 文件。把这个内表的内容,下载到本地,以本地文件的方式保存。

2007 年 ~ 2025 年,深耕 SAP 技术 18 年 1743

【Java EE初阶 --- 多线程(初阶)】多线程的实现案例

之前博客对多线程的是什么和基本内容都有详细了解,目前对于多线程的运用还很浅显,不能发挥出多线程应有的实力。这篇博客将带来多线程的基本应用,它会用到什么地方?又会带来什么高效的运行效率?我们又会学到什么?诸位尽情期待…

optimistic_chen的博客 1243

文档:亲测可用解决vnc灰屏,黑屏,鼠标大黑叉情况

主要解决vnc-viewer灰屏,黑屏,鼠标大黑叉等现象,详细见文档。

android 蓝牙通讯demo

android 下蓝牙通讯demo,实现了发现蓝牙,使能蓝牙,通过蓝牙发送消息。

Java多线程初阶-线程协作与实战案例

本篇笔记深入探讨Java多线程进阶技术。首先讲解`wait`/`notify`机制,实现线程间的精准协作。接着,通过实战代码剖析了多种核心并发设计模式:确保对象唯一的单例模式、解耦利器生产者-消费者模型、资源复用的线程池(并优化了其关闭机制),以及任务调度的定时器。最后总结了保证线程安全的三大核心思路,帮助开发者构建健壮的并发应用。

weixin_73966418的博客 1591

efm32 入门.pdf

本PDF共计22页,介绍的是操作驱动API,下载与否自己斟酌。

java初阶多线程4

基本用法和特点: synchronized 用于实现线程同步,保证同一时刻只有一个线程能访问被同步的代码块或方法。它可修饰方法和代码块,如 synchronized (锁对象) { } ,锁对象可以是任意对象。- 可重入性:指的是同一个线程在持有锁的情况下,再次进入被该锁同步的代码块或方法是允许的。例如,一个递归方法被 synchronized 修饰,在递归调用过程中,线程不需要重新获取锁。- 死锁问题- 死锁场景举例- 一个线程连续两次尝试获取同一把锁,会导致死锁。

j202312101422的博客 1021

精雕软件(JDpaint) 5.5 绿色版

精雕软件(JDpaint) 5.5 绿色版精雕软件(JDpaint) 5.5 绿色版精雕软件(JDpaint) 5.5 绿色版

java初阶多线程5

概念: BlockingQueue 是Java并发包中的一个接口,它支持阻塞的插入和移除操作。当队列满时,插入操作会阻塞等待空间可用;当队列空时,移除操作会阻塞等待元素可用。- 优点- 减少资源竞争,提升效率:通过阻塞机制,避免了线程不断轮询队列状态,减少了CPU资源浪费,提高了整体效率。- 降低耦合:生产者和消费者之间通过队列进行通信,解耦了两者之间的直接依赖关系。- 错峰平谷:可以在生产和消费速度不均衡时,缓冲数据,平滑流量。- 缺点。

j202312101422的博客 581

Java多线程初阶

认识Java多线程

qq_46209845的博客 764

JAVA EE_多线程-初阶(一)

我们可以先把进程理解为一个医院的门诊部,而门诊部是一个大类,这里面又有多个门诊,例如:内科门诊,外科门诊,妇科门诊,如果让张三一个人来做是负责不过来的,所以张三叫来了李四,王五,一个人负责一个门诊,这样就大大提高了效率。而张三,李四,王五就是线程,即多线程,其中张三是,因为李四线程和王五线程是由张三线程叫来的。

2401_82914354的博客 1339

【Java EE初阶】多线程-初阶

使⽤没有共享资源的模型适⽤共享资源只读,不写的模型a. 不需要写共享资源的模型b. 使⽤不可变对象直⾯线程安全(重点)a. 保证原⼦性b. 保证顺序性c. 保证可⻅性1.线程的优点创建⼀个新线程的代价要⽐创建⼀个新进程⼩得多与进程之间的切换相⽐,线程之间的切换需要操作系统做的⼯作要少很多线程占⽤的资源要⽐进程少很多能充分利⽤多处理器的可并⾏数量在等待慢速I/O操作结束的同时,程序可执⾏其他的计算任务。

2402_85428625的博客 2466

Java 多线程初阶

ps:使用jdk内置的jconsole程序可以观察多线程的运行状态。

libralucy的博客 1401

Java之多线程初阶2

发现串行的总耗时比并行的总耗时还要短,因此我们可以大胆推测,并不是所有的场景下多线程的效率都是最高的,当我们的运算量很小的时候,创建线程的时间比代码运行的时间还短,这样子显然是不适合用多线程的.我们之前使用的线程都是前台线程,因为创建线程之后默认都是前台线程,必须手动设置成为后台线程,我们通过 thread.setDaemon(true);后台进程:在一些容错率高的任务,可以采用,如微信的步数计算,并不需要精确计算你精确的步数,不影响主线程的关闭..线程的中断:停止或者中断当前线程的任务.

qq_64580912的博客 1868

Java之多线程初阶

指的是一个任务或进程按顺序逐个完成,一个任务完成后,另一个任务才开始执行。例如,一个计算机需要处理多个任务,这个时候串行是一个任务执行完毕之后,才能处理下一个任务.指的是。在中,实现并发需要快速地在之间。例如,一个计算机需要同时运行多个应用程序,每个应用程序都有一个单独的进程,操作系统需要在这些进程之间快速地切换。指的是,每个任务在上。并行需要来同时执行。例如,一个大型计算任务被分成多个子任务,每个子任务分配到不同的处理器或计算机上执行。在计算机领域中,并行和并发的概念通常被用来。

qq_64580912的博客 1206

Java 多线程 初阶

Java 多线程 初阶 并发与并行 并发:多个线程在一段时间内交替运行,某一时刻只有一个线程在运行 并行:多个线程在某一时刻同时运行 进程和线程 进程:是拥有系统资源的基本单位,有独立的地址空间,切换开销较大 线程:是系统调度的基本单位,线程本身不拥有系统资源,同属一个进程的多个线程共享进程中的资源,没有独立的地址空间,只是进程的不同执行路径,切换开销较小 多线程的优缺点 优点:多线程可...

David的博客 218

Java EE初阶——初识多线程

线程是操作系统进行运算调度的最小单位,包含在进程中,共享进程资源但拥有独立的栈空间和寄存器。线程的引入解决了进程创建/销毁成本高、上下文切换开销大、通信困难等问题,提高了资源利用率和程序并发性。线程通过共享地址空间降低内存开销,优化程序结构,降低上下文切换成本。Java线程基于操作系统线程实现,通过JVM创建和管理。创建线程的方法包括继承Thread类、实现Runnable接口、使用匿名内部类和lambda表达式。Thread类提供了管理线程的常见方法,如设置守护线程、获取线程状态等。线程的启动通过star

2302_80250536的博客 1072

【JAVA EE初阶】多线程(上)

不同的操作系统之间不兼容,java却有“跨平台”特性,不需要任何修改,就可以在不同的系统上完成同样的功能,原因在于Java虚拟机,不同的主流系统都有各自的Java 虚拟机,Windows有Windows JVM,Linux有Linux JVM,这些JVM是不同的程序,但是上层支持的Java字节码是一致的。当一个线程循环进行上述读取变量操作的时候,就会把主内存中的数据,拷贝到该线程的工作内存中,后续另一个线程修改,也是先修改自己的工作内存,拷贝到主内存中。等先走的线程解锁,后走的线程才能加上锁,继续执行。

2301_80802299的博客 949
上一篇: MySQL--存储过程
下一篇: Java多线程(进阶)
七星-落凡
博客等级 码龄1年 24粉丝 18原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值