Java-多线程(常用)

首先我们先来了解一些生活中的例子补充点知识。

        我们下载到电脑桌面的QQ程序只是静态的代码,QQ通常是一个可执行文件(在 Windows 系统上可能是 .exe 文件),它是静态的代码,还没有开始运行。这个可执行文件包含了 QQ 程序的指令和数据,但此时它们并没有被执行,因此不构成进程或线程。

        当我们双击启动的时候,就会形成一个进程(实例),这个时候你还可以再次双击,再形成一个进程(类似你要登录两个QQ账号)。登录成功后,你可以进行视频通话,或者语音通话等等,这些就是进程中的功能就可以说是线程。

然后是线程的一些状态:

一、静态代码(可执行文件),进程,线程定义与区别

1、静态代码(可执行文件)

  • 是一组静态指令的集合,存储在磁盘上,等待被加载到内存中执行。
  • 不涉及任何活动执行,不消耗 CPU 时间,不具有独立执行的上下文。

2、进程:

        进程是操作系统进行资源分配和调度的一个独立单位。它是应用程序运行的实例,拥有独立的内存空间。每个进程至少有一个线程,即主线程。进程的特点包括:

  1. 地址空间:每个进程有自己的虚拟地址空间,这意味着它们的数据(变量、数组等)是隔离的,互不干扰。
  2. 全局数据:进程有自己的全局变量和静态变量。
  3. 资源拥有者:进程是资源拥有者,它拥有打开文件、网络连接、设备句柄等资源。
  4. 调度:进程是操作系统调度的最小单位,即操作系统会决定哪个进程在何时运行。
  5. 创建和销毁开销:进程的创建和销毁比线程要重,需要更多的时间和资源。

3、线程:

        线程是进程中的一个实体,是 CPU 调度和执行的单位,它是比进程更小的执行单位。一个进程可以包含多个线程,这些线程共享进程的资源,如内存空间、文件句柄等。线程的特点包括:

  1. 共享资源:同一进程内的线程共享进程的地址空间和资源。
  2. 执行独立:每个线程有自己独立的执行堆栈和局部变量。
  3. 开销较小:线程的创建和销毁比进程要轻,因此开销较小。
  4. 调度:线程是操作系统调度的更小单位,操作系统会决定哪个线程在何时执行。
  5. 并发性:线程允许多个任务并发执行,提高了程序的执行效率。

4、进程与线程的区别:

  • 资源拥有:进程拥有资源,线程共享进程的资源。
  • 开销:进程的创建、销毁和管理开销比线程大。
  • 通信方式:线程间通信更简单,因为它们共享相同的内存空间;进程间通信(IPC)需要特定的机制,如管道、信号、共享内存等。
  • 独立性:进程是独立的执行环境,一个进程的崩溃不会直接影响到其他进程;而线程是进程的一部分,线程的崩溃可能会导致整个进程的崩溃。
  • 上下文切换:线程之间的上下文切换比进程之间的上下文切换要快,因为线程共享了许多状态。

二、多线程

        多线程是指在同一个进程中同时运行多个线程的操作方式。线程是程序执行的独立路径,允许多个任务同时进行。多线程在现代操作系统和多核处理器上是实现并行计算和提高程序性能的常见手段。

1、多线程的特点

  1. 共享内存:同一进程中的多个线程共享进程的内存空间,这使得线程间的数据共享变得容易,但也需要注意线程安全问题。

  2. 资源利用:多线程可以更有效地利用 CPU 资源,特别是当执行 I/O 操作或其他耗时操作时,可以通过执行其他线程的任务来提高 CPU 的利用率。

  3. 并行处理:多线程允许程序执行并行处理,即多个线程可以同时执行,这在多核处理器上尤为有效。

  1. 上下文切换:操作系统负责管理线程的执行,当一个线程等待 I/O 或被操作系统暂停时,CPU 可以切换到另一个线程继续执行。

  2. 简化模型:多线程提供了一种相对简单的并发编程模型,程序员可以利用线程来实现复杂的并发逻辑。

  3. 开销:与进程相比,线程的创建、同步和销毁的开销较小。

  4. 调度:线程的调度(哪个线程在何时运行)由操作系统的调度器负责,程序员可以通过线程优先级等机制来影响调度决策。

2、多线程的应用场景

  1. 用户界面:在图形用户界面(GUI)程序中,多线程可以确保界面的响应性,即使在执行耗时操作时也能接受用户输入。

  2. 服务器:Web 服务器和其他类型的服务器通常使用多线程来同时处理多个客户端的请求。

  3. 计算密集型任务:对于需要大量计算的任务,如科学模拟、图像处理或数据分析,多线程可以显著提高处理速度。

  4. 异步I/O:在需要执行 I/O 操作的程序中,多线程可以避免程序在等待数据时闲置,而是继续执行其他任务。

  5. 实时处理:实时系统或需要快速响应的系统中,多线程可以确保关键任务能够及时执行。

3、多线程的挑战

  1. 线程安全:确保共享数据在多线程访问时的一致性和完整性。

  2. 死锁:避免线程因为相互等待对方持有的资源而无法继续执行。

  3. 竞态条件:当多个线程并发访问和修改共享数据时,可能会导致不可预测的结果。

  4. 上下文切换:频繁的线程切换可能会降低系统性能。

  5. 资源限制:系统中可用的线程数量是有限的,过多的线程可能会导致资源耗尽。

三、Java多线程启动方式

1、第一种启动方式

在 Java 中,启动多线程的第一种常见方式是实现 Runnable 接口(因为实现接口的话还可以继承其他类,方便使用)。以下是通过实现 Runnable 接口来启动多线程的步骤:

1.创建 Runnable 类: 创建一个类实现 Runnable 接口,并重写 run 方法来定义线程的任务。

public class MyRunnable implements Runnable {
    public void run() {
        // 线程要执行的代码
    }
}

2.创建 Runnable 实例: 实例化你的 Runnable 类。

MyRunnable task = new MyRunnable();

3.创建 Thread 对象: 使用 Runnable 实例创建一个 Thread 对象。

Thread thread = new Thread(task);

4.启动线程: 调用 Thread 对象的 start() 方法来启动线程。这将导致 run 方法被调用,从而执行线程的任务。

thread.start();

5.线程执行: 一旦线程启动,它将在 run 方法中执行 MyRunnable 实例的任务。

以下是完整的示例代码:

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 线程要执行的代码
        System.out.println("线程 " + Thread.currentThread().getName() + " 正在执行。");
    }

    public static void main(String[] args) {
        MyRunnable task = new MyRunnable(); // 创建 Runnable 实例
        Thread thread = new Thread(task);   // 创建 Thread 对象
        thread.start();                     // 启动线程
    }
}

在这个示例中,MyRunnable 类实现了 Runnable 接口,并重写了 run 方法。在 main 方法中,我们创建了 MyRunnable 的实例,并用它来创建一个 Thread 对象。调用 start() 方法启动了线程,线程开始执行 run 方法中的代码。MyRunnable 类可以理解为一个任务,创建一个任务,可以创建多个线程来完成这个任务。

        同时要记得,线程只会调用run方法,run方法外的函数,属性是不会调用的,所以在run方法外定义的变量属性可以理解为,都是这个任务的属性,实现这个任务的线程共享这个任务的属性,用的都是同一个属性,可以理解为是静态的,a线程改变属性后,b线程调用这个任务时使用的属性就是a线程改变后的,而不是自己创建的。

        但是如果是run方法中的变量,就不是共享的,而是每个线程独有的,这些一般可以用来区分每个线程。

这是 Java 中启动新线程的基本方法之一。记住,线程启动后,它将与创建它的线程并发运行,直到 run 方法执行完毕或遇到异常。

2、第二种启动方式

Java 中启动多线程的第二种常见方式是继承 Thread 类并重写其 run 方法。以下是通过继承 Thread 类来启动多线程的步骤:

1.创建继承自 Thread 的类: 创建一个类继承自 Thread 类,并重写 run 方法来定义线程的任务。

public class MyThread extends Thread {
    public void run() {
        // 线程要执行的代码
    }
}

2.创建 Thread 的实例: 实例化你的 Thread 子类。

MyThread thread = new MyThread();

3.启动线程: 调用 Thread 对象的 start() 方法来启动线程。这将导致 run 方法被调用,从而执行线程的任务。

thread.start();

4.线程执行: 一旦线程启动,它将在 run 方法中执行 MyThread 实例的任务。

以下是完整的示例代码:

public class MyThread extends Thread {
    @Override
    public void run() {
        // 线程要执行的代码
        System.out.println("线程 " + this.getName() + " 正在执行。");
    }

    public static void main(String[] args) {
        MyThread thread = new MyThread(); // 创建 Thread 的实例
        thread.start(); // 启动线程
    }
}

在这个示例中,MyThread 类继承自 Thread 类,并重写了 run 方法。在 main 方法中,我们创建了 MyThread 的实例,并调用了 start() 方法来启动线程。线程的执行将从 run 方法开始。

继承 Thread 类的启动方式与实现 Runnable 接口的方式相比,有以下不同:

  • 实现 Runnable:需要创建一个实现 Runnable 接口的类,并将其作为参数传递给 Thread 对象。
  • 继承 Thread:需要创建一个继承自 Thread 类的子类,并重写 run 方法。

两种方式都可以实现多线程,但是通常推荐使用实现 Runnable 接口的方式,因为:

  • 单一职责原则:实现 Runnable 接口的方式更加符合单一职责原则,因为线程的逻辑被封装在单独的类中,而不是与 Thread 类的子类混合在一起。
  • 灵活性:一个实现了 Runnable 接口的类可以作为多个线程的对象,而继承自 Thread 的类只能创建一个线程。
  • 避免单继承限制:Java 不支持多重继承,如果一个类已经继承了其他类,那么它就不能再继承 Thread 类。

这种创建线程的方式中,在想要创建多个线程的时候,就需要创建直接用自己的类创建多个线程,这种时候,如果你创建的子线程类中有定义其他属性,那你定义的线程thread1,thread2中的这些属性是独有的,相当于你创建一个学生类,里面有姓名年龄等属性,你创建的学生1和学生2的姓名年龄是特有的。但我们一般会希望多个线程一起改变同一个属性,这个时候就需要在你自己创建的子线程类中将该属性设为static的了。

MyThread thread1 = new MyThread();
MyThread thread2 = new MyThread();

3、第三种启动方式

在 Java 中,除了实现 Runnable 接口和继承 Thread 类之外,还可以使用线程池(ExecutorService)来启动和管理线程。这是推荐的方式,特别是对于需要创建大量线程的应用程序,因为线程池可以有效管理线程的生命周期,重用线程,从而提高性能并减少系统资源的消耗。

以下是使用线程池启动线程的步骤:

1.创建线程池: 使用 Executors 类或 ExecutorService 的实现来创建一个线程池。

ExecutorService executorService = Executors.newFixedThreadPool(3); // 可以指定线程池的大小

2.提交任务: 将任务提交给线程池执行。任务可以是实现了 Runnable 接口的实例,或者实现了 Callable 接口(第四种方式)的实例。

Runnable task = new Runnable() {
    @Override
    public void run() {
        // 线程要执行的代码
    }
};

3.启动线程: 使用线程池的 execute 方法来启动线程。

executorService.execute(task); // 提交 Runnable 任务

或者使用 submit 方法,它可以返回一个 Future 对象,用于跟踪任务的状态。

Future<?> future = executorService.submit(task); // 提交 Runnable 任务并返回 Future 对象

4.关闭线程池: 在所有任务完成之后,关闭线程池以释放资源。(但是一般不会关闭,毕竟上线的应用一般是一直运行的)

executorService.shutdown(); // 请求关闭,不再接受新任务,但会处理已提交的任务

5.等待线程池关闭: 可以选择性地等待线程池完全关闭。

executorService.awaitTermination(1, TimeUnit.MINUTES); // 等待一段时间,直到所有任务完成

以下是完整的示例代码:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class ThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(3);

        for (int i = 0; i < 5; i++) {
            final int threadNumber = i;
            executorService.execute(() -> {
                System.out.println("线程 " + threadNumber + " 正在执行。");
            });
        }

        // 关闭线程池,不再接受新任务,并等待已提交的任务完成
        executorService.shutdown();
        try {
            // 可选:等待所有任务完成,最多等待一分钟
            if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) {
                System.out.println("线程池关闭超时,强制关闭。");
            }
        } catch (InterruptedException e) {
            executorService.shutdownNow(); // 强制关闭线程池
        }
    }
}

在这个示例中,我们创建了一个固定大小的线程池,并提交了 5 个任务。每个任务都是一个匿名内部类,实现了 Runnable 接口。然后我们关闭线程池,并等待所有任务完成。

使用线程池的好处包括:

  • 提高性能:线程池可以重用已创建的线程,避免频繁创建和销毁线程的开销。
  • 控制并发:线程池可以控制并发级别,防止系统因过多的线程而过载。
  • 线程管理:线程池提供了线程的生命周期管理,可以方便地启动、暂停、恢复或停止线程。
  • 提高资源利用率:合理配置线程池,可以提高系统资源的利用率。

线程池是 Java 多线程编程中的一个重要概念,适用于大多数需要多线程的应用程序。

4、第四种启动方式

在 Java 中,除了实现 Runnable 接口和继承 Thread 类之外,第三种常见的启动线程的方式是实现 Callable 接口。Callable 接口类似于 Runnable 接口,但它可以返回结果,并且可以抛出异常。当我们需要返回线程的结果时,就可以使用这种方式。

在 Java 中,实现 Callable 接口的类可以产生一个带有返回值的线程任务。然而,Callable 任务通常与 FutureExecutor(如线程池)一起使用,因为 Executor 负责将 Callable 任务异步地提交给线程执行,并返回一个 Future 对象用于跟踪任务的状态和结果。

以下是通过实现 Callable 接口来启动线程的步骤:

1.创建 Callable 类: 创建一个类实现 Callable 接口,并重写 call 方法来定义线程的任务。

public class MyCallable implements Callable<String> {
    @Override
    public String call() {
        // 线程要执行的代码,并返回结果
        return "线程执行完毕";
    }
}

2.创建 Callable 实例: 实例化你的 Callable 类。

MyCallable task = new MyCallable();

3.使用 ExecutorService 提交任务: 将 Callable 实例作为任务提交给 ExecutorService,它会返回一个 Future 对象。

ExecutorService executorService = Executors.newFixedThreadPool(3);
Future<String> future = executorService.submit(task);

4.启动线程并获取结果: 通过调用 Future 对象的 get 方法来启动线程并等待其结果。

String result = future.get(); // 启动线程并等待结果

5.关闭线程池: 在所有任务完成之后,关闭线程池以释放资源。

executorService.shutdown();

以下是完整的示例代码:

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class CallableExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(3);

        MyCallable task = new MyCallable();
        Future<String> future = executorService.submit(task);

        try {
            String result = future.get(); // 启动线程并获取结果
            System.out.println(result);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            executorService.shutdown(); // 关闭线程池
        }
    }
    
    static class MyCallable implements Callable<String> {
        @Override
        public String call() {
            // 执行线程任务,并返回结果
            return "线程执行完毕";
        }
    }
}

在这个示例中,我们创建了一个实现了 Callable 接口的 MyCallable 类,并重写了 call 方法。然后,我们创建了一个 ExecutorService 实例,并通过它提交了 Callable 任务。通过 Future 对象的 get 方法,我们启动了线程并获取了其返回结果。最后,我们关闭了线程池。

使用 Callable 接口的好处是:

  • 返回结果Callable 任务可以返回结果,而 Runnable 任务不能。
  • 抛出异常Callable 任务可以在 call 方法中抛出异常,而 Runnable 的 run 方法中的异常必须被内部捕获和处理。
  • 灵活性Callable 接口提供了更多的灵活性,尤其是在需要从线程任务中获取结果时。

通过线程池使用 Callable 任务是 Java 多线程编程中的一种强大且灵活的方式。

小结:

四、Java多线程的一些常用方法

 对于这些方法,这个Java多线程(超详细!)-CSDN博客网址的up主讲的很好,可以去看看,以下有一些引用他的。

方法名作用
start()这段代码的任务只是为了开启一个新的栈空间,只要新的栈空间开出来,start()方法就结束了。线程就启动成功了。
启动成功的线程会自动调用run方法,并且run方法在分支栈的栈底部(压栈)。
run方法在分支栈的栈底部,main方法在主栈的栈底部。run和main是平级的。
run()包含线程要执行的代码。在 Thread 类中,run 是空的,需要重写它。不会启动线程,只是普通的调用方法而已。不会分配新的分支栈。(这种方式就是单线程。)
join()等待线程终止。调用 join() 的线程会阻塞,直到目标线程完成执行。
sleep(long millis)使当前线程暂停执行指定的毫秒数。
currentThread()返回对当前线程的引用。
setDaemon(boolean on)将线程设置为守护线程。如果参数 on 为 true,则线程成为守护线程。
yield()提示线程调度器当前线程愿意让出对 CPU 的使用。线程调度器会进行考虑,但不一定立即执行。
interrupt()中断线程。如果线程处于阻塞状态,它可以响应中断并抛出 InterruptedException
isInterrupted()检查当前线程是否被中断。
setName(String name)设置线程的名称。
getName()获取线程的名称。
setPriority(int priority)设置线程的优先级(1~10)默认是5级别。
getPriority()获取线程的优先级。
stop()(已过时)强制线程终止。由于安全原因,此方法不推荐使用。
synchronized关键字用于同步代码块或方法,确保同时只有一个线程可以执行特定的代码段。
Locksjava.util.concurrent.locks 包提供了更复杂的锁机制,如 ReentrantLock
volatile关键字用于声明一个变量在多线程环境中是可见的,即所有线程看到的是同一个变量的最新值。
wait()线程等待某个条件成立。
notify()唤醒在对象上等待的单个线程。
notifyAll()唤醒在对象上等待的所有线程。

1、Thread 的相关方法。

方法名作用
static Thread currentThread()获取当前线程对象
String getName()获取线程对象名字
void setName(String name)修改线程对象名字

在Runnable的run方法中可以用第一个方法获取线程对象,因为这样你才能知道线程对象的名字,只有Thread 类中才有name属性,才能调用get和set方法,当我们没有设置名字时,线程有默认名字,格式为        Thread-0,Thread-1...

2、start()和run()方法

关于start()和run(),这个Java多线程(超详细!)-CSDN博客网址的up主讲的很好,大家可以去看看,以下是他对这两个画的图:

3、sleep方法

static void sleep(long time)                        让线程休眠指定的时间,单位为毫秒
细节:
1、哪条线程执行到这个方法,那么哪条线程就会在这里停留对应的时间
2、方法的参数:就表示睡眠的时间,单位毫秒1 秒= 1000毫秒
3、当时间到了之后,线程会自动的醒来,继续执行下面的其他代码

4、setDaemon守护线程方法

final void setDaemon(boolean on)                        填true设置为守护线程
细节:
        当其他的非守护线程执行完毕之后,守护线程会陆续结束(例如:当女神线程结束了,那么备胎也没有存在的必要了)
注意:陆续:代表着不会立马结束,还可能运行一小会。

五、锁

        在Java 中,同步代码块(Synchronized Block)是一种用于实现线程同步的机制,它通过锁定一个对象来保证代码块内的指令在多线程环境中能够安全地执行。以下是关于同步代码块锁的详细讲解:

1、锁的概念

        在 Java 中,锁通常指的是监视器锁(Monitor Lock),它是 Java 虚拟机提供的锁机制。当一个线程进入同步代码块时,它会尝试获取锁对象的监视器锁。如果锁可用,线程会获得锁并执行同步代码块;如果锁不可用(已被其他线程持有),则请求锁的线程将阻塞,直到锁被释放。

2、锁对象

同步代码块的锁对象可以是任何 Java 对象,但通常有以下几种选择:

  1. 当前实例对象(this):锁定当前实例对象,确保同一时间只有一个线程可以执行该实例的特定同步代码块。

synchronized (this) {
    // 同步代码
}

   补充:当前实例对象指的是含有此同步代码块的类的实例对象,例如学生类,this指的是具体的学生A,而不是线程。比如说多个线程更改学生A的总成绩时,线程B先拿到了this,学生A这个实例对象,其他线程就不能更改,得等到线程B释放this锁。

    2.类对象(ClassName.class):锁定整个类的 Class 对象,实现类级别的同步。

synchronized (MyClass.class) {
    // 同步代码
}

     3.任意对象:使用一个共享对象作为锁,可以实现更细粒度的锁控制。

Object lock = new Object();
synchronized (lock) {
    // 同步代码
}

3、锁的获取与释放

  • 获取锁:当线程执行到同步代码块时,它会尝试获取锁对象的监视器锁。如果锁当前没有被其他线程持有,线程将成功获取锁并执行同步代码块。
  • 释放锁:一旦线程完成同步代码块的执行,或者在同步代码块中抛出异常,它将释放锁对象的监视器锁,允许其他等待的线程尝试获取锁。

4、锁的作用

  • 线程安全:同步代码块确保了共享资源在同一时间只被一个线程访问,从而避免了竞态条件和数据不一致的问题。
  • 性能考虑:虽然同步可以保证线程安全,但也可能成为性能瓶颈,特别是在高并发的情况下。因此,合理设计同步代码块的大小和锁对象的选择至关重要。

5、注意事项

  • 死锁:如果线程 A 锁定了对象 X 并尝试获取对象 Y 的锁,而线程 B 锁定了对象 Y 并尝试获取对象 X 的锁,将导致死锁(线程A和线程B都需要对象X和对象Y的锁才能实现功能,例如AB两个人需要对象XY两根筷子才能吃饭(只有对象XY两根筷子),这个时候AB各自拿了一根,然后就一直等对方释放筷子才能吃饭,就陷入无限等待中,形成死锁)。
  • 锁的粒度:同步代码块应该尽量细粒度,只同步对共享资源的访问,以减少不必要的同步等待时间。
  • 锁的可见性:Java 内存模型保证了锁的可见性,即一个线程对共享资源的修改对其他线程是可见的。

6、示例

假设有一个栈结构,需要保证线程安全的 push 和 pop 操作:

public class Stack {
    private int[] data;
    private int top = -1;

    public Stack(int capacity) {
        data = new int[capacity];
    }

    public synchronized void push(int number) {
        if (top == data.length - 1) {
            throw new StackOverflowError("Stack is full");
        }
        data[++top] = number;
    }

    public synchronized int pop() {
        if (top == -1) {
            throw new EmptyStackException("Stack is empty");
        }
        return data[top--];
    }
}

在这个示例中,pushpop 方法都被声明为 synchronized,这意味着它们会自动进入一个隐式的同步代码块,使用当前实例对象(this)作为锁。这样,任何时候只有一个线程可以执行 pushpop 方法,保证了栈的线程安全。

7、结论

同步代码块是 Java 中实现线程同步的重要机制,通过锁对象来控制对共享资源的访问。合理使用同步代码块可以有效地避免并发问题,但同时也需要考虑性能和死锁等潜在问题。在设计多线程程序时,应该仔细权衡同步的范围和锁的选择,以达到线程安全和性能之间的平衡。

六、同步代码块

在 Java 中,同步代码块(Synchronized Block)是一种用于实现线程同步的机制,它确保同一时间只有一个线程可以执行被同步代码块包裹的代码段。这在多线程环境中对于保护共享资源和防止竞态条件至关重要。

1、同步代码块的语法

同步代码块可以用以下两种方式之一来声明:

1.同步实例方法或实例变量: 使用实例方法或实例变量作为锁对象。这种方式锁定的是当前实例对象,确保同一时间只有一个线程可以执行该实例的同步代码块。

synchronized (instanceVariable) {
    // 需要同步的代码
}

2.同步类方法或类变量: 使用类作为锁对象。这种方式锁定的是整个类的 Class 对象,影响该类的所有实例。

synchronized (MyClass.class) {
    // 需要同步的代码
}

2、同步代码块的作用

  • 线程安全:同步代码块可以保护共享资源不被多个线程同时修改,从而避免数据不一致和竞态条件。
  • 锁的粒度:相比于同步整个方法,同步代码块提供了更细粒度的锁控制,可以减少不必要的同步,提高程序性能。
  • 多个锁:可以使用不同的对象作为锁,实现更复杂的同步逻辑。

3、示例

假设有一个账户类 Account,需要确保在存款操作期间账户余额的一致性:

class Account {
    private String actno;
    private double balance; //实例变量。

    //对象
    Object o= new Object(); // 实例变量。(Account对象是多线程共享的,Account对象中的实例变量obj也是共享的。)

    public Account() {
    }

    public Account(String actno, double balance) {
        this.actno = actno;
        this.balance = balance;
    }

    public String getActno() {
        return actno;
    }

    public void setActno(String actno) {
        this.actno = actno;
    }

    public double getBalance() {
        return balance;
    }

    public void setBalance(double balance) {
        this.balance = balance;
    }

    //取款的方法
    public void withdraw(double money){
        /**
         * 以下可以共享,金额不会出错
         * 以下这几行代码必须是线程排队的,不能并发。
         * 一个线程把这里的代码全部执行结束之后,另一个线程才能进来。
         */
        synchronized(this) {
        //synchronized(actno) {
        //synchronized(o) {
        
        /**
         * 以下不共享,金额会出错
         */
		  /*Object obj = new Object();
	        synchronized(obj) { // 这样编写就不安全了。因为obj2不是共享对象。
	        synchronized(null) {//编译不通过
	        String s = null;
	        synchronized(s) {//java.lang.NullPointerException*/
            double before = this.getBalance();
            double after = before - money;
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.setBalance(after);
        //}
    }
}

class AccountThread extends Thread {
    // 两个线程必须共享同一个账户对象。
    private Account act;

    // 通过构造方法传递过来账户对象
    public AccountThread(Account act) {
        this.act = act;
    }

    public void run(){
        double money = 5000;
        act.withdraw(money);
        System.out.println(Thread.currentThread().getName() + "对"+act.getActno()+"取款"+money+"成功,余额" + act.getBalance());
    }
}

public class Test {
    public static void main(String[] args) {
        // 创建账户对象(只创建1个)
        Account act = new Account("act-001", 10000);
        // 创建两个线程,共享同一个对象
        Thread t1 = new AccountThread(act);
        Thread t2 = new AccountThread(act);

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

在这个例子中,deposit 方法和 getBalance 方法都被包裹在同步代码块中,锁对象是 this,即当前 Account 实例。这意味着在任何时候,只有一个线程可以执行任一方法,从而确保了账户余额的一致性。

4、注意事项

  • 死锁:不当的同步代码块使用可能导致死锁,特别是当多个线程尝试以不同的顺序获取多个锁时。
  • 性能:过度同步可能导致性能下降,因为线程需要等待获取锁。
  • 可读性:同步代码块可能会降低代码的可读性,特别是在涉及多个锁和复杂条件的情况下。

在使用同步代码块时,需要仔细考虑同步的范围和锁对象的选择,以确保线程安全的同时,也能保持良好的性能和代码清晰度。

补充:

多个线程共同对同一个对象操作时,可以在那个线程类中通过构造方法传递对象。(技巧)

七、同步方法

在 Java 中,同步方法是一种通过使用 synchronized 关键字来实现线程同步的方式。同步方法可以确保在同一时间只有一个线程可以执行该方法,从而保护了方法内部对共享资源的访问,防止了多线程并发访问时可能出现的数据不一致和竞态条件问题。

1、同步方法的声明

同步方法可以通过以下两种方式之一声明(锁对象不能自己定义,只能选择格式定义所需的锁对象):

1.实例方法的同步: 在实例方法(非静态方法)前加上 synchronized 关键字,表示锁定当前实例对象(this)。

public synchronized void myMethod() {
    // 方法体
}

2.静态方法的同步: 在静态方法前加上 synchronized 关键字,表示锁定当前类的 Class 对象。

public static synchronized void myStaticMethod() {
    // 方法体
}

2、同步方法的锁对象

  • 实例方法:锁定的是当前实例对象(this)。这意味着每个实例对象都有独立的锁,不同实例的同步实例方法可以被不同线程同时执行。

  • 静态方法:锁定的是整个类的 Class 对象。由于所有实例共享同一个类对象,静态同步方法在类的所有实例之间是同步的。

3、同步方法的实现

同步方法的实现依赖于 Java 虚拟机的监视器(Monitor)机制。当线程执行同步方法时,它会尝试获取方法声明中指定的锁对象的监视器锁。如果锁已经被其他线程持有,请求锁的线程将阻塞,直到锁被释放。

4、示例

以下是 BankAccount 类的一个简化示例,展示了如何使用同步方法来保护对共享资源的访问:

public class BankAccount {
    private double balance;

    public BankAccount(double balance) {
        this.balance = balance;
    }

    // 同步实例方法
    public synchronized void deposit(double amount) {
        balance += amount;
        System.out.println("Deposited " + amount + ". New balance is " + balance);
    }

    // 同步实例方法
    public synchronized void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
            System.out.println("Withdrew " + amount + ". New balance is " + balance);
        } else {
            System.out.println("Insufficient balance to withdraw " + amount);
        }
    }
}

在这个示例中,depositwithdraw 方法都被声明为同步方法,它们使用 this(当前实例对象)作为锁对象。这意味着同个对象时,任何时候只有一个线程可以执行这些方法中的任何一个,从而确保了账户余额的一致性。

5、注意事项

  • 性能:同步方法可以保护共享资源,但也可能成为性能瓶颈,尤其是在高并发的情况下。因此,合理设计同步的范围和粒度是重要的。

  • 死锁:不当的同步方法使用可能导致死锁,特别是当多个线程尝试以不同的顺序获取多个锁时。

  • 可读性:同步方法的使用可以提高代码的可读性,因为它清楚地表明了哪些方法是线程安全的。

  • 替代方案:除了同步方法,还可以使用同步代码块或 java.util.concurrent 包中的锁来实现线程同步。

同步方法是实现线程同步的一种简单而强大的方式,但在使用时需要仔细考虑同步的范围和锁的选择,以确保线程安全的同时,也能保持良好的性能。

6、为什么不能都用当前类的字节码文件对象

        ​​​​​​​在 Java 中,每个类只有一个 Class 对象,这个对象代表了类的结构,包括类的静态变量和方法。如果使用类的 Class 对象作为锁,将会导致以下问题(静态的同步方法锁整个类,非静态的同步方法锁实例对象):

1.实例级别的同步:
        非静态的同步方法通常用于实现实例级别的同步,锁定 this 可以确保每个实例独立同步,互不干扰。
2.类级别的同步:
        如果所有同步方法都锁定类的 Class 对象,那么整个类的所有实例共享同一把锁,这会限制并发性,因为所有实例的同步方法将串行执行。
3.灵活性:
        使用 this 作为锁对象提供了更大的灵活性,允许开发者根据需要选择实例级别的锁或类级别的锁。
4.错误假设:
        如果假设所有同步方法都锁定类的 Class 对象,那么在多线程环境中,非静态的同步方法将无法实现预期的同步行为,因为它们需要锁定调用它们的特定实例。
5.性能考虑:
        
使用类 Class 对象作为锁可能会因为锁的竞争而导致性能下降,尤其是在大量线程操作同一个类的不同实例时。

八、lock锁

在 Java 中,Lock 接口是 java.util.concurrent.locks 包提供的一个用于线程同步控制的更为强大和灵活的锁机制。与使用 synchronized 关键字相比,Lock 提供了更多高级的同步特性和更大的灵活性。

1、Lock 接口的主要方法

Lock 接口定义了多个方法,主要包括:

  1. lock():获取锁。如果锁不可用,调用线程将阻塞,直到锁可用。

  2. lockInterruptibly():可中断地获取锁。如果当前线程在等待获取锁时被中断,该方法将抛出 InterruptedException

  3. tryLock():尝试获取锁。如果锁可用,该方法立即返回 true;如果锁不可用,返回 false,并且线程不会阻塞。

  4. tryLock(long, TimeUnit):尝试获取锁,但等待指定的最长时间。如果在指定时间内锁变为可用,返回 true;否则返回 false

  5. unlock():释放锁。

  6. newCondition():创建与此锁相关的条件对象。

2、使用 Lock 的理由

  1. 可中断的锁获取:使用 synchronized 时,线程在等待获取锁时是不可中断的,除非锁被释放。而 Lock 允许线程在等待锁的过程中被中断。

  2. 可定时的锁获取Lock 允许线程尝试获取锁,但只等待有限的时间,这有助于避免死锁。

  3. 公平性Lock 可以支持公平锁,这意味着等待时间最长的线程将最先获得锁。

  4. 条件对象Lock 允许使用条件对象来实现等待/通知机制,这是 synchronized 关键字所不具备的。

  5. 多个锁的复合Lock 可以更容易地实现多个锁的复合操作。

3、示例

以下是如何使用 ReentrantLockLock 接口的一个实现)来同步代码块的示例:

import java.util.concurrent.locks.ReentrantLock;

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

    public void performAction() {
        lock.lock(); // 获取锁
        try {
            // 受保护的代码
        } finally {
            lock.unlock(); // 释放锁
        }
    }
}

在这个示例中,我们创建了 ReentrantLock 的实例,并在 performAction 方法中使用 lock.lock() 获取锁,并在 finally 块中使用 lock.unlock() 释放锁。这种模式确保了即使在受保护的代码中发生异常,锁也能被正确释放。

4、注意事项

  • 锁的释放:使用 Lock 时,必须在 finally (不论如何,这里面的代码最后一定会执行)块中释放锁,以避免在发生异常时死锁。

  • 锁的复合Lock 可以实现更复杂的锁操作,如尝试获取多个锁,或者在获取锁时设置超时。

  • 性能:在某些情况下,Lock 可能比 synchronized 提供更好的性能,尤其是在锁竞争不激烈的情况下。

  • 使用场景:当需要更细粒度的锁控制或更高级的锁特性时,Lock 是一个很好的选择。

Lock 提供了比 synchronized 更多的灵活性和控制,但也要求开发者更仔细地管理锁的获取和释放。正确使用 Lock 可以实现更高效和更复杂的线程同步。

九、线程池

线程池是一种在程序中预先创建并重复利用一组固定数量的线程的机制,而不是每次需要执行任务时都创建和销毁线程。线程池的主要目的是减少在多线程环境中创建和销毁线程的资源消耗,提高程序的响应速度。

1、线程池的主要组件

  1. 线程池管理器:负责管理线程池中的线程,包括线程的创建、销毁和任务的调度。

  2. 工作线程:线程池中的线程,用于执行提交的任务。

  3. 任务队列:用于存放待执行的任务,线程池中的线程会从这个队列中获取任务并执行。

2、线程池的优点

  1. 资源节约:通过重用已经创建的线程,避免了频繁创建和销毁线程的开销。

  2. 响应速度:当任务到达时,线程池可以立即执行任务,而不需要等待线程创建。

  3. 控制并发:线程池可以控制最大的并发线程数,避免过度并发导致的性能问题。

  4. 提高稳定性:线程池中的线程是受控的,不会出现线程数量无限增长导致系统资源耗尽的情况。

  5. 灵活性:线程池可以有不同的线程创建、调度和管理策略,如固定大小的线程池、单线程池、缓存线程池等。

3、线程池的参数

  1. 核心线程数:线程池中始终保持的线程数量,即使它们处于空闲状态。

  2. 最大线程数:线程池中允许的最大线程数量。

  3. 工作队列:用于存放待执行任务的阻塞队列。

  4. 线程存活时间:非核心线程空闲时在终止前等待新任务的最长时间(含值以及单位)。

  5. 线程工厂:用于创建新线程的工厂。

  6. 拒绝策略:当任务太多,无法被线程池及时处理时,采取的策略。

4、线程池的执行流程

  1. 提交任务:将任务提交给线程池。

  2. 线程池管理:线程池管理器接收到任务后,根据当前线程池的状态和配置参数决定如何执行任务。

  3. 执行任务:工作线程从任务队列中取出任务并执行。

  4. 任务完成:任务执行完毕后,线程返回线程池,等待执行下一个任务。

  5. 线程销毁:如果线程是临时创建的,并且超过了线程存活时间,线程池管理器可能会销毁该线程。

5、Java 中的线程池

在 Java 的 java.util.concurrent 包中,ExecutorService 接口是线程池的核心接口,其实现类 ThreadPoolExecutorScheduledThreadPoolExecutor 提供了线程池的实现。

以下是使用 Executors 类创建线程池的示例:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 使用 Executors 类创建线程池
        ExecutorService executorService = Executors.newFixedThreadPool(3);

        // 提交任务给线程池
        for (int i = 0; i < 5; i++) {
            final int taskNumber = i;
            executorService.execute(() -> {
                System.out.println("执行任务 " + taskNumber + " 由线程 " + Thread.currentThread().getName() + " 处理");
            });
        }

        // 关闭线程池,不再接受新任务,并等待已提交的任务完成
        executorService.shutdown();
    }
}

在这个示例中,我们使用 Executors.newFixedThreadPool 方法创建了一个固定大小的线程池,然后提交了 5 个任务给线程池。线程池中的线程将执行这些任务。

6、注意事项

  • 资源泄露:不正确地管理线程池可能导致资源泄露,如线程池未关闭或任务队列未清空。

  • 死锁:不当的线程池使用可能导致死锁,特别是在任务依赖于多个资源时。

  • 性能调优:线程池的参数需要根据应用程序的具体情况进行调优,以获得最佳性能。

线程池是多线程编程中的一个重要概念,它提供了一种有效的方式来管理线程和任务,提高程序的响应速度和资源利用率。

  • 12
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值