java 多线程和并发
线程与进程的区别
线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
进程是操作系统分配资源的最小单元,线程是操作系统调度的最小单元。一个程序至少有一个进程,一个进程至少有一个线程。
何为多线程?为什么提倡多线程而不是多进程?
多线程就是多个线程同时运行或交替运行。单核CPU的话是顺序执行,也就是交替运行。多核CPU的话,因为每个CPU有自己的运算器,所以在多个CPU中可以同时运行。开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
线程就是轻量级进程,是程序执行的最小单位。使用多线程而不是用多进程去进行并发程序的设计,是因为线程间的切换和调度的成本远远小于进程。
创建线程的三种方式
1. 继承 Thread 类
2. 实现 Runnable 接口
3. 实现 Callable 接口,需要实现的是 call() 方法
相比扩展Thread,实现Runnable接口可能更优.原因有二:
a. Java不支持多继承.因此扩展Thread类就代表这个子类不能扩展其他类.而实现Runnable接口的类还可能扩展另一个类.
b. 类可能只要求可执行即可,因此继承整个Thread类的开销过大.
什么是Callable和Future?
Callable接口类似于Runnable,但是Runnable不会返回结果,而Callable可以返回值,Future可以拿到Callable异步执行任务的返回值,可以认为是带有回调的Runnable。
线程的优先级
每个线程都具有各自的优先级,线程的优先级可以在程序中表明该线程的重要性,如果有很多线程处于就绪状态,系统会根据优先级来决定首先使哪个线程进入运行状态。但这个并不意味着低
优先级的线程得不到运行,而只是它运行的几率比较小,如垃圾回收机制线程的优先级就比较低。所以很多垃圾得不到及时的回收处理。
线程优先级具有继承特性比如A线程启动B线程,则B线程的优先级和A是一样的。
线程优先级具有随机性也就是说线程优先级高的不一定每一次都先执行完。
线程安全
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交换执行,也不需要进行额外的同步,或者调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。
java中用到的线程调度算法是什么?
计算机通常只有一个CPU,在任意时刻只能执行一条机器指令,每个线程只有获得CPU的使用权才能执行指令.所谓多线程的并发运行,其实是指从宏观上看,各个线程轮流获得CPU的使用权,分别执行各自的任务.
分时调度模型:让所有的线程轮流获得cpu的使用权,并且平均分配每个线程占用的CPU的时间片。
抢占式调度模型:优先让可运行池中优先级高的线程占用CPU,java虚拟机采用抢占式调度模型。
线程的分类
用户线程一般用户执行用户级任务,而守护线程也就是后台线程,一般用来执行后台任务。jvm会等待用户线程完成后关闭,但是jvm不会等待守护线程。守护线程最典型的例子就是GC线程。任何线程都可以设置为守护线程和用户线程,通过方Thread.setDaemon(bool on)。Thread.setDaemon()必须在Thread.start()之前调用,否则运行时会抛出异常。
同步和异步
同步和异步通常用来形容一次方法调用。同步方法调用一旦开始,调用者必须等到方法调用返回后,才能继续后续的行为。异步方法调用更像一个消息传递,一旦开始,方法调用就会立即返回,调用者可以继续后续的操作。
并发(Concurrency)和并行(Parallelism)
并发和并行是两个非常容易被混淆的概念。它们都可以表示两个或者多个任务一起执行,但是偏重点有些不同。并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的。而并行是真正意义上的“同时执行”。
多线程在单核CPU的话是顺序执行,也就是交替运行(并发)。多核CPU的话,因为每个CPU有自己的运算器,所以在多个CPU中可以同时运行(并行)。
synchronized关键字
在java中,每一个对象有且仅有一个同步锁。 当我们调用某对象的synchronized方法时,就获取了该对象的同步锁。如synchronized(obj)就获取了"obj"这个对象的同步锁。 不同线程对同步锁的访问是互斥的。即同一时间点,对象的同步锁只能被一个线程获取到。synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
synchronized关键字的三种使用方式
修饰实例方法,作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁 。也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份,所以对该类的所有对象都加了锁)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。 和 synchronized 方法一样,synchronized(this)代码块也是锁定当前对象的。synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。这里再提一下:synchronized关键字加到非 static 静态方法上是给对象实例上锁。另外需要注意的是:尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓冲功能!
我们将synchronized的基本规则总结为下面3条,并通过实例对它们进行说明。
第一条: 当一个线程访问“某对象”的“synchronized方法”或者“synchronized代码块”时,其他线程对“该对象”的该“synchronized方法”或者“synchronized代码块”的访问将被阻塞。
第二条: 当一个线程访问“某对象”的“synchronized方法”或者“synchronized代码块”时,其他线程仍然可以访问“该对象”的非同步代码块。
第三条: 当一个线程访问“某对象”的“synchronized方法”或者“synchronized代码块”时,其他线程对“该对象”的其他的“synchronized方法”或者“synchronized代码块”的访问将被阻塞。
volatile关键字
在 JDK1.2 之前,Java的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,所有的变量都存储在主内存中每条线程都由自己的工作内存,线程的工作内存中(比如机器的寄存器)保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。要解决这个问题,就需要把变量声明为 volatile,这就指示 JVM,这个变量是不稳定的,每次使用它都到主存中进行读取。
(a) 不使用volatile (b) 使用volatile
volatile 关键字的主要作用就是保证变量的可见性和防止指令重排序。
volatile关键字的可见性
volatile 修饰的成员变量在每次被线程访问时,都强迫从主存(共享内存)中重读该成员变量的值。而且,当成员变量发生变化时,强迫线程将变化值回写到主存(共享内存)。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值,这样也就保证了同步数据的可见性。
(1)修改volatile变量时会强制将修改后的值刷新的主内存中。
(2)修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值。
volatile关键字能保证原子性吗?
volatile关键字不能保证原子性。要保证数据的原子性还是要使用synchronized关键字。
为什么代码会重排序?
在执行程序时,为了提供性能,处理器和编译器常常会对指令进行重排序,但它需要满足以下两个条件。重排序不会影响单线程环境的执行结果,但是会破坏多线程的执行语义。
(1)在单线程环境下不能改变程序运行的结果;
(2)存在数据依赖关系的不允许重排序
volatile有序性(即程序执行的顺序按照代码的先后顺序执行)
volatile关键字本身就包含了禁止指令重排序的语义。
synchronized:一个变量在同一时刻只允许一条线程对其进行lock 操作这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入
synchronized关键字和volatile关键字比较
- volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。synchronized关键字在JavaSE1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,实际开发中使用 synchronized 关键字的场景还是更多一些。
- 多线程访问volatile关键字不会发生阻塞,而synchronized关键字可能会发生阻塞
- volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。
- volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized关键字解决的是多个线程之间访问资源的同步性。
线程的基本状态
1. 新建状态(New) : 线程对象被创建后,就进入了新建状态。例如,Thread thread = new Thread()。
2. 就绪状态(Runnable): 也称"可执行状态"。线程对象被创建后,其它线程调用该对象的start()方法来启动该线程thread.start()。处于就绪状态的线程,随时可能被CPU调度执行。
3. 运行状态(Running) : 线程获取CPU权限进行执行。需要注意的是,线程只能从就绪状态进入到运行状态。
4. 阻塞状态(Blocked) : 阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:
(1) 等待阻塞 -- 通过调用线程的wait()方法,让线程等待某工作的完成。
(2) 同步阻塞 -- 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态。
(3) 其他阻塞 -- 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
5. 死亡状态(Dead) : 线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
线程的等待/通知机制的相关方法
notify() 随机唤醒等待队列中等待同一共享资源的 “一个线程”,并使该线程退出等待队列,进入可运行状态,也就是notify()方法仅通知“一个线程”
notifyAll() 使所有正在等待队列中等待同一共享资源的 “全部线程” 退出等待队列,进入可运行状态。此时,优先级最高的那个线程最先执行,但也有可能是随机执行,这取决于JVM虚拟机的实现
wait() 使调用该方法的线程释放共享资源锁,然后从运行状态退出,进入等待队列,直到被再次唤醒
wait(long) 超时等待一段时间,这里的参数时间是毫秒,也就是等待长达n毫秒,如果没有通知就超时返回
wait(long,int) 对于超时时间更细力度的控制,可以达到纳秒
Thread类中的start()和run()有什么区别?
start()的作用是启动一个新线程,而且start()内部调用了run()方法,这和直接调用run()方法的效果不一样。当你调用run()方法的时候,只会是在原来的线程中调用,没有新的线程启动,start()方法才会启动新线程。
Object类中的wait()和Thread类中的sleep()有什么区别?
wait()的作用是让当前线程由“运行状态”进入“等待(阻塞)状态”的同时,也会释放同步锁。而sleep()的作用是也是让当前线程由“运行状态”进入到“休眠(阻塞)状态”。
但是,wait()会释放对象的同步锁,而sleep()则不会释放锁。
join() 定义在Thread.java中。
join() 的作用:让“主线程”等待“子线程”结束之后才能继续运行。
等待/通知(wait/notify)机制的相关方法必须用在synchronized关键字同步的Object的临界区内。通过调用wait()方法可以使处于临界区内的线程进入等待状态,同时释放被同步对象的锁。而notify()方法可以唤醒一个因调用wait操作而处于阻塞状态中的线程,使其进入就绪状态。notify()执行后并不会立即释放锁,必须执行完notify()方法所在的synchronized代码块后才释放。
synchronized (object) { object.wait(); // object.notify(); 两个线程使用同一个对象锁,一个线程调用其wait方法进入阻塞状态,另一个调用notify唤醒阻塞线程 }
Java中怎样唤醒一个阻塞的线程?
死锁:死锁是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去 。
死锁的4个必要条件
- 互斥条件:一个资源每次只能被一个进程使用。
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
一种非常简单的避免死锁的方式就是:指定获取锁的顺序,并强制线程按照指定的顺序获取锁。因此,如果所有的线程都是以同样的顺序加锁和释放锁,就不会出现死锁了。这也就是破坏了第四个条件循环等待条件。
活锁:任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。
活锁和死锁的区别:处于活锁的实体是在不断的改变状态,所谓的“活”, 而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。
饥饿:一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。
Java中导致饥饿的原因:
高优先级线程吞噬所有的低优先级线程的CPU时间。
线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。
线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的wait方法),因为其他线程总是被持续地获得唤醒。
线程间的几种通信方式
1、锁机制
- 互斥锁:提供了以排它方式阻止数据结构被并发修改的方法。
- 读写锁:允许多个线程同时读共享数据,而对写操作互斥。
- 条件变量:可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
2、信号量机制:包括无名线程信号量与有名线程信号量
3、信号机制:类似于进程间的信号处理。
线程间通信的主要目的是用于线程同步,所以线程没有象进程通信中用于数据交换的通信机制。
进程间的几种通信方式
- 管道(pipe):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有血缘关系的进程间使用。进程的血缘关系通常指父子进程关系。管道分为pipe(无名管道)和fifo(命名管道)两种,有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间通信。
- 信号量(semophore):信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它通常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
- 消息队列(message queue):消息队列是由消息组成的链表,存放在内核中 并由消息队列标识符标识。消息队列克服了信号传递信息少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。消息队列与管道通信相比,其优势是对每个消息指定特定的消息类型,接收的时候不需要按照队列次序,而是可以根据自定义条件接收特定类型的消息。
- 信号(signal):信号是一种比较复杂的通信方式,用于通知接收进程某一事件已经发生。
- 共享内存(shared memory):共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问,共享内存是最快的IPC方式,它是针对其他进程间的通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量配合使用,来实现进程间的同步和通信。
- 套接字(socket):socket,即套接字是一种通信机制,凭借这种机制,客户/服务器(即要进行通信的进程)系统的开发工作既可以在本地单机上进行,也可以跨网络进行。也就是说它可以让不在同一台计算机但通过网络连接计算机上的进程进行通信。也因为这样,套接字明确地将客户端和服务器区分开来。
JUC原子类
根据修改的数据类型,可以将JUC包中的原子操作类可以分为4类。这些类存在的目的是对相应的数据进行原子操作。所谓原子操作,是指操作过程不会被中断,保证数据操作是以原子方式进行的。
1. 基本类型: AtomicInteger, AtomicLong, AtomicBoolean ;
2. 数组类型: AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray ;
3. 引用类型: AtomicReference, AtomicStampedRerence, AtomicMarkableReference ;
4. 对象的属性修改类型: AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, AtomicReferenceFieldUpdater 。
AtomicLong
AtomicLong是作用是对长整形进行原子操作。在32位操作系统中,64位的long 和 double 变量由于会被JVM当作两个分离的32位来进行操作,所以不具有原子性。而使用AtomicLong能让long的操作保持原子型。
JUC锁
1. AQS -- 指AbstractQueuedSynchronizer类。
AQS是java中管理“锁”的抽象类,锁的许多公共方法都是在这个类中实现。AQS是独占锁(例如,ReentrantLock)和共享锁(例如,Semaphore)的公共父类。
2. AQS锁的类别 -- 分为“独占锁”和“共享锁”两种。
(01) 独占锁 -- 锁在一个时间点只能被一个线程锁占有。根据锁的获取机制,它又划分为“公平锁”和“非公平锁”。公平锁,是按照通过CLH等待线程按照先来先得的规则,公平的获取锁;而非公平锁,则当线程要获取锁时,它会无视CLH等待队列而直接获取锁。独占锁的典型实例子是ReentrantLock,此外,ReentrantReadWriteLock.WriteLock也是独占锁。
(02) 共享锁 -- 能被多个线程同时拥有,能被共享的锁。JUC包中的ReentrantReadWriteLock.ReadLock,CyclicBarrier, CountDownLatch和Semaphore都是共享锁。这些锁的用途和原理,在以后的章节再详细介绍。
3. CLH队列 -- Craig, Landin, and Hagersten lock queue
CLH队列是AQS中“等待锁”的线程队列。在多线程中,为了保护竞争资源不被多个线程同时操作而起来错误,我们常常需要通过锁来保护这些资源。在独占锁中,竞争资源在一个时间点只能被一个线程锁访问;而其它线程则需要等待。CLH就是管理这些“等待锁”的线程的队列。
CLH是一个非阻塞的 FIFO 队列。也就是说往里面插入或移除一个节点的时候,在并发条件下不会阻塞,而是通过自旋锁和 CAS 保证节点插入和移除的原子性。
4. CAS函数 -- Compare And Swap
CAS函数,是比较并交换函数,它是原子操作函数;即,通过CAS操作的数据都是以原子方式进行的。例如,compareAndSetHead(), compareAndSetTail(), compareAndSetNext()等函数。它们共同的特点是,这些函数所执行的动作是以原子的方式进行的。
使用线程池的好处
- 降低资源消耗。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。 当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
Java 主要提供了下面4种线程池
- FixedThreadPool: 该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
- SingleThreadExecutor: 方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
- CachedThreadPool: 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
- ScheduledThreadPoolExecutor: 主要用来在给定的延迟后运行任务,或者定期执行任务。ScheduledThreadPoolExecutor又分为:ScheduledThreadPoolExecutor(包含多个线程)和SingleThreadScheduledExecutor (只包含一个线程)两种。
各种线程池的适用场景介绍
- FixedThreadPool: 适用于为了满足资源管理需求,而需要限制当前线程数量的应用场景。它适用于负载比较重的服务器;
- SingleThreadExecutor: 适用于需要保证顺序地执行各个任务并且在任意时间点,不会有多个线程是活动的应用场景。
- CachedThreadPool: 适用于执行很多的短期异步任务的小程序,或者是负载较轻的服务器;
- ScheduledThreadPoolExecutor: 适用于需要多个后台执行周期任务,同时为了满足资源管理需求而需要限制后台线程的数量的应用场景,
- SingleThreadScheduledExecutor: 适用于需要单个后台线程执行周期任务,同时保证顺序地执行各个任务的应用场景。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix