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