并发编程入门

一、并发基础

1. 串行、并行、并发、高并发的区别

  • 串行:任务按照一定的顺序逐个执行,无任何重叠。比如在单核CPU环境下,多个任务只能依次执行。
  • 并行:多个任务在同一时刻同时进行。这通常发生在多核处理器或分布式系统中,每个任务在一个独立的计算资源上运行。
  • 并发:从宏观角度看,多个任务在一段时间内看似同时进行,但实际上可能是交替执行。并发不等同于并行,它强调的是任务间的执行关系而非物理上的同时进行。例如,在单核CPU上通过时间片轮转实现的多线程就是并发。
  • 高并发:指系统能够同时处理大量并发请求的能力。在互联网应用中,高并发通常是指在短时间内系统能够应对大量用户请求而不至于崩溃或响应过慢。

例子:你正在做饭,切菜、炒菜、煮饭,如果只能一个接一个完成,那就是串行;如果你可以同时切菜、炒菜(有两个锅),那就是并行;如果你用一个锅,先炒一会菜,然后煮饭,再炒菜,交替进行,那就是并发。

2. 线程的创建方式

1.继承 Thread 类:
public class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("Thread running: " + Thread.currentThread().getName());
        // 执行具体的线程任务
    }

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

优点

  • 编写相对直接,可以通过 this 关键字直接访问当前线程的一些属性或方法。

缺点

  • 由于Java不支持多重继承,继承 Thread 类会限制该类继承其他类的能力,对于那些需要继承特定类的场景不适用。
2.实现 Runnable 接口:
public class RunnableTask implements Runnable {
    @Override
    public void run() {
        System.out.println("Runnable running: " + Thread.currentThread().getName());
        // 执行具体的线程任务
    }

    public static void main(String[] args) {
        RunnableTask task = new RunnableTask();
        Thread thread = new Thread(task, "MyRunnableThread");
        thread.start(); // 启动线程
    }
}

优点

  • 具有更好的灵活性,因为Java支持多接口实现,这样定义的类可以继承其他类的同时还能实现多线程。
  • 适合多个相同线程处理同一份资源的情况,只需共享同一个 Runnable 实例即可。
  • 遵循面向接口编程原则,使得代码更加模块化。

缺点

  • 编程稍显复杂,如果需要访问当前线程,需要使用 Thread.currentThread()。
3.实现 Callable 接口:
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;

public class CallableTask implements Callable<String> {
    @Override
    public String call() throws Exception {
        System.out.println("Callable running: " + Thread.currentThread().getName());
        // 执行计算并返回结果
        return "Result from callable";
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CallableTask task = new CallableTask();
        FutureTask<String> futureTask = new FutureTask<>(task);
        Thread thread = new Thread(futureTask, "MyCallableThread");
        thread.start();

        // 获取并打印线程执行结果
        String result = futureTask.get();
        System.out.println("Callable result: " + result);
    }
}

优点

  • 支持返回值和异常处理,适用于需要异步计算结果的场景。
  • 结合 Future 或 FutureTask,可以方便地进行结果的同步获取、取消任务等操作。

缺点

  • 相对于前两种方式,代码结构更复杂,需要处理 Callable、FutureTask 和线程之间的关系。
4.使用 ExecutorService 和 Executor 框架(线程池):
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class ThreadPoolExample {

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(2); // 创建固定大小的线程池

        executor.submit(() -> {
            System.out.println("Runnable task running in thread pool: " + Thread.currentThread().getName());
            // 执行具体的线程任务
        });

        executor.submit(new CallableTask()); // 提交 Callable 任务

        // 关闭线程池
        executor.shutdown();
        executor.awaitTermination(5, TimeUnit.SECONDS);
    }
}

优点

  • 提供了一种统一的方式来管理和控制线程,包括线程的创建、销毁、调度以及对线程池的监控。
  • 能够有效复用线程,减少线程创建和销毁的开销,提高系统性能。
  • 支持任务队列、任务拒绝策略、线程数量控制等功能,提供了更高级的线程管理机制。

3. 线程生命周期

  • 新建状态(New):当使用new Thread()或者实现Runnable/Callable接口并通过new Thread(Runnable target)/new FutureTask(Callable callable)创建线程对象时,线程就进入了新建状态。此时,线程对象已经被创建,但还没有开始运行。
  • 就绪状态(Runnable):当调用线程的start()方法后,线程就进入了就绪状态。此时,线程已经准备好运行,但可能还没有被分配到CPU时间片。JAVA虚拟机会为其创建方法调用栈和程序计数器。线程的执行是由底层平台控制,具有一定的随机性。
  • 运行状态(Running):当线程获得CPU时间片并开始执行时,线程就进入了运行状态。此时,线程正在执行其任务中的代码。
  • 阻塞状态(Blocked):线程可能因为多种原因进入阻塞状态,无法继续执行。这些原因包括:调用wait()方法,等待其他线程唤醒; 试图获取一个对象的同步锁,但该锁被其他线程持有; 调用sleep()或join()方法,或发出I/O请求; 其他任何导致线程暂停执行的原因。
  • 等待状态(Waiting):当线程需要等待某些条件满足时,比如其他线程的通知或某个特定时间过去,线程会进入等待状态。
  • 计时等待状态(Timed Waiting):这是等待状态的一个变种,线程需要等待一定时间或者等待某些条件满足。可以通过sleep(long timeout)、wait(long timeout)或Object.wait(long timeout, int nanos)等方法实现。
  • 终止状态(Terminated):当线程完成了任务,或者因为异常等原因退出时,线程就进入了终止状态。此时,线程对象仍然存在,但已经不再是一个活动的执行线程。

4. 线程的关闭:stop()与interrupt()的区别

1. stop()
  • stop()已废弃: stop()方法早在Java 1.2版本就被标注为@Deprecated,明确指出不应再使用。这意味着它在现代Java编程中被视为过时且不安全的操作。
  • 强制终止:stop()方法会立即、强制地中止目标线程的执行,无论线程当时处于什么状态或正在做什么操作。这种粗暴的终止方式类似于直接“拔掉电源”,不给线程任何清理资源或保存状态的机会。
  • 数据不一致性风险: 强制终止可能导致数据结构处于不一致状态,因为线程可能在修改数据的中途被停止,没有机会完成预期的更新逻辑或释放相关锁。
  • 锁资源问题: stop()方法会立即释放线程持有的所有锁,这可能导致其他线程对这些锁的预期控制顺序被打乱,进而引发难以预料的并发问题。
  • 异常处理缺失: 由于线程突然终止,任何未捕获的异常、资源泄漏或其他应由线程自身处理的清理逻辑都可能得不到执行。
2. interrupt()
  • 中断请求而非立即终止: interrupt()方法不会立即停止线程的执行,而是设置线程的中断状态,并通知该线程应该中断其当前活动。线程是否真正停止取决于其自身的协作。
  • 中断标志与响应: 调用interrupt()后,受影响的线程可以通过检查自身的中断状态(使用isInterrupted()方法)来得知中断请求的存在,并根据需要采取相应行动,如提前结束循环、关闭资源或执行清理工作。
  • 阻塞操作的响应: 当线程处于sleep()、wait()、join()、I/O阻塞等可中断的阻塞状态时,对其调用interrupt()将导致这些方法抛出InterruptedException。捕获此异常后,线程通常会清除中断状态并优雅地退出阻塞操作。
  • 安全与协作: interrupt()方法遵循线程间的协作原则,允许线程在安全点检查中断请求并适时退出,从而避免了数据不一致性和锁资源问题。它鼓励程序员编写能够响应中断信号的线程代码,确保资源释放和状态一致性。
3.stop()方法与interrupt()方法在关闭线程时的主要区别在于:
  • 安全性与可靠性: stop()方法由于其强制、即时且不考虑上下文的终止行为,存在严重的数据不一致性和并发问题风险,已被弃用。而interrupt()方法通过设置中断标志和线程间的协作机制,提供了更安全、可控的线程终止方式。
  • 控制粒度与优雅性: stop()直接终结线程,不给线程任何自我清理的机会。相反,interrupt()允许线程在接收到中断请求后执行必要的清理工作,确保资源释放和状态一致性,实现优雅退出。
  • 响应性与协作: interrupt()依赖线程对中断请求的主动响应,线程需在其代码中适当位置检查中断状态并做出相应处理。这种设计鼓励线程间的协作与沟通,使得程序更具可维护性和可预测性。

因此,在现代Java编程中,强烈建议使用interrupt()方法而非stop()方法来关闭线程,以保证程序的稳定性和数据完整性。

二、Synchronized原理

1. 锁的范围

  • 类对象锁:锁住整个类,影响该类的所有实例。
  • 实例对象锁:锁住对象实例,不影响其他实例。
public class MyClass {  
    public static synchronized void staticMethod() {  
        // 使用类对象锁  
    }  
  
    public synchronized void instanceMethod() {  
        // 使用实例对象锁  
    }  
}

2. 锁的升级

Java中的synchronized锁为了提高性能,会经历偏向锁、轻量级锁、重量级锁的升级过程。

  1. 偏向锁(Biased Locking):偏向锁是 Java 6 引入的一种锁优化,它的目的是减少无竞争情况下的同步开销。当一个线程首次访问同步代码块或方法时,锁对象会被标记为偏向该线程,后续该线程再次进入同步代码块或方法时,无需进行任何同步操作。如果出现了其他线程尝试获取该锁的情况,偏向锁就会被撤销,升级为轻量级锁。
  2. 轻量级锁(Lightweight Locking):轻量级锁是为了减少线程之间因争夺锁而导致的上下文切换开销。当一个线程尝试获取轻量级锁时,如果锁当前被其他线程持有,那么该线程会通过自旋(忙等待)来尝试获取锁,而不是立即阻塞。如果自旋一定次数后仍未获取到锁,那么轻量级锁就会升级为重量级锁,此时线程会被阻塞。
  3. 重量级锁(Heavyweight Locking):当轻量级锁升级为重量级锁时,会涉及到操作系统的调度,因此上下文切换的开销会比较大。在重量级锁的状态下,其他尝试获取该锁的线程会被阻塞,直到持有锁的线程释放锁。

例子
假设您经营一家只有一台收银机的小超市。为了管理顾客结账的秩序,您需要实施一种排队策略。随着超市业务的变化,您可能会调整这个策略以适应不同的情况。

  • 无锁状态(无人结账): 超市刚开业时,没有任何顾客结账,收银台处于闲置状态。
  • 偏向锁(常客频繁结账): 一段时间后,您发现大部分时间只有一位常客(线程)在购物后结账。为了简化流程,您为这位常客设立了一个专属通道(偏向锁)。当常客来到收银台时,只需确认通道上写着自己的名字(Mark Word 记录线程ID),就可以直接开始结账,无需额外手续。
  • 轻量级锁(偶尔有其他顾客): 某天,另一位顾客(线程)也来购物。常客通道已被占用。但是他也来到常客通道并在通道口放置一块写有自己名字的牌子进行等待(Lock Record)(轻量级锁),如果此时常客恰好完成结账离开,这位顾客就可以直接进入快速通道结账。如果常客还在结账中,顾客就原地等待一小会儿(自旋),看看常客是否会很快离开。但如果常客结账时间过长,顾客就会放弃等待,转而请求设立正式的排队通道(重量级锁)。
  • 重量级锁(高峰期多顾客排队):随着超市人气渐旺,经常有多位顾客同时结账。此时,您决定启用正式的排队系统(重量级锁)。每个顾客到达收银台时,都需要在指定的等候区排队,并由收银员(操作系统)按照顺序依次叫号。虽然这种方式增加了管理成本(线程阻塞、上下文切换),但确保了结账秩序,避免了混乱。

三、Volatile

Volatile是Java语言提供的一种用于修饰共享变量的关键字,它主要服务于多线程环境中的内存可见性和指令重排序控制。简单来说,volatile关键字具有以下两个核心作用:

1.保证可见性: 当一个线程修改了被volatile修饰的变量时,该更改会立即被刷新到主内存中,并且其他线程在访问该变量时,会看到最新的值,而不是从它们各自的工作内存(缓存)中获取过期的副本。这意味着使用volatile修饰的变量能够确保线程间的共享数据更新对所有线程都是立即可见的。

2.禁止指令重排序: volatile还能阻止编译器和处理器对被修饰变量的读写操作进行重排序优化。在没有volatile的情况下,为了提高性能,编译器和处理器可能会对指令进行重排,导致执行顺序与代码逻辑顺序不一致。而volatile变量的访问会按照程序的顺序语义来执行,确保多线程环境下的特定操作顺序得到保持,这对于依赖特定执行顺序的并发代码至关重要。

使用注意事项:

  1. 仅修饰变量: volatile只能应用于变量声明,不能用于方法或类。
  2. 适用于特定场景状态标志(flags),单例模式双重检查锁定(Double-Checked Locking),简单数据同步
  3. 不保证原子性: volatile不能替代synchronized或Atomic类等用于实现原子性操作。例如,对于count++这样的复合操作,即使count是volatile的,也不能保证其原子性,需要使用AtomicInteger等原子类或同步块来保护。
  4. 避免过度使用: 虽然volatile提供了轻量级的同步机制,但过度使用可能会导致性能下降。只有当确实需要保证变量的可见性和禁止指令重排序时才应使用。
  5. 理解局限性: 要清楚volatile无法解决复杂的同步问题,例如涉及多个变量的同步状态、循环依赖等。在复杂同步场景下,应使用更强大的同步工具,如锁(synchronized、ReentrantLock等)或高级并发类(如Semaphore、CyclicBarrier等)。

理解volatile的原理需要结合Java内存模型(Java Memory Model, JMM)和硬件层面的内存交互机制。

Java内存模型(JMM)层面:

JMM定义了主内存(Main Memory)和工作内存(Working Memory)的概念。主内存是所有线程共享的存储区域,其中存放着所有实例变量、静态变量等共享数据。工作内存则是每个线程私有的,包含该线程使用的变量副本以及对主内存数据的缓存。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接操作主内存。

当一个线程修改了volatile变量时,会发生如下过程:

  • 变量的新值被写入工作内存。
  • 工作内存中的新值立即被同步回主内存,同时其他线程工作内存中该变量的缓存副本被标记为无效,迫使下次访问时从主内存重新加载。
  • 对于后续读取该变量的线程,它们必须从主内存中获取最新值,而不是依赖自己的工作内存缓存。

硬件层面(缓存一致性协议):

在硬件层面,为了保证不同CPU核心之间缓存的一致性,现代处理器采用了诸如MESI(Modified, Exclusive, Shared, Invalid)协议这样的缓存一致性协议。当一个核心修改了volatile变量时,协议会确保:

  • 将该变量所在的缓存行(Cache Line)状态更新为“已修改”(Modified),并广播一个 invalidate 信号。
  • 接收到invalidate信号的其他核心会将它们缓存的该变量副本标记为无效(Invalid),从而促使它们在下次访问时从主内存重新加载。

此外,针对volatile变量的写操作,处理器还会在指令层面插入相应的内存屏障(Memory Barrier),以确保写操作的全局可见性和禁止指令重排。内存屏障是一种特殊的指令,可以确保某些内存操作的顺序性,并同步不同CPU核心的缓存视图。

综上所述,volatile关键字通过Java内存模型的规则以及硬件层面的缓存一致性协议与内存屏障,实现了对共享变量的可见性和有序性保证,为多线程编程提供了一种轻量级的同步机制。然而,需要注意的是,volatile并不能保证原子性,即对于复合操作(如递增、条件判断与更新等),仍然需要借助synchronized、Atomic类或其他锁机制来确保操作的原子性。

四、ThreadLocal原理

ThreadLocal是一个Java类,用于提供线程本地变量。它使得每个线程都拥有自己独立的变量副本,从而确保了在并发环境中,线程之间的变量操作互不影响。ThreadLocal主要用于解决以下几个问题:

  • 线程间数据隔离:每个线程都能访问到一个独立的变量副本,不会影响其他线程的数据。
  • 跨组件数据传递:在同一线程的不同组件或方法中传递数据,无需通过参数传递。
  • 避免全局状态污染:减少全局变量的使用,降低代码复杂性和潜在的并发问题。

ThreadLocal的结构

ThreadLocal的核心结构是一个线程内部的ThreadLocalMap类,它是一个定制化的哈希表,用于存储ThreadLocal变量与它们对应的值。ThreadLocalMap的键是ThreadLocal对象本身,值则是用户存储的实际数据。

public class Thread implements Runnable {
    ...
    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
    ...
}

每个线程都有一个threadLocals属性,即一个ThreadLocalMap实例。当通过ThreadLocal的get()、set()或remove()方法操作变量时,实际上是在操作当前线程的ThreadLocalMap。

引用类型

·强引用(Strong Reference):最常见的引用类型,只要强引用存在,垃圾收集器就不会回收被引用的对象。例如:

Object obj = new Object(); // obj是强引用

·软引用(Soft Reference):当系统内存不足时,即使存在软引用,也可能被垃圾收集器回收。主要用于实现内存敏感的缓存。例如:

SoftReference<Object> softRef = new SoftReference<>(new Object());

·弱引用(Weak Reference):弱引用对象在垃圾收集器进行下一次回收时(无论内存是否充足),都会被回收。常用于实现弱键的映射关系。例如:

WeakReference<Object> weakRef = new WeakReference<>(new Object());

为什么ThreadLocal中的key是弱引用

ThreadLocal中的key是弱引用,主要是为了避免内存泄漏。如果没有使用弱引用,ThreadLocalMap的key(即ThreadLocal实例)一旦被其他对象强引用,即使当前线程已经不再使用该ThreadLocal,由于key仍被引用,整个Entry(包括key和value)也不会被垃圾收集器回收。而value通常是由线程内部的逻辑所创建,如果线程生命周期很长,那么这些value就可能长期驻留内存,形成内存泄漏。

使用弱引用作为key,当ThreadLocal实例不再有其他强引用时,即使当前线程还在运行,key也可以被垃圾收集器回收。这样,对应的value由于失去了key的引用,也会在下一次垃圾回收时被清理,从而避免了内存泄漏。

ThreadLocal如何解决内存溢出

  • 及时清理不再使用的ThreadLocal变量:在使用完ThreadLocal后,调用其remove()方法移除变量,尤其是在使用线程池时,确保线程复用时不会累积过多不再使用的ThreadLocal变量。
  • 合理设置ThreadLocal变量的值:避免在ThreadLocal中存储大对象或大量对象,以防单个线程的ThreadLocalMap占用过多内存。
  • 监控和排查内存泄漏:使用内存分析工具(如VisualVM、MAT等)定期检查是否存在因ThreadLocal未清理导致的内存泄漏。

ThreadLocal如何解决Hash冲突

ThreadLocalMap内部使用开放寻址法(Open Addressing)来解决Hash冲突,具体实现是线性探测(Linear Probing)。当插入一个新的Entry时,如果遇到Hash冲突(即索引位置已有Entry),则向后寻找下一个空闲位置,直到找到为止。这种策略简化了Entry的删除操作,因为不需要维护链表或其他复杂的冲突解决结构。

代码示例与介绍

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

public class ThreadLocalExample {
    // 定义一个ThreadLocal变量,用于存储线程的唯一标识
    private static final ThreadLocal<String> THREAD_ID = new ThreadLocal<>();

    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(2);

        // 向线程池提交任务
        for (int i = 0; i < 10; i++) {
            executor.submit(() -> {
                // 设置当前线程的唯一标识(这放入的是你传进来的参数就是你想隔离啥就传啥)
                THREAD_ID.set("Task-" + Thread.currentThread().getId());

                // 使用ThreadLocal变量
                doWork();

                // 清理ThreadLocal变量,避免内存泄漏
                THREAD_ID.remove();
            });
        }

        executor.shutdown();
    }

    private static void doWork() {
        String id = THREAD_ID.get(); // 获取当前线程的唯一标识(你在这用的时候就只有你这一个线程使用)
        System.out.println("Thread " + id + " is doing some work.");
    }
}

在这个示例中:

  • 定义了一个THREAD_ID ThreadLocal变量,用于存储每个线程的唯一标识。
  • 主线程创建了一个固定大小的线程池,并提交了多个任务
  • 每个任务在执行时,首先设置其ThreadLocal变量的值,然后执行doWork()方法,该方法通过get()方法获取当前线程的唯一标识并打印。
  • 任务结束后,调用remove()方法清理ThreadLocal变量,防止内存泄漏。

通过这个示例,可以看到ThreadLocal如何在多线程环境中为每个线程提供独立的变量副本,且在任务执行完毕后主动清理,以避免内存泄漏。

  • 27
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值