多线程随笔-生活工作点滴

何谓线程安全?如何实现线程安全?

2019-07-07  本文已影响7人  梨涡贱笑

本文译自,如有兴趣可查看原文:https://www.baeldung.com/java-thread-safety

1.总览

Java支持开箱即用的多线程功能。这意味着,JVM可在多个线程间,同时运行字节码,提升应用性能。

多线程虽功能强大,但凡事均有代价:在多线程环境下,我们需实现“线程安全”的代码。这就是说,在多个线程进入同一资源时,不会产生错误行为和无法预期的后果。

这种编程方法就是所谓的“线程安全”。

该课程中,我们会讨论几种实现线程安全的方法。

2.无状态实现

多线程应用中的错误,基本源于多线程间,无法正确分享状态。

因此,我们首个实现线程安全的方法,便是使用无状态的代码。

为更好了解该方法,假设有个简单的UTIL类,其中有个静态方法,可计算某数字的阶乘。


public class MathUtils {

    public static BigInteger factorial(int number) {

        BigInteger f = new BigInteger("1");

        for (int i = 2; i <= number; i++) {

            f = f.multiply(BigInteger.valueOf(i));

        }

        return f;

    }

}

factorial() 是一个不可逆的无状态方法。在参数固定的情况下,它总是返回同一结果。

该方法既不依赖外部状态,也不需维护状态。因此它是一个线程安全的方法,可安全地被多个线程同时调用。

所有线程均可安全调用factorial()方法,获得正确的结果,而无需担心线程间干扰、方法结果被其他线程影响等问题。

3.不可变的实现

如果需要在线程之间分享状态,我们可以创建一个不可变类,使它变得线程安全。

“不可变”是一个跨语言,强大的编程概念,在Java中实现不可变非常容易。

简单地说,当实例被初始化后,其内部属性不可被修改,它就是不可变类。

在Java中,最简单的创造不可变类方法是使用private和final修饰属性,并不提供任何setter方法。


public class MessageService {

    private final String message;

    public MessageService(String message) {

        this.message = message;

    }

    // standard getter

}

MessageService 在初始化后,属性便不可变更,所以它是不可变的,也是线程安全的。

退一步说,哪怕MessageService是可变的,但各个线程均只能读取其数据,所以它也是线程安全的。

因此,不可变是实现线程安全的另一个方法。

4.使用线程专用(Thread-Local)属性

在面向对象编程中(OOP),对象通过属性维持状态,并使用一或多个方法实现其行为。

如果真的需要维持状态,我们可以让类的属性变成线程专用(Thread-Local),如此一来,类就不会在线程间分享状态。

在Thread类中定义私有的(private)的属性,即可使这些属性变为线程专用属性。

例如,我们可以在Thread类中保存一个整形列表。


public class ThreadA extends Thread {

    private final List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

    @Override

    public void run() {

        numbers.forEach(System.out::println);

    }

}

另一个类可能保存了字符串列表。


public class ThreadB extends Thread {

    private final List<String> letters = Arrays.asList("a", "b", "c", "d", "e", "f");

    @Override

    public void run() {

        letters.forEach(System.out::println);

    }

}

在以上两个实现,虽然类均含有自己的属性,但这些属性不会与其他线程共享。所以这些类是线程安全的。

同样道理,向属性分配ThreadLocal实例,也能让属性变成线程专用(thread-local)。

来看看这个叫StateHolder的类:


public class StateHolder {

    private final String state;

    // standard constructors / getter

}

使用以下方式,可简单的将之变为线程安全:


public class ThreadState {

    public static final ThreadLocal<StateHolder> statePerThread = new ThreadLocal<StateHolder>() {

        @Override

        protected StateHolder initialValue() {

            return new StateHolder("active"); 

        }

    };

    public static StateHolder getState() {

        return statePerThread.get();

    }

}

不同线程在访问ThreadLocal属性时,都会通过一个独立初始化的setter/getter方法,获得专属于自己的状态。除此以外,ThreadLocal跟普通属性没什么两样。

5.同步(Synchronized)集合

我们可以使用collections框架中的同步包装方法,让集合变得线程安全。

例如,其中一种创造线程安全的集合方法如下:

Collection<Integer> syncCollection = Collections.synchronizedCollection(new ArrayList<>());

Thread thread1 = new Thread(() -> syncCollection.addAll(Arrays.asList(1, 2, 3, 4, 5, 6)));

Thread thread2 = new Thread(() -> syncCollection.addAll(Arrays.asList(7, 8, 9, 10, 11, 12)));

thread1.start();

thread2.start();

要记住,同步集合中每个方法执行时,都会使用内在锁(我们稍后会讨论内在锁)。

这意味着,在同一时间,同一方法只能被单个线程访问,其它(想调用方法的)线程都会被堵塞,直到第一个线程解锁方法为止。

因此,这种同步访问策略会导致性能损失。

6.并发(Concurrent)集合

除了同步集合,我们还可以创建并发集合来实现线程安全。

Java提供的java.util.concurrent包里,包含了诸如ConcurrentHashMap内的几个并发集合。

Map<String,String> concurrentMap = new ConcurrentHashMap<>();

concurrentMap.put("1", "one");

concurrentMap.put("2", "two");

concurrentMap.put("3", "three");

与同步集合不同的是,并发集合通过切割数据的方法来实现线程安全(Java1.7之前,1.8已使用其他策略)。例如,在ConcurrentHashMap,不同线程,访问不同的数据碎片时会获得锁。因此,多个线程可以同时访问Map。

因为这种高级的线程访问策略,并发锁的性能比同步锁好得多。

值得一提的是,无论同步还是并发集合,都只会使集合本身变得线程安全,而非其内容。

7.原子操作对象

使用Java提供的原子操作类,同样可以实现线程安全。例如:AtomicInteger,AtomicLong,AtomicBoolean,和AtomicReference。

原子操作类让我们在不使用同步(synchronization)功能时,实现线程安全的原子操作。原子操作,即为一个整体,不可被打断的操作。

为理解原子操作类所解决的问题,请看如下Counter类:


public class Counter {

    private int counter = 0;

    public void incrementCounter() {

        counter += 1;

    }

    public int getCounter() {

        return counter;

    }

}

假设两个线程竞争资源,同时进入了incrementCounter()方法。

理论上说,counter属性的最终结果是2。但我们无法肯定——因为两个线程在同一时间执行同一代码,而累加并非原子操作。

现在使用AtomicInteger对象来创造一个线程安全的Counter类:


public class AtomicCounter {

    private final AtomicInteger counter = new AtomicInteger();

    public void incrementCounter() {

        counter.incrementAndGet();

    }

    public int getCounter() {

        return counter.get();

    }

}

因为incrementAndGet 是原子操作,所以它变得线程安全。

8.同步(Synchronized)方法

前面的解决方案十分适用于集合和原始类型,但有时,我们也需要控制更多功能。

因此,我们实现线程安全的另一个普通手段,就是使用同步方法。

同步方法的原理为,同一时间,只让一个线程进入方法,阻塞其他意图进入方法的线程,直到第一个线程执行完毕或抛错为止。

我们使用synchronized修饰方法,创建另一个线程安全的incrementCounter()。

public synchronized void incrementCounter() {

    counter += 1;

}

因为同一时间只能有一个线程进入方法,所以不会发生诸如并行执行的问题。

同步方法依赖“内在锁”或“监听锁(monitor locks)”实现线程安全,内在锁,是一种与某个关联实例的隐式内部实体。

多线程环境下,“监听”一词,指的是对关联实例的上锁行为,该行为会确保特定方法/代码的独家访问权。

当一个线程调用同步方法,它就会获得内在锁。执行完毕后,它就会释放锁,允许其他线程获得锁并进入方法。

我们可以在实例方法、静态方法和代码块中使用同步功能。

9.同步代码块

有时,我们仅需一部分代码实现线程安全。在这个前提下,同步整个方法,未免铺张浪费。

例如,我们来重写incrementCounter():

public void incrementCounter() {

    // 其他无需同步的操作

    synchronized(this) {

        counter += 1; 

    }

}

