Java 线程基本概念可见性和同步

本文介绍了Java中进程与线程的区别,详细解释了线程的创建与执行状态,探讨了可见性问题及其解决方法,如使用synchronized和volatile关键词。此外还介绍了锁机制、线程间的同步以及中断线程的处理。

在操作系统中两个比较容易混淆的概念是进程(process)和线程(thread)。操作系统中的进程是资源的组织单位。进程有一个包含了程序内容和数据的地址空间,以及其它的资源,包括打开的文件、子进程和信号处理器等。不同进程的地址空间是互相隔离的。而线程表示的是程序的执行流程,是CPU调度的基本单位。线程有自己的程序计数器、寄存器、栈和帧等。引入线程的动机在于操作系统中阻塞式I/O的存在。当一个线程所执行的I/O被阻塞的时候,同一进程中的其它线程可以使用CPU来进行计算。这样的话,就提高了应用的执行效率。线程的概念在主流的操作系统和编程语言中都得到了支持。

 

一部分的Java程序是单线程的。

 

程序的机器指令按照程序中给定的顺序依次执行。Java语言提供了java.lang.Thread类来为线程提供抽象。有两种方式创建一个新的线程:一种是继承java.lang.Thread类并覆写其中的run()方法,另外一种则是在创建java.lang.Thread类的对象的时候,在构造函数中提供一个实现了java.lang.Runnable接口的类的对象。在得到了java.lang.Thread类的对象之后,通过调用其start()方法就可以启动这个线程的执行。

 

一个线程被创建成功并启动之后,可以处在不同的状态中。这个线程可能正在占用CPU时间运行;也可能处在就绪状态,等待被调度执行;还可能阻塞在某个资源或是事件上。多个就绪状态的线程会竞争CPU时间以获得被执行的机会,而CPU则采用某种算法来调度线程的执行。不同线程的运行顺序是不确定的,多线程程序中的逻辑不能依赖于CPU的调度算法

 

 

可见性

  可见性(visibility)的问题是Java多线程应用中的错误的根源。在一个单线程程序中,如果首先改变一个变量的值,再读取该变量的值的时候,所读取到的值就是上次写操作写入的值。也就是说前面操作的结果对后面的操作是肯定可见的。但是在多线程程序中,如果不使用一定的同步机制,就不能保证一个线程所写入的值对另外一个线程是可见的。造成这种情况的原因可能有下面几个:

 

  CPU 内部的缓存:现在的CPU一般都拥有层次结构的几级缓存。CPU直接操作的是缓存中的数据,并在需要的时候把缓存中的数据与主存进行同步。因此在某些时刻,缓存中的数据与主存内的数据可能是不一致的。某个线程所执行的写入操作的新值可能当前还保存在CPU的缓存中,还没有被写回到主存中。这个时候,另外一个线程的读取操作读取的就还是主存中的旧值。

 

 

