Java线程安全详解:多线程并发编程最佳实践与解决方案

Java中的线程安全(第1部分/共2部分)

Java中的线程安全是一个非常重要的主题。Java通过Java线程提供多线程环境支持,从同一个对象创建的多个线程会共享对象变量,当线程用于读取和更新共享数据时,这可能导致数据不一致性。

线程安全与数据不一致的原因

数据不一致的原因在于更新任何字段的值不是原子性操作,它需要三个步骤:首先读取当前值,然后执行必要的操作以获取更新后的值,最后将更新后的值赋给字段引用。让我们通过一个简单的程序来检查这个情况,其中多个线程正在更新共享数据。

package com.Olivia.threads;

public class ThreadSafety {

    public static void main(String[] args) throws InterruptedException {
    
        ProcessingThread pt = new ProcessingThread();
        Thread t1 = new Thread(pt, "t1");
        t1.start();
        Thread t2 = new Thread(pt, "t2");
        t2.start();
        //wait for threads to finish processing
        t1.join();
        t2.join();
        System.out.println("Processing count="+pt.getCount());
    }

}

class ProcessingThread implements Runnable{
    private int count;
    
    @Override
    public void run() {
        for(int i=1; i < 5; i++){
            processSomething(i);
        	count++;
        }
    }

    public int getCount() {
        return this.count;
    }

    private void processSomething(int i) {
        // processing some job
        try {
            Thread.sleep(i*1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
}

在上面的程序中,通过for循环,计数器递增了四次。由于我们有两个线程,当这两个线程都执行完后,计数器的值应该是8。但是当你多次运行上述程序时,你会发现计数器的值在6、7、8之间变化。这是因为即使count++看起来是一个原子操作,实际上它并不是,导致数据出现错误。

Java中的线程安全概念

Java中的线程安全是指使我们的程序在多线程环境中可以安全地使用的过程,有多种方法可以使我们的程序线程安全。

  • 同步是Java中最简单且使用最广泛的线程安全工具。
  • 使用java.util.concurrent.atomic包中的原子包装类,例如AtomicInteger。
  • 使用java.util.concurrent.locks包中的锁。
  • 使用线程安全的集合类,可以查看关于ConHashMap用于线程安全的文章。
  • 在变量上使用volatile关键字,使每个线程从内存中读取数据,而不是从线程缓存中读取。

Java同步机制

这是文章《Java中的线程安全》的第2部分(共2部分)。

同步是我们实现线程安全的工具,JVM保证同步代码只会被一个线程执行。Java关键字synchronized用于创建同步代码,在内部它使用对象或类的锁来确保只有一个线程执行同步代码。

  • Java同步基于资源的锁定和解锁机制。在任何线程进入同步代码之前,必须先获取对象的锁;当代码执行结束时,它会释放资源锁,以便其他线程可以锁定该资源。在此期间,其他线程处于等待状态,以锁定同步资源。
  • 我们可以通过两种方式使用synchronized关键字,一种是使整个方法同步,另一种是创建同步块。
  • 当一个方法被同步时,它会锁定对象;如果方法是静态的,它会锁定类。因此,最佳实践是使用同步块来仅锁定需要同步的方法部分。
  • 创建同步块时,我们需要提供将被获取锁的资源,它可以是XYZ.class或类的任何对象字段。
  • synchronized(this)会在进入同步块之前锁定对象。
  • 你应该使用最低级别的锁定。例如,如果一个类中有多个同步块,其中一个锁定了对象,那么其他同步块也将无法被其他线程执行。当我们锁定一个对象时,它会获取该对象所有字段的锁。
  • Java同步以性能为代价提供数据完整性,因此只有在绝对必要时才应使用它。
  • Java同步仅在同一个JVM中有效,因此如果需要在多个JVM环境中锁定某些资源,它将不起作用,你可能需要寻找一些全局锁定机制。
  • Java同步可能导致死锁,查看这篇关于Java死锁及其避免方法的文章。
  • Java synchronized关键字不能用于构造函数和变量。
  • 最好创建一个虚拟的私有对象用于同步块,这样它的引用就不能被任何其他代码更改。例如,如果你有一个用于同步的对象的setter方法,它的引用可能会被其他代码更改,导致同步块的并行执行。
  • 我们不应该使用常量池中维护的任何对象,例如String不应该用于同步,因为如果任何其他代码也锁定相同的String,它将尝试获取String池中相同引用对象的锁,即使两个代码不相关,它们也会相互锁定。

以下是我们需要在上述程序中进行的代码更改,以使其具备线程安全性。

    //用于同步的虚拟对象变量
    private Object mutex=new Object();
    ...
    //使用同步块来同步地读取、递增和更新count值
    synchronized (mutex) {
            count++;
    }

让我们看一些同步的例子,以及我们可以从中学到什么。

public class MyObject {
 
  // 锁定对象的监视器
  public synchronized void doSomething() { 
    // ...
  }
}
 
// 黑客的代码
MyObject myObject = new MyObject();
synchronized (myObject) {
  while (true) {
    // 无限期延迟myObject
    Thread.sleep(Integer.MAX_VALUE); 
  }
}

请注意,黑客的代码正试图锁定myObject实例,一旦成功获得锁,就不会释放它,导致doSomething()方法在等待锁时被阻塞,这会引发系统死锁并导致拒绝服务攻击(DoS)。

public class MyObject {
  public Object lock = new Object();
 
  public void doSomething() {
    synchronized (lock) {
      // ...
    }
  }
}

//不受信任的代码

MyObject myObject = new MyObject();
//更改lock对象的引用
myObject.lock = new Object();

请注意,锁定对象是公共的,通过更改其引用,我们可以在多个线程中并行执行同步块。如果您拥有私有对象但具有设置器方法来更改其引用,情况类似。

public class MyObject {
  //锁定类对象的监视器
  public static synchronized void doSomething() { 
    // ...
  }
}
 
// 黑客的代码
synchronized (MyObject.class) {
  while (true) {
    Thread.sleep(Integer.MAX_VALUE); // 无限期延迟MyObject
  }
}

请注意,黑客代码正在锁定班级监视器并且未释放,这将导致系统出现死锁和拒绝服务攻击。以下是另一个例子,多个线程正在处理同一字符串数组,一旦处理完成,会将线程名称附加到数组值上。

package com.Olivia.threads;

import java.util.Arrays;

public class SyncronizedMethod {

    public static void main(String[] args) throws InterruptedException {
        String[] arr = {"1","2","3","4","5","6"};
        HashMapProcessor hmp = new HashMapProcessor(arr);
        Thread t1=new Thread(hmp, "t1");
        Thread t2=new Thread(hmp, "t2");
        Thread t3=new Thread(hmp, "t3");
        long start = System.currentTimeMillis();
        //启动所有线程
        t1.start();t2.start();t3.start();
        //等待线程完成
        t1.join();t2.join();t3.join();
        System.out.println("耗时= "+(System.currentTimeMillis()-start));
        //现在检查共享变量的值
        System.out.println(Arrays.asList(hmp.getMap()));
    }

}

class HashMapProcessor implements Runnable{
    
    private String[] strArr = null;
    
    public HashMapProcessor(String[] m){
        this.strArr=m;
    }
    
    public String[] getMap() {
        return strArr;
    }

    @Override
    public void run() {
        processArr(Thread.currentThread().getName());
    }

    private void processArr(String name) {
        for(int i=0; i < strArr.length; i++){
            //处理数据并附加线程名称
            processSomething(i);
            addThreadName(i, name);
        }
    }
    
    private void addThreadName(int i, String name) {
        strArr[i] = strArr[i] +":"+name;
    }

    private void processSomething(int index) {
        // 处理一些任务
        try {
            Thread.sleep(index*1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
}

当我运行上述程序时,这是输出结果。

耗时= 15005
[1:t2:t3, 2:t1, 3:t3, 4:t1:t3, 5:t2:t1, 6:t3]

由于共享数据和缺乏同步,String数组的值遭到破坏。以下是我们如何更改addThreadName()方法,使我们的程序具备线程安全性。

    private Object lock = new Object();
    private void addThreadName(int i, String name) {
        synchronized(lock){
        strArr[i] = strArr[i] +":"+name;
        }
    }

在这次更改之后,我们的程序运行正常,以下是程序的正确输出。

耗时= 15004
[1:t1:t2:t3, 2:t2:t1:t3, 3:t2:t3:t1, 4:t3:t2:t1, 5:t2:t1:t3, 6:t2:t1:t3]

关于Java中的线程安全问题,就先介绍到这里了。希望你了解了线程安全编程和使用synchronized关键字的方法。

bannerAds