例子很简单,但它展示了创建同步代码块的原理。假设该方法有一些额外的、不需同步的业务,那我们只需用synchronized块包住需要同步的代码即可。

与同步方法不同,同步代码块需要指定内在锁锁定的对象,通常用this即可。

同步是一种昂贵的操作,如果非要使用,可以只同步必须部分。

同步方法和同步代码块,可有效解决线程间的变量可视度问题。即便如此,普通类中的值有可能被CPU缓存。因此,即使使用同步技术,在变量被改变后,其它线程也有可能,无法获取最新的变量值。

我们使用volatile防止这种情况出现:

public class Counter {

    private volatile int counter;

    // 标准的构造方法/getter

}

volatile关键字告诉JVM和编译器,把counter变量存放在主内存中。这样有效保证JVM每次都会从/向主内存读取/写入counter变量,而非CPU缓存。

使用volatile,确保了对某个线程可见的所有变量,均是从主内存读取而来。

public class User {

    private String name;

    private volatile int age;

    // 标准的构造方法/getter

}

以上例子里,JVM不仅会向主内存写入age变量,还会写入name变量到主内存。这样保证了两个变量的最新数值都保存在主内存中,更新变量的结果对其他线程均可见。

相似的,如果某个线程读取了volatile变量中的值,所有对该线程可见的变量,都会从主内存中读取数值。

volatile提供的这个特性,被称为一变量volatile,全员走内存(full volatile visibility guarantee)。

11.外在锁

我们可以使用外在锁(而非内在锁),稍微改进一下Counter类中的线程安全实现。

外在锁同样保证了多线程环境下,共享资源的访问问题,不同的是,它使用了外在实体来对排他性访问进行上锁。

public class ExtrinsicLockCounter {

    private int counter = 0;

    private final Object lock = new Object();

    public void incrementCounter() {

        synchronized(lock) {

            counter += 1;

        }

    }

    // 标准的getter

}

我们使用一个普通的Object创造了外在锁,这种实现增加了锁的安全性,比前一种实现稍微好点。

使用内在锁时,synchronized方法和代码块依赖this引用,攻击者可以获得内在锁,然后触发DoS(denial of service)状态,引发死锁。

与内在锁不同的是,外在锁使用了私有实体,无法从外度获取。这加大了攻击者获得锁并引发死锁的难度。

12.可重入锁

Java提供了一系列改进的锁功能,它们的行为,比上面讨论的内在锁要稍微复杂一些。

内在锁的获取/释放机制相当死板:一个线程获得锁,开始执行方法或代码块,最终,锁会被释放,然后被其他线程获取。

内在锁并没有机制去为线程排队,为等待时间最长的线程提供优先权。

可重入锁提供了这些功能,避免等待的线程出现资源饥荒。

public class ReentrantLockCounter {

    private int counter;

    private final ReentrantLock reLock = new ReentrantLock(true);

    public void incrementCounter() {

        reLock.lock();

        try {

            counter += 1;

        } finally {

            reLock.unlock();

        }

    }

    // 标准的构造方法/getter

}

在初始化可重入锁时,能传入一个布尔值参数,表示是否实现公平分配。当设为true时,JVM会为等待时间最长的线程提供优先权。

13.读/写锁

另一个可以实现线程安全的强大机制就是读/写锁。

读写锁是一对关联的锁,一个负责只读操作,另一个负责写操作。

因此,只要没线程写入资源,就可以实现多个线程同时访问资源。在线程写入资源时,会防止其他线程读取资源。

可以像下例那样使用读写锁:

public class ReentrantReadWriteLockCounter {

    private int counter;

    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();

    private final Lock readLock = rwLock.readLock();

    private final Lock writeLock = rwLock.writeLock();

    public void incrementCounter() {

        writeLock.lock();

        try {

            counter += 1;

        } finally {

            writeLock.unlock();

        }

    }

    public int getCounter() {

        readLock.lock();

        try {

            return counter;

        } finally {

            readLock.unlock();

        }

    }

   // 标准的构造方法

}

14.结论

在这篇文章中,我们学习了Java的线程安全,并展示了不同的实现方法。

上一篇下一篇

猜你喜欢

热点阅读