CPU的指令执行顺序:在某些时候,CPU可能改变指令的执行顺序。这有可能导致一个线程过早的看到另外一个线程的写入操作完成之后的新值。

 

 

  编译器代码重排:出于性能优化的目的,编译器可能在编译的时候对生成的目标代码进行重新排列。

 

  现实的情况是:不同的CPU可能采用不同的架构,而这样的问题在多核处理器和多处理器系统中变得尤其复杂。而Java的目标是要实现“编写一次,到处运行”,因此就有必要对Java程序访问和操作主存的方式做出规范,以保证同样的程序在不同的CPU架构上的运行结果是一致的。Java内存模型(Java Memory Model)就是为了这个目的而引入的。JSR 133则进一步修正了之前的内存模型中存在的问题。总得来说,Java内存模型描述了程序中共享变量的关系以及在主存中写入和读取这些变量值的底层细节。Java内存模型定义了Java语言中的synchronized、volatile和final等关键词对主存中变量读写操作的意义。Java开发人员使用这些关键词来描述程序所期望的行为,而编译器和JVM负责保证生成的代码在运行时刻的行为符合内存模型的描述。比如对声明为volatile的变量来说,在读取之前,JVM会确保CPU中缓存的值首先会失效,重新从主存中进行读取;而写入之后,新的值会被马上写入到主存中。而synchronized和volatile关键词也会对编译器优化时候的代码重排带来额外的限制。比如编译器不能把 synchronized块中的代码移出来。对volatile变量的读写操作是不能与其它读写操作一块重新排列的。

 

 

  Java 内存模型中一个重要的概念是定义了“在之前发生(happens-before)”的顺序。如果一个动作按照“在之前发生”的顺序发生在另外一个动作之前,那么前一个动作的结果在多线程的情况下对于后一个动作就是肯定可见的。最常见的“在之前发生”的顺序包括:对一个对象上的监视器的解锁操作肯定发生在下一个对同一个监视器的加锁操作之前;对声明为volatile的变量的写操作肯定发生在后续的读操作之前。有了“在之前发生”顺序,多线程程序在运行时刻的行为在关键部分上就是可预测的了。编译器和JVM会确保“在之前发生”顺序可以得到保证。比如下面的一个简单的方法:

public void increase() { 
  this.count++; 
}

 

 

 

这是一个常见的计数器递增方法,this.count++实际是this.count = this.count + 1,由一个对变量this.count的读取操作和写入操作组成。如果在多线程情况下,两个线程执行这两个操作的顺序是不可预期的。如果 this.count的初始值是1,两个线程可能都读到了为1的值,然后先后把this.count的值设为2,从而产生错误。错误的原因在于其中一个线程对this.count的写入操作对另外一个线程是不可见的,另外一个线程不知道this.count的值已经发生了变化。如果在increase() 方法声明中加上synchronized关键词,那就在两个线程的操作之间强制定义了一个“在之前发生”顺序。一个线程需要首先获得当前对象上的锁才能执行,在它拥有锁的这段时间完成对this.count的写入操作。而另一个线程只有在当前线程释放了锁之后才能执行。这样的话,就保证了两个线程对 increase()方法的调用只能依次完成,保证了线程之间操作上的可见性。

 

  

如果一个变量的值可能被多个线程读取,又能被最少一个线程锁写入,同时这些读写操作之间并没有定义好的“在之前发生”的顺序的话,那么在这个变量上就存在数据竞争(data race)。数据竞争的存在是Java多线程应用中要解决的首要问题。解决的办法就是通过synchronized和volatile关键词来定义好“在之前发生”顺序。

 

  

Java中的锁

  当数据竞争存在的时候,最简单的解决办法就是加锁。锁机制限制在同一时间只允许一个线程访问产生竞争的数据的临界区。Java语言中的 synchronized关键字可以为一个代码块或是方法进行加锁。任何Java对象都有一个自己的监视器,可以进行加锁和解锁操作。当受到 synchronized关键字保护的代码块或方法被执行的时候,就说明当前线程已经成功的获取了对象的监视器上的锁。当代码块或是方法正常执行完成或是发生异常退出的时候,当前线程所获取的锁会被自动释放。一个线程可以在一个Java对象上加多次锁。同时JVM保证了在获取锁之前和释放锁之后,变量的值是与主存中的内容同步的。 

 

 

Java线程的同步

  在有些情况下,仅依靠线程之间对数据的互斥访问是不够的。有些线程之间存在协作关系,需要按照一定的协议来协同完成某项任务,比如典型的生产者-消费者模式。这种情况下就需要用到Java提供的线程之间的等待-通知机制。当线程所要求的条件不满足时,就进入等待状态;而另外的线程则负责在合适的时机发出通知来唤醒等待中的线程。Java中的java.lang.Object类中的wait/notify/notifyAll方法组就是完成线程之间的同步的。

 

  

      在某个Java对象上面调用wait方法的时候,首先要检查当前线程是否获取到了这个对象上的锁。如果没有的话,就会直接抛出java.lang.IllegalMonitorStateException异常。如果有锁的话,就把当前线程添加到对象的等待集合中,并释放其所拥有的锁。当前线程被阻塞,无法继续执行,直到被从对象的等待集合中移除。引起某个线程从对象的等待集合中移除的原因有很多:对象上的notify方法被调用时,该线程被选中;对象上的notifyAll方法被调用;线程被中断;对于有超时限制的wait操作,当超过时间限制时;JVM内部实现在非正常情况下的操作。

 

  

      从上面的说明中,可以得到几条结论:wait/notify/notifyAll操作需要放在synchronized代码块或方法中,这样才能保证在执行 wait/notify/notifyAll的时候,当前线程已经获得了所需要的锁。当对于某个对象的等待集合中的线程数目没有把握的时候,最好使用 notifyAll而不是notify。notifyAll虽然会导致线程在没有必要的情况下被唤醒而产生性能影响,但是在使用上更加简单一些。由于线程可能在非正常情况下被意外唤醒,一般需要把wait操作放在一个循环中,并检查所要求的逻辑条件是否满足。典型的使用模式如下所示:

private Object lock = new Object(); 
synchronized (lock) { 
  while (/* 逻辑条件不满足的时候 */) { 
    try { 
      lock.wait();  
    } catch (InterruptedException e) {} 
  } 
  //处理逻辑 
}

 

 

 

上述代码中使用了一个私有对象lock来作为加锁的对象,其好处是可以避免其它代码错误的使用这个对象。

 

  

      中断线程

  通过一个线程对象的interrupt()方法可以向该线程发出一个中断请求。中断请求是一种线程之间的协作方式。当线程A通过调用线程B的interrupt()方法来发出中断请求的时候,线程A 是在请求线程B的注意。线程B应该在方便的时候来处理这个中断请求,当然这不是必须的。当中断发生的时候,线程对象中会有一个标记来记录当前的中断状态。通过isInterrupted()方法可以判断是否有中断请求发生。如果当中断请求发生的时候,线程正处于阻塞状态,那么这个中断请求会导致该线程退出阻塞状态。可能造成线程处于阻塞状态的情况有:当线程通过调用wait()方法进入一个对象的等待集合中,或是通过sleep()方法来暂时休眠,或是通过join()方法来等待另外一个线程完成的时候。在线程阻塞的情况下,当中断发生的时候,会抛出java.lang.InterruptedException,代码会进入相应的异常处理逻辑之中。实际上在调用wait/sleep/join方法的时候,是必须捕获这个异常的。中断一个正在某个对象的等待集合中的线程,会使得这个线程从等待集合中被移除,使得它可以在再次获得锁之后,继续执行java.lang.InterruptedException异常的处理逻辑。

 

  

      通过中断线程可以实现可取消的任务。在任务的执行过程中可以定期检查当前线程的中断标记,如果线程收到了中断请求,那么就可以终止这个任务的执行。当遇到 java.lang.InterruptedException的异常,不要捕获了之后不做任何处理。如果不想在这个层次上处理这个异常,就把异常重新抛出。当一个在阻塞状态的线程被中断并且抛出java.lang.InterruptedException异常的时候,其对象中的中断状态标记会被清空。如果捕获了java.lang.InterruptedException异常但是又不能重新抛出的话,需要通过再次调用interrupt()方法来重新设置这个标记。

### Java 线程可见性问题及解决方案 #### 1. 可见性问题概述 在多线程环境中,当一个线程修改了一个共享变量的值时,其他线程可能无法及时看到该变量的最新值。这种现象被称为 **可见性问题**[^1]。这是因为每个线程都有自己的工作内存(Work Memory),而这些工作内存中的数据可能是主内存(Main Memory)的一个副本。如果某个线程更新了其工作内存中的变量值,但未立即将其同步回主内存,则其他线程的工作内存可能会继续使用旧值。 以下是可能导致可见性问题的原因: - 编译器优化:为了提高性能,编译器可能会重新排列指令顺序。 - CPU缓存机制:现代CPU通常会有多个级别的缓存,这使得某些操作的结果仅存储在局部缓存中而不是全局内存中。 ```java public class Test { private static boolean stop = false; public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(() -> { int i = 0; while (!stop){ i++; } System.out.println("Result:" + i); }); thread.start(); Thread.sleep(1000); stop = true; // 修改后的值可能不会立刻被子线程感知到 } } ``` 在这个例子中,`stop` 被设置为 `true` 后,主线程期望子线程能够退出循环并终止运行。然而,由于缺乏必要的同步措施,子线程可能始终看不到最新的 `stop` 值,从而导致无限循环[^1]。 --- #### 2. 解决方案 ##### (1)使用 `volatile` 关键字 通过声明变量为 `volatile`,可以确保对该变量的所有读取都直接从主内存中获取,而非线程本地缓存;同时所有的写入也会立即刷新至主内存[^2]。这样就解决了因缓存而导致的数据不一致性问题。 ```java public class VolatileTest { private volatile static boolean stop = false; public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(() -> { int i = 0; while (!stop){ i++; } System.out.println("Result with volatile:" + i); }); thread.start(); Thread.sleep(1000); stop = true; // 使用 volatile 后,子线程能正确检测到变化 } } ``` 注意:虽然 `volatile` 提供了可见性的保障,但它并不具备原子性支持。对于复合赋值运算(如自增操作 `count++`),仍需额外考虑同步手段来维护线程安全性。 ##### (2)利用 `synchronized` 锁定资源 `synchronized` 不仅提供了互斥访问控制功能,还隐含着建立发生前关系的作用——即每次进入或离开监视器之前都会触发相应的内存屏障动作,强制将当前线程持有的所有脏数据刷回到主存,并使后续线程重载目标对象的状态[^2]^。 下面展示了一种基于锁的方式实现相同效果: ```java public class SyncTest { private static boolean stop = false; public static synchronized void setStop(boolean value){ stop = value; } public static synchronized boolean getStop(){ return stop; } public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(() -> { int i = 0; while (!getStop()){ i++; } System.out.println("Synchronized Result:" + i); }); thread.start(); Thread.sleep(1000); setStop(true); // 设置标志位并通过同步方法传播给其它线程 } } ``` 此处定义的一对 getter/setter 方法均加上了同步修饰符,保证任何时刻只有一个线程可对其进行更改或者查询操作,进而消除潜在竞争条件带来的隐患。 ##### (3)采用高级并发工具类 除了基本的关键字外,在实际开发过程中更推荐借助 JDK 自带的一些专门设计用于处理复杂场景下的并发问题的支持库,像 AtomicXXX 类族成员便是一大亮点之一。它们内部实现了无锁算法,能够在大多数情况下提供优于传统锁定策略的表现特性的同时兼顾高效与稳定两方面需求[^4]. 例如替换掉原始布尔型字段改用 AtomicInteger 来表示停止信号量: ```java import java.util.concurrent.atomic.AtomicInteger; public class AtomicTest { private static final AtomicInteger STOP_FLAG = new AtomicInteger(0); public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(() -> { long count = 0L; while (STOP_FLAG.get() == 0){ count++; } System.out.println("Atomic Count:" + count); }); thread.start(); Thread.sleep(1000); STOP_FLAG.set(1); // 利用 CAS 实现精确数值比较交换过程完成状态切换 } } ``` 以上三种方式各有优劣之处,具体选用哪一种取决于应用场景的具体要求以及团队技术积累程度等因素综合考量决定。 --- ### 总结 Java 中的可见性问题是多线程环境下常见的挑战之一。通过对共享变量应用合适的同步机制,可以有效缓解此类难题的发生概率及其影响范围。常用的解决办法包括但不限于标记易变属性为 `volatile`, 运用内置锁结构 (`synchronized`) 或者调用更高层次抽象封装好的 API 接口等途径达成目的。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值