Java中的线程安全性

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

线程安全

数据不一致的原因是因为更新任何字段值都不是一个原子过程,它需要三个步骤;首先读取当前值,其次进行必要的操作以获取更新后的值,最后将更新后的值分配给字段引用。让我们通过一个简单的程序来验证这一点,在该程序中,多个线程正在更新共享数据。

package com.journaldev.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();
        //等待线程完成处理
        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) {
        //处理一些任务
        try {
            Thread.sleep(i*1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
}

在上面的程序中,对循环中的计数进行了四次加1操作,由于我们有两个线程,所以在这两个线程都执行完成后,其值应该为8。但当你多次运行上述程序时,你会注意到计数值在6、7、8之间变化。这是因为即使count++看起来是一个原子操作,但实际上它并不是,从而导致了数据损坏。

Java中的线程安全

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

  • 同步是Java中实现线程安全的最简单且广泛使用的工具。
  • 可以使用java.util.concurrent.atomic包中的原子包装类,例如AtomicInteger
  • 还可以使用java.util.concurrent.locks包中的锁。
  • 使用线程安全的集合类,查看此文章以了解如何使用ConcurrentHashMap来确保线程安全。
  • 使用`volatile`关键字与变量一起,以使每个线程从内存中读取数据,而不是从线程缓存中读取。

Java同步

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

  • Java同步是基于对资源进行锁定和解锁的机制运行的,在任何线程进入同步代码之前,必须获取对象上的锁,当代码执行结束时,它会解锁资源,其他线程可以锁定该资源。与此同时,其他线程处于等待状态以锁定同步资源。
  • 我们可以用两种方式使用同步关键字,一种是使整个方法同步,另一种是创建同步块。
  • 当一个方法被同步时,它会锁定对象,如果方法是静态的,则会锁定,所以最好的做法是始终使用同步块来锁定需要同步的方法部分。
  • 在创建同步块时,我们需要提供将要获取锁的资源,可以是XYZ.class或类的任何对象字段。
  • synchronized(this)会在进入同步块之前锁定对象。
  • 你应该使用最低级别的锁定,例如,如果一个类中有多个同步块,其中一个正在锁定对象,则其他同步块也将不可用于其他线程的执行。当我们锁定一个对象时,它会获取对象的所有字段的锁。
  • Java同步在性能上会带来数据完整性,因此只有在绝对必要时才应使用它。
  • Java同步只在同一JVM中起作用,因此,如果您需要在多个JVM环境中锁定某些资源,则无法实现,您可能需要考虑一些全局锁定机制。
  • Java同步可能导致死锁,请查看关于Java死锁及如何避免的帖子
  • Java的`synchronized`关键字不能用于构造函数和变量。
  • 最好创建一个虚拟的私有对象,用于同步块,以便其引用不会被其他代码更改。例如,如果您有一个用于同步的对象的setter方法,其引用可以被其他代码更改,从而导致同步块的并行执行。
  • 不应使用维护在常量池中的任何对象,例如不应将String用于同步,因为如果任何其他代码也锁定同一String,它将尝试在String池上获取相同引用对象的锁,即使这两段代码不相关,它们将相互锁定。

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

    // 用于同步的虚拟对象变量
    private Object mutex=new Object();
    ...
    // 使用同步块同步读取、递增和更新计数值
    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();
//更改锁对象引用
myObject.lock = new Object();

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

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

请注意,黑客代码正在获取类监视器的锁,并且不会释放它,这将导致系统发生死锁和 DoS。这里是另一个例子,多个线程正在处理相同的字符串数组,一旦处理完成,就将线程名称附加到数组值中。

package com.journaldev.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("Time taken= "+(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();
        }
    }
    
}

这是我运行上述程序时的输出。

Time taken= 15005
[1:t2:t3, 2:t1, 3:t3, 4:t1:t3, 5:t2:t1, 6:t3]

由于共享数据和缺乏同步,字符串数组值已损坏。以下是我们如何更改 addThreadName() 方法以使我们的程序线程安全的方法。

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

经过这些更改,我们的程序可以正常工作,以下是程序的正确输出。

Time taken= 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`关键字的知识。

Source:
https://www.digitalocean.com/community/tutorials/thread-safety-in-java