JUC并发编程1(初识进程和线程)
初识进程和线程
初识进程:
定义:
- 进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。(百度百科)
- 进程由程序、数据和进程控制块三部分组成。
什么是进程?
狭义定义:进程是正在运行的程序的实例。
广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
eg:进程可以看做是程序的实例,你可以打开多个程序,每一个程序就是一个进程(比如你重复打开QQ登录不同的用户,每一个用户登录的那个程序就是一个进程)。
如果你关闭一个窗口,那么这个进程也就结束了

概念:
1.进程是一个实体。
进程包括文本区域、堆栈、数据区域。
- 文本区域:存储处理器执行的代码
- 堆栈:存储变量和进程执行期间使用的动态分配的内存
- 数据区域:存储着活动过程调用的指令和本地变量
2.进程是一个执行中的程序。
程序是没有生命的,只有执行一个程序才能称之为进程(进行中的程序)
进程存储在哪里?
1.内存:
当一个程序被执行时,操作系统会为其创建一个或多个进程,并将这些进程的数据加载到内存中。
eg:现在我只启动了一个程序的实例,操作系统会启动多个进程并行执行任务。
![]()
![]()
2.硬盘:
虽然进程的主要活动区域是在内存中,但在某些情况下,进程相关的数据也可能被保存到硬盘上。
- 交换空间(Swap Space):当物理内存不足时,操作系统可能会将一些不活跃的内存页写入到硬盘上的交换分区或交换文件中,以释放内存给更需要的进程使用。这被称为“分页”或“交换”。
- 持久存储:某些应用可能选择将部分进程状态或数据周期性地保存到硬盘上,比如检查点机制(checkpointing),以便在崩溃后可以恢复。
3.操作系统:
操作系统内部维护着一张进程表,记录了所有当前正在运行或已停止但尚未完全结束的进程的信息。这张表包含了进程ID(PID)、父进程ID(PPID)、优先级、状态等元数据,它们通常存储在内核内存中。
进程的状态转换
1)就绪状态(Ready):
进程已获得除处理器外的所需资源,等待分配处理器资源;只要分配了处理器进程就可执行。就绪进程可以按多个优先级来划分队列。例如,当一个进程由于时间片用完而进入就绪状态时,排入低优先级队列;当进程由I/O操作完成而进入就绪状态时,排入高优先级队列。
2)运行状态(Running):
进程占用处理器资源;处于此状态的进程的数目小于等于处理器的数目。在没有其他进程可以执行时(如所有进程都在阻塞状态),通常会自动执行系统的空闲进程。
3)阻塞状态(Blocked):
由于进程等待某种条件(如I/O操作或进程同步),在条件满足之前无法继续执行。该事件发生前即使把处理器资源分配给该进程,也无法运行。
初识线程
什么是线程?
1.是程序执行流的最小单位。
2.线程是进程最小执行单元。
3.进程是操作系统进行资源分配和调度的基本单位,每个进程至少包含一个线程,这个初始创建的线程称为主线程。
并发和并行的区别
1.并发:
并发指的是系统能够支持多个任务在同一时间段内交替执行的能力。这意味着即使表面上看起来多个任务是“同时”进行的,实际上它们可能是在快速切换执行状态,轮流使用处理器资源
2.并行:
并行则是指真正地同时执行多个任务,通常需要利用多核处理器或多台计算机来实现。并行计算的目标是加快单个任务或一组任务的完成速度,通过将任务分解成子任务并在不同处理器或计算机上同时执行来达成这一目的
线程的状态转换
1.新建(New):
当创建了一个线程对象但还没有调用start()方法时,线程处于新建状态。在这个状态下,线程还未准备好执行。
2.可运行(Runnable):
调用了线程的start()方法后,线程进入可运行状态。这意味着线程已经准备好,可以被调度执行,但实际上是否正在执行取决于操作系统的线程调度器。
3.运行中(Running):
当线程调度器选择了该线程并分配了CPU时间片后,线程进入运行中状态,开始执行其run()方法中的代码。
4.阻塞/等待(Blocked/Waiting):
线程可能由于多种原因离开运行状态而进入阻塞或等待状态,例如:
- 等待获取同步锁以进入临界区。
- 调用了如wait()方法等待另一个线程的通知。
- 请求I/O操作未完成等。
- 在这种状态下,线程暂时不活跃,直到某种条件满足(如获得了锁或者收到了通知),它将重新回到可运行状态。
5.终止(Terminated/Dead):
线程完成了执行(即run()方法正常结束)或者因为异常退出,则进入终止状态。到达这个状态后,线程的生命期结束,不能再恢复执行。
Java线程
如何构造一个线程?
通过Thread类可以创建线程对象,通过start方法启动线程。
Thread类的参数
1.ThreadGroup group :
这是目标线程所属的线程组。线程组(ThreadGroup)允许你对一组线程进行批量管理(例如,中断所有线程或检查它们的状态)。如果你不打算使用线程组来管理线程,可以将此参数设为null,这样新的线程就不会被分配到特定的线程组中。
2.Runnable target :
这是一个实现了Runnable接口的对象。Runnable接口要求实现一个无返回值的run()方法,该方法定义了线程启动时应执行的代码。通过提供一个Runnable对象给Thread构造函数,你实际上是在告诉这个线程它需要执行的任务是什么。
3.String name :
线程的名称。这是一个字符串,用于标识线程,便于调试和日志记录。虽然每个线程都有一个默认名称(如Thread-0, Thread-1等),但为线程指定一个有意义的名字可以使调试过程更加容易。
4.long stackSize :
建议的堆栈大小。这是在线程创建时建议给该线程分配的堆栈大小(以字节为单位)。需要注意的是,这个参数只是一个建议,并不一定能够精确地控制实际分配的堆栈大小。对于大多数应用来说,除非有特殊需求,否则通常可以忽略这个参数或者传入值为0,表示使用平台或JVM默认的堆栈大小。
创建线程的方法
通过重写Thread类中的run方法
1 | Thread t1 = new Thread("t1") { |
通过Runnable创建任务对象传给Thread类
1 | Runnable task1 = new Runnable() { |
通过FutureTask接收线程返回值
1 | FutureTask<Integer> task1 = new FutureTask<>( |
这里重点说一下:
FutureTask中的构造器接收的是Callable对象
1 | public FutureTask(Callable<V> callable) { |
其实这样写更加直观:
1 | Callable callable = new Callable<Object>() { |
你会不会有这样一个疑惑,既然创建任务对象的是Callable这个类,那能不能在创建线程的时候传递Callable对象呢?
答案是否定的,FutureTask对象之所以能传递给Thread对象,是因为FutureTask实现了Runnable接口,而Callable并没有实现Runnable接口。
FutureTask类间接实现的Runnable接口
Callable本身是就是一个接口,所以我们创建的Callable对象都是实现这个接口,是这个接口的实现类。
线程存储在哪里?
更详细的需要了解一下JVM:Java虚拟机
每个线程启动后,虚拟机就会为其分配一块栈内存。 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存,每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。
线程的上下文切换
什么是线程的上下文切换?
当CPU从执行一个线程切换到执行另一个线程时,这个过程就叫做线程的上下文切换
为什么会上下文切换(原因)?
- 时间片轮转:这是最常见的一种情况,在分时系统中,为了公平地分配CPU资源给所有正在运行的进程或线程,操作系统会为每个线程分配一个固定的时间片段(称为时间片)。当一个线程的时间片用完后,即使它还没有完成工作,也会被挂起,以便让其他线程有机会运行。
- I/O操作等待:当线程执行过程中需要等待外部资源(如磁盘读写、网络请求等)时,线程会进入阻塞状态,此时操作系统会选择另一个就绪状态的线程来使用CPU资源。这样可以避免CPU空闲等待I/O操作完成。
- 优先级抢占:如果系统支持基于优先级的调度算法,那么当一个更高优先级的线程变为就绪状态时,操作系统可能会中断当前较低优先级的线程,将其挂起,并将CPU资源分配给高优先级线程。
- 线程休眠:程序中有时会主动让线程休眠一段时间(例如调用Thread.sleep()方法),在此期间,该线程会让出CPU,使得其他线程有机会运行。
- 锁竞争:当多个线程试图访问共享资源而需要获取互斥锁时,未能获得锁的线程将会被挂起,直到持有锁的线程释放了锁。这也会引起上下文切换。
- 硬件中断:硬件设备触发的中断信号(例如键盘输入、鼠标移动、定时器到期等)可能导致当前线程暂停执行,以便处理中断服务程序。处理完中断后,可能恢复原来的线程继续执行,也可能根据调度策略选择另一个线程执行。
- 用户模式与内核模式切换:某些操作需要从用户模式切换到内核模式才能执行(比如系统调用)。这种模式切换本身虽然不是直接的上下文切换,但通常伴随着一些上下文保存和恢复的工作,类似于上下文切换的过程。
Java 中的线程状态(Thread.State枚举类)
1 | public enum State { |
1.NEW(新建):
线程状态为尚未启动。这意味着线程对象已经被创建,但start()方法还未被调用。
2.RUNNABLE(可运行):
线程状态为可运行。一个处于此状态的线程正在Java虚拟机中执行,但它可能正在等待来自操作系统的其他资源,比如处理器时间。
3.BLOCKED(阻塞):
线程状态为因等待监视器锁而被阻塞。当一个线程试图进入一个同步块或方法,或者在调用了Object.wait()后重新进入同步块/方法时,如果当前没有获得所需的监视器锁,就会处于这种状态。
4.WAITING(等待):
线程状态为等待。线程会因为调用以下方法之一而进入此状态:无超时的Object.wait()、无超时的Thread.join()、LockSupport.park()。在这种状态下,线程在等待另一个线程执行特定动作,如通知或终止。
5.TIMED_WAITING(计时等待):
线程状态为带有指定等待时间的等待。线程会因为调用以下带有超时参数的方法之一而进入此状态:Thread.sleep()、带超时的Object.wait(long)、带超时的Thread.join(long)、LockSupport.parkNanos()、LockSupport.parkUntil()。
6.TERMINATED(终止):
线程状态为已终止。这意味着线程已经完成了执行。
图片来源:JUC并发编程-黑马程序员
Thread常用方法
菜鸟教程:
1.启动线程
- start(): 启动线程,并调用run()方法。
2.获取信息
- getName(): 返回线程的名称。
- setName(String name): 设置线程的名称。
- getId(): 返回线程的唯一标识符。
- getPriority(): 返回线程的优先级。
- setPriority(int newPriority): 更改线程的优先级。
- isAlive(): 测试线程是否处于活动状态(即已启动但尚未死亡)。
- currentThread(): 返回对当前正在执行的线程对象的引用。
- activeCount(): 返回当前线程组中的活跃线程数量。
3.睡眠和等待
- sleep(long millis): 使当前正在执行的线程暂停一段时间。
- join(): 等待线程终止。
- yield(): 暂停当前正在执行的线程对象,并允许其他具有相同优先级的线程获得运行机会。
4.中断线程
- interrupt(): 中断线程。
- isInterrupted(): 测试线程是否已被中断。
- static interrupted(): 测试当前线程是否已被中断,并清除中断状态。
5.守护线程
- isDaemon(): 测试线程是否为守护线程。
- setDaemon(boolean on): 将此线程标记为守护线程或用户线程。
6.处理异常
- uncaughtExceptionHandler getUncaughtExceptionHandler(): 返回该线程的未捕获异常处理器。
- void setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh): 设置该线程的未捕获异常处理器。
- static Thread.UncaughtExceptionHandler getDefaultUncaughtExceptionHandler(): 返回默认的未8捕获异常处理器。
- static void setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh): 设置默认的未捕获异常处理器。
7.栈跟踪
- StackTraceElement[] getStackTrace(): 返回表示该线程堆栈转储的数组。
线程运行
并发执行(start)
t1.start();
t2.start();
两个线程抢夺CPU资源
1 | Thread t1 = new Thread("t1") { |
运行态转阻塞态(sleep)
sleep();
调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)
1 | Thread t1 = new Thread("t1") { |
运行态转就绪态(yield)
Thread.yield(); // 让出cpu
调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程
就绪态:拿到了除了cpu以外的所有资源
1 | Thread t1 = new Thread("t1") { |
中断线程进入阻塞态(interrupt)
t1.interrupt();
this.isInterrupted()
1 | Thread t1 = new Thread() { |
说明中断线程,不会阻止线程继续往下运行
当我们需要使用interrupt中断线程时:
我们可以采用判断中断信号的方式来中断程序
1 | Thread t1 = new Thread() { |
线程优先级(priority)
t1.setPriority(Thread.MAX_PRIORITY);
t2.setPriority(Thread.MIN_PRIORITY);
1 | Thread t1 = new Thread("t1") { |
等待线程(join)
t1.join();
阻塞等待线程执行完毕(苦苦等待)
1 | Thread t1 = new Thread() { |
t1.join(100);
表示阻塞等待100毫秒,没有执行完我也不等了(爱来来,不来拉倒)
1 | t1.start(); |
打断park进程(park)
LockSupport.park();
用于线程的阻塞(即“停车”)。它在并发编程中提供了比传统等待/通知机制更灵活和高效的线程阻塞与唤醒功能
1 | Thread t1 = new Thread("t1") { |
守护线程
守护线程(Daemon Thread)是Java中一种特殊的后台线程,它不是应用程序的核心部分。当Java程序仅剩下守护线程在运行时,JVM会自动退出程序执行,这意味着程序已经结束了其所有用户线程的执行。守护线程通常用于执行一些辅助性的工作,比如垃圾回收、内存泄漏检测等,这些工作并不影响程序的主要逻辑。(非守护线程全部结束了,守护线程也会自动结束,这就意味着只要非守护线程结束了,所有的线程就都结束了,守护线程就扮演一个不太重要的角色)
1 | Thread deamon = new Thread("deamon") { |
通过运行结果可以发现,守护线程没有执行
线程安全
初识线程安全
1 | Thread t1 = new Thread("t1") { |
第一次运行:16:39:48 [main] c.thread - 26146
第二次运行:16:41:10 [main] c.thread - 12260
第三次运行:16:41:23 [main] c.thread - -9234
—> 很显然,这三次运行结果都不一样,如何导致的呢?
操作系统使用调度算法将CPU时间分割成一系列的时间段,即时间片,轮流为每个进程或线程分配时间片来执行。
在Java 中对静态变量的自增,自减并不是原子操作,通过JVM虚拟机的类加载器将一个命令生成多条JVM字节码指令。
所以在进行多线程并发运行时,可能会引起时间片流转带来的线程上下文切换,从而导致因为不恰当的执行时序而出现的不正确的结果(竞态条件)。
什么是线程安全?
线程安全是指一个函数、方法或某个代码段可以在多线程环境下正确执行,不会因为并发访问导致数据不一致或其他错误。当多个线程同时访问共享资源(如变量、数据结构或设备)时,如果没有适当的同步机制来管理这些访问,就可能导致竞态条件、死锁、资源争用等问题。
无状态的对象一定是线程安全的。(无状态可以简单认为成,对象中无成员变量,也就是所有的操作都是基于传入的参数进行的)
—> 如何判断一个对象是否需要设计成线程安全?
一个对象是否需要是线程安全的取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。
—> 怎么使得对象是线程安全的?
- 要使得对象是线程安全的,需要采用同步机制来协调对对象可变状态的访问。
- Java中主要的同步机制是关键字synchronized,提供一种独特的加锁方式,这个同步还包括volatile类型的变量,显式锁和原子变量。
线程安全类
当多个线程都能访问某个类时,这个类始终能表现出正确的行为,那么就称这个类是线程安全的。
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程如何交替执行,并且在主调代码中不需要任何额外的同步或协调,这个类都能表现出正确的行为,那么这个类就是线程安全的。
—> 我的理解是,这个类不依赖多线程的调度顺序,自身实现了线程安全(doge)
临界区
临界区(Critical Section)是指在多线程编程中,一段访问共享资源(如数据或设备)的代码块,这段代码的执行必须是互斥的,即在同一时间只能由一个线程执行,以避免多个线程同时访问共享资源导致的数据不一致或其他并发问题。临界区的概念对于确保程序的正确性和稳定性至关重要。(一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区 )
简单来说,临界区是指在多线程程序中的一段代码,这段代码访问共享资源(如变量、数据结构或外部设备),并且需要确保在同一时间只能由一个线程执行,以避免并发问题。
—-> 我的理解就是会发生多线程安全问题的代码区域(doge)
竞态条件
竞态条件(Race Condition) 是指在多线程或并发编程中,程序的行为依赖于不受控制的时间顺序或线程的交错执行顺序。当两个或多个线程尝试同时访问和修改同一个共享资源(如变量、数据结构等),且至少有一个线程执行写操作时,如果没有适当的同步机制来控制这些访问的顺序,就可能发生竞态条件。这会导致不确定的结果或程序错误。
—> 我的理解就是因为执行顺序而带来的多线程并发问题(doge)
防止临界区出现竞态条件
防止临界区出现竞态条件的核心在于确保在同一时间只有一个线程能够访问和修改共享资源。这通常通过使用同步机制来实现,以保证对临界区的互斥访问。
方式:
1. 使用 synchronized 关键字
- 在Java中,synchronized 关键字可以用于方法或代码块,以确保任意时刻只有一个线程可以执行被 synchronized 保护的代码。
2. 使用显式锁(如 ReentrantLock)
- Java 提供了更灵活的显式锁机制,如 ReentrantLock,它可以提供比 synchronized 更丰富的功能,例如尝试获取锁、定时等待等。
3. 使用原子变量
- 对于一些简单的操作,可以使用 java.util.concurrent.atomic 包中的原子类,如 AtomicInteger,它们提供了无锁的线程安全操作。
4. 使用并发集合
- 对于涉及集合的操作,可以考虑使用 java.util.concurrent 包提供的并发集合类,这些类内部实现了高效的并发控制机制,允许在大多数情况下无阻塞地读取和有条件地写入。
5. 设计模式的应用
- 某些设计模式也有助于避免竞态条件,比如单例模式(Double-Checked Locking)、工厂模式等。正确应用这些模式可以帮助开发者更轻松地编写线程安全的代码。
锁(sychronized)
概念:
在Java中,synchronized 是一个关键字,用于控制对共享资源的访问,确保在同一时间只有一个线程能够执行被 synchronized 修饰的方法或代码块。这有助于避免竞态条件和数据不一致问题,是实现线程安全的一种方式。
synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。
1 | synchronized (lock) { |
1 | public synchronized int method() { |
下面来看一个例子(因为锁粒度导致的性能差别):
1 | // 时间戳 |
1 | // 时间戳 |
第一个运行结果:08:48:18 [main] c.thread.Test1 - cnt=0,cost=41
第二个运行结果:08:48:59 [main] c.thread.Test2 - cnt=0,cost=2
- 可以发现第二种加锁的方式的性能要比第一种要好,因为加锁的粒度影响了线程的上下文切换所带来的性能降低。
- 第一种加锁方式对每一个操作进行加锁,当操作完成锁释放,两个线程来争夺cpu的使用权,导致线程上下文切换。
- 第二种加锁方式,将整个操作锁住了,当这一整个操作结束前锁不会释放,因此第二种方式的执行流程一般是先执行t1再执行t2,或者先执行t2再执行t1(顺序执行)
面向对象改进:
构建线程安全类,把要加锁的操作进行封装
1 |
|
测试:
1 | Room room = new Room(); |
结果:09:19:01 [main] c.thread.Test3 - cnt=0,cost=26
方法上的sychronized
1.实例方法
当你在一个实例方法上使用 synchronized 关键字时,它相当于在方法内部的所有代码周围包裹了一个 synchronized(this) 同步块。这意味着每次只有一个线程可以执行该实例的任何同步方法。
public synchronized void test(){
}
等价于
public void test(){
synchronized (this) {}
}
2.静态方法
对于静态方法,synchronized 锁定的是类的 Class 对象,而不是某个特定的实例。这意味着无论创建了多少个该类的实例,同一时间只有一个线程可以执行该类的任何同步静态方法。
public static synchronized void test3(){
}
等价于
public static void test4(){
synchronized (Room.class) {}
}
线程八锁
1. 同一个类的不同实例上的同步方法
如果有两个不同的实例对象,即使它们调用的是同一个同步方法,由于每个对象都有自己的锁,因此这两个方法可以并行执行。
1 |
|
运行结果:
10:04:11 [t1] c.lock1 - a 1742609051383
10:04:11 [t2] c.lock1 - b 1742609051383
2. 静态同步方法与实例同步方法
静态同步方法锁定的是Class对象,而实例同步方法锁定的是实例对象。这意味着它们不会相互影响,可以并行执行。
1 |
|
运行结果:
09:54:18 [t2] c.lock2 - t2: 静态方法 1742608458384
09:54:18 [t1] c.lock2 - t1: 实例方法 1742608458384
可以看出来,这两个线程同时调用对应的方法 ,所以这两个线程是并行执行的
3. 静态同步方法之间的同步
所有对静态同步方法的调用都共享同一个锁(即Class对象),因此不能并行执行
1 |
|
执行结果:
09:59:48 [t1] c.lock3 - a 1742608788365
09:59:48 [t2] c.lock3 - b 1742608788373
4. 实例同步方法之间的同步
同一对象实例的所有同步实例方法共享同一个锁(即该对象实例),因此不能并行执行。
1 |
|
运行结果:
10:05:29 [t1] c.lock1 - a 1742609129540
10:05:29 [t2] c.lock1 - b 1742609129548
5. 静态同步代码块与实例同步代码块
静态同步代码块锁定的是Class对象,而实例同步代码块锁定的是实例对象,因此它们不会相互阻塞。
1 | public class Lock5 { |
运行结果:
6. 不同对象作为锁的同步代码块
如果同步代码块使用不同的对象作为锁,则它们之间不会相互阻塞。
1 |
|
执行结果:
10:17:45 [t1] c.lock6 - lock1—> a : 1742609865149
10:17:45 [t2] c.lock6 - lock2—> b: 1742609865149
7. 锁的重入性
Java中的锁是可重入的,这意味着同一个线程可以多次获取同一个锁而不会导致死锁。
1 |
|
运行结果:
10:22:43 [t1] c.lock7 - b: 1742610163030
10:22:43 [t1] c.lock7 - a: 1742610163037
10:22:43 [t2] c.lock7 - b: 1742610163037
8. 锁的释放
当一个线程退出同步方法或同步代码块时,它会自动释放锁。如果异常发生且未在同步块内捕获,锁也会被释放
1 |
|
解释一下:t1线程调用a,拿到锁,但是由于输入操作抛出异常,自动释放锁,锁被t2拿到继续执行
局部变量的线程安全分析
局部变量在Java中是线程安全的,原因在于每个线程都会有自己的方法调用栈,局部变量存储在这个栈中,因此它们不会被多个线程共享。这意味着不同的线程执行同一方法时,各自使用的局部变量是完全独立的,不存在一个线程能够访问另一个线程的局部变量的情况。
1 |
|
运行结果:
10:59:13 [t1] c.thread - t1: 100000 —– 1742612353780
10:59:13 [t2] c.thread - t2: 100000 —– 1742612353780
—-> 为什么局部变量是线程安全的?
当一个方法被调用时,Java虚拟机会为该方法的执行分配一个新的栈帧(Stack Frame),这个栈帧包含了方法执行期间所需的所有信息,包括局部变量、操作数栈等。对于基本数据类型(如int, float等)的局部变量,它们直接存储在栈帧中;而对于对象引用类型的局部变量,虽然实际的对象存储在堆内存中,但引用本身仍然存储在栈帧中。
由于每个线程都有自己的栈空间,因此每个线程在执行同一方法时使用的局部变量实际上是不同的副本,这些副本之间互不干扰。这就保证了局部变量的线程安全性。
常见的线程安全类
1.String:
- String 类是不可变的(immutable),这意味着一旦创建后就不能被修改。由于其不可变性,String 对象本身是线程安全的。
2.Integer:
- 类似于 String,Integer 也是不可变类。因此,它的实例在多线程环境下也是线程安全的。
3.StringBuffer:
- StringBuffer 提供了与 StringBuilder 类似的API,但所有公共方法都是同步的,使其成为线程安全的。然而,由于同步带来的开销,在单线程环境中推荐使用 StringBuilder。
4.Random:
- 虽然 Random 不是严格意义上的线程安全类,但如果多个线程同时使用同一个 Random 实例,则可能会遇到性能瓶颈。对于高并发场景,可以考虑使用 ThreadLocalRandom,它为每个线程提供独立的随机数生成器,从而避免了同步问题。
5.Vector 和 Hashtable:
- 这两个类是早期集合框架的一部分,它们的方法都是同步的,所以从技术上讲它们是线程安全的。但是,由于其同步机制可能导致性能问题,在现代应用中更倾向于使用 ArrayList 和 HashMap 结合适当的同步措施或使用 java.util.concurrent 包下的类。
6.java.util.concurrent 包下的类:
- 此包提供了丰富的线程安全工具和集合,适用于各种并发编程需求
看一个String源码中的substring方法来分析一下线程安全:
String 类是不可变的,这意味着一旦创建后就不能被修改。所有看似“修改”字符串的操作(如concat, substring等)实际上都是创建了一个新的String对象。由于其不可变性,多个线程可以共享同一个String实例而无需担心并发修改的问题,因此它是线程安全的。
1 | /** |
1 | /** |
1 | /** |
1 | /** |
线程安全分析案例
解释下列代码:
模拟卖票过程,总共有2000张票,然后2000个人来买,每个人能买1~5张票,看会不会出现超卖情况。
1 |
|
执行结果:
12:38:10 [main] c.thread - selled count:2003
12:38:10 [main] c.thread - remainder count:0
—> 出现超卖情况 ,为什么?
因为在进行买票逻辑时并不是原子性的,在线程并发过程中会出现竞态条件。
修改买票逻辑:加上sychronized关键字
1 | public synchronized int sell(int amount) { |
再运行一下,结果为:
12:39:46 [main] c.thread - selled count:2000
12:39:46 [main] c.thread - remainder count:0
参考资料:
1.百度百科
2.《Java并发编程编程实战》
3.视频:*Java并发编程-黑马程序员*
如有错误,欢迎指正!!!