欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

大话线程安全与线程安全的实现方式

程序员文章站 2023-12-28 14:16:34
...

什么是线程安全

一个类可以被多个线程安全调用那么这个类就是线程安全的。

根据线程共享数据的安全程度可以分为以下五类线程安全:

  1. 不可变
  2. 绝对线程安全
  3. 相对线程安全
  4. 线程兼容
  5. 线程对立

不可变(Immutable)

不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。

不可变的类型:

  1. final 关键字修饰的基本数据类型
  2. String
  3. 枚举类型
  4. Number 部分子类,如 Long 和 Double等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
  5. 集合类型,可以使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合。

获取不可变集合代码如下:

public class ImmutableExample {
    public static void main(String[] args) {
        Map<String, Integer> map = new HashMap<>();
        Map<String, Integer> unmodifiableMap = Collections.unmodifiableMap(map);
        unmodifiableMap.put("a", 1);
    }
}

由于 unmodifiableMap是不可变的,因此使用put方法时会报出 UnsupportedOperationException异常

绝对线程安全

不管运行时环境如何,调用者都不需要任何额外的同步措施这就是绝对线程安全。

相对线程安全

相对线程安全需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施。但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

java里大部分的线程安全类就是相对线程安全的,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。

例如:如果一个线程删除Vector 中的一个元素,另一个线程试图获取这个被删除的元素,会抛出ArrayIndexOutOfBoundsException异常。

实例代码如下:

public class VectorUnsafeDemo {
    private static Vector<Integer> vector = new Vector<>();

    public static void main(String[] args) {
        while (true) {
            for (int i = 0; i < 100; i++) {
                vector.add(i);
            }
            ExecutorService executorService = Executors.newCachedThreadPool();
            executorService.execute(() -> {
                for (int i = 0; i < vector.size(); i++) {
                    vector.remove(i);
                }
            });
            executorService.execute(() -> {
                for (int i = 0; i < vector.size(); i++) {
                    vector.get(i);
                }
            });
            executorService.shutdown();
        }
    }
}

解决方式:为删除和获取进行同步(这里使用synchronized

代码如下:

executorService.execute(() -> {
    synchronized (vector) {
        for (int i = 0; i < vector.size(); i++) {
            vector.remove(i);
        }
    }
});
executorService.execute(() -> {
    synchronized (vector) {
        for (int i = 0; i < vector.size(); i++) {
            vector.get(i);
        }
    }
});

线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。Java API 中大部分的类都是属于线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap 等。

线程对立

线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。


实现线程安全的方式

互斥同步(阻塞同步)

互斥同步方式实现线程安全也是我们编程中最常用的实现方式。主要是使用 synchronizedReentrantLock
如果想要初步了解 synchronized 和 ReentrantLock ,可以参考:

聊一聊线程互斥与同步的那些事(以实例解释synchronized与ReentrantLock)

互斥同步方式是属于阻塞方式,是一种悲观的并发策略,性能上不如非阻塞同步方案。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

悲观策略:不做就不会做错。(出错的人总是那些干活的人,不干活的人是不会犯错的)

非阻塞同步

非阻塞同步方案目前主流的有 CAS,Atomic类。

CAS

CAS是基于冲突检测的乐观并发策略 ,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。 乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是: 比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。

硬件速度是高于软件速度的,因此CAS是比同步互斥的方式性能更佳。

Atomic类

其实原子类的很多方法都是使用了Unsafe的CAS做非阻塞同步,因此在一定程度上说原子类只是CAS的一种JDK实现,我们不需要关注内部实现,直接使用即可,但是需要明白原子类实现线程安全的机制是非阻塞的,性能高于 synchronized 加锁的对象。


无同步方案

上面说的两种方式都是同步方案的两种解决方案,而这种方案是 无同步。原理很简单:

如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性

实现无同步 的解决方案主要有 :栈封闭线程本地存储可重入代码

栈封闭

首先,java使用栈封闭的方案有 :

  1. JUC线程池: FutureTask详解
  2. JUC线程池: ThreadPoolExecutor详解
  3. JUC线程池:ScheduledThreadPool详解
  4. JUC线程池: Fork/Join框架详解

多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

示例代码如下:

public class StackClosedExample {
    public void add100() {
        int cnt = 0;
        for (int i = 0; i < 100; i++) {
            cnt++;
        }
        System.out.println(cnt);
    }
}
public static void main(String[] args) {
    StackClosedExample example = new StackClosedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> example.add100());
    executorService.execute(() -> example.add100());
    executorService.shutdown();
}

结果如下:

100
100

线程本地存储(Thread Local Storage)

本地存储的java实现 : Java 并发 - ThreadLocal详解

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。

可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能。

对于以下代码,thread1 中设置 threadLocal 为 1,而 thread2 设置 threadLocal 为 2。过了一段时间之后,thread1 读取 threadLocal 依然是 1,不受 thread2 的影响。

示例代码如下:

public class ThreadLocalDemo {
    public static void main(String[] args) {
        ThreadLocal threadLocal = new ThreadLocal();
        Thread thread1 = new Thread(() -> {
            threadLocal.set(1);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(threadLocal.get());
            threadLocal.remove();
        });
        Thread thread2 = new Thread(() -> {
            threadLocal.set(2);
            threadLocal.remove();
        });
        thread1.start();
        thread2.start();
    }
}

结果如下:

1

可重入代码(Reentrant Code)

这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。

可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。

上一篇:

下一篇: