当前位置:   article > 正文

【超详细】深入探究Java中的线程安全,让你的程序更加可靠~_java 线程安全的

java 线程安全的

深入探究Java中的线程安全,让你的程序更加可靠!

我们将从以下四个问题入手,对Java的多线程问题抽丝剥茧。

  1. 什么是线程安全?
  2. 如何实现线程安全?
  3. 不同的线程安全实现方法有什么区别?
  4. 如何实现HashMap线程安全?
1. 什么是线程安全?

线程安全指的是多个线程并发访问共享资源时,不会出现数据不一致或其他意外情况的情况。在多线程编程中,线程安全非常重要,因为多个线程可能会同时访问和修改同一数据,如果不进行适当的同步处理,就可能导致数据不一致、竞态条件和死锁等问题。

为了实现线程安全,需要使用一些技术和方法来保证数据的一致性和同步性,例如锁机制、原子操作、线程局部变量等。常用的线程安全类包括Vector、CopyOnWriteArrayList、Hashtable、ConcurrentHashMap、原子类等。

2. 如何实现线程安全?

在Java中,线程安全可以通过以下几种方式实现:

  1. synchronized关键字:读作“森科奈日得”。Java中最基本的锁机制。使用synchronized关键字可以保证多个线程访问共享资源时的互斥性,确保同一时刻只有一个线程能够访问共享资源。可以用于方法或代码块。当方法或代码块被synchronized关键字修饰时,只有一个线程能够进入该方法或代码块,其他线程则会被阻塞,直到当前线程执行完毕。

synchronized的实现原理:被synchronized修饰的代码块称为同步块,当线程进入同步块时,会尝试获取对象的锁,如果对象没有被加锁或者已经获取了该对象的锁,则锁计数器+1;如果该对象已经被其他线程加锁,则该线程会进入阻塞状态,等待其他线程释放锁。当其他线程释放锁后,等待的线程会被唤醒,并重新尝试获取锁并执行同步块中的代码。同一时刻,只有一个线程可以获取对象的锁并执行同步块中的代码。

对象头:在Java中,每个对象都有一个对象头(Object
Header),它用于存储对象的元数据,包括对象的哈希码(hashCode)、锁状态、GC标记状态等信息。对象头的大小是固定的,通常占用8个字节(64位系统)或4个字节(32位系统)。

对象头中最重要的信息是锁状态,用于实现Java中的synchronized关键字的同步机制。锁状态的值可以是无锁状态、偏向锁状态、轻量级锁状态或重量级锁状态,锁状态取决于线程之间的竞争情况和锁的使用方式。

以下方法为synchronized关键字的使用:

public class Counter {
    private int count;

    // synchronized 修饰方法
    public synchronized void increment() {
        count++;
    }

    // synchronized 修饰代码块
    public void add(int n) {
        synchronized (this) {
            count += n;
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

使用synchronized关键字的注意点:

  • 方法是实例方法(非静态方法)

优点:简单易用、支持可重用锁。
缺点:性能问题、只能保护代码块或方法。

  1. volatile关键字:读作“我你太欧”。只能用于修饰变量。使用volatile关键字可以保证变量的可见性,即使多个线程同时访问同一个变量时,保证变量的值是一致的。此外,volatile还具有禁止指令重排的作用。当一个变量被volatile修饰时,所有线程访问该变量都是从主内存中读取最新的值。

可见性:如果两个线程同时对一个volatile变量进行修改,由于volatile变量能够保证可见性,那么它们的修改结果都会被立即刷新到主内存中,从而使得另外一个线程可以读取到最新的值。

读取操作顺序与写入操作顺序一致:如果一个线程读取了volatile变量,而在它进行写入之前,另一个线程也读取了同一个volatile变量,那么在第一个线程写入变量之后,另一个线程读取到的变量值是第一个线程写入的最新值,而不是读取时的值。

指令重排:是指处理器或编译器为了优化程序执行效率,在不改变原有程序执行结果的前提下,改变指令的执行顺序,以达到减少指令执行的等待时间、利用处理器的多级流水线、减少分支预测错误等目的。在单线程环境下,指令重排不会带来任何问题,因为最终执行结果不会发生变化。但在多线程环境下,指令重排可能会导致一些意料之外的结果,例如数据不一致、死锁、无限循环等问题。

以下方法为volatile关键字的使用:

public class VolatileExample {
    private volatile int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

优点:变量对于所有线程的可见性、顺序读写
缺点:不能保证原子性、频繁读写volatile变量的开销大

  1. Lock锁:使用Lock锁机制可以实现更加灵活的锁操作,比synchronized关键字更加高效。Lock锁最常用的是可重入锁ReentrantLock,Reentrant读作“瑞恩穿特”。

可重入锁是指可以对同一个锁进行重复的加锁和解锁操作,每次加锁操作都必须对应一个解锁操作,否则锁将一直被占用。synchronized关键字也是可重入锁。

以下方法为ReentrantLock可重入锁的使用:

import java.util.concurrent.locks.ReentrantLock;

public class Demo {
    private ReentrantLock lock = new ReentrantLock();

    public void method1() {
        lock.lock();
        try {
            System.out.println("method1");
            method2();
        } finally {
            lock.unlock();
        }
    }

    public void method2() {
        lock.lock();
        try {
            System.out.println("method2");
        } finally {
            lock.unlock();
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

可重入锁的特点:

  • 支持重复加锁。在同一个线程中,可重入锁可以对同一个锁进行多次加锁,而不会出现死锁的情况。

重复加锁:内部有一个类似于计数器的变量(锁计数器),每当加锁时,计数器+1,解锁时,计数器-1,直到计数器为0时释放锁。

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockDemo {
    private ReentrantLock lock = new ReentrantLock();

    public void foo() {
        lock.lock(); // 第一次加锁
        System.out.println(Thread.currentThread().getName() + " get lock.");
        lock.lock(); // 第二次加锁
        System.out.println(Thread.currentThread().getName() + " get lock again.");
        lock.unlock(); // 第一次释放锁
        System.out.println(Thread.currentThread().getName() + " release lock.");
        lock.unlock(); // 第二次释放锁
        System.out.println(Thread.currentThread().getName() + " release lock again.");
    }

    public static void main(String[] args) {
        ReentrantLockDemo demo = new ReentrantLockDemo();

        Thread t1 = new Thread(() -> {
            demo.foo();
        }, "Thread-1");

        Thread t2 = new Thread(() -> {
            demo.foo();
        }, "Thread-2");

        t1.start();
        t2.start();
    }
}

//输出结果
//Thread-1 get lock.
//Thread-1 get lock again.
//Thread-1 release lock.
//Thread-1 release lock again.
//Thread-2 get lock.
//Thread-2 get lock again.
//Thread-2 release lock.
//Thread-2 release lock again.
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 支持公平锁和非公平锁。可重入锁可以指定是公平锁还是非公平锁,默认是非公平锁。

公平锁和非公平锁:公平锁是指多个线程在等待锁时,按照等待的时间先后依次获得锁,即先到先得的策略。非公平锁则是不考虑等待的时间先后,直接去抢占锁,这可能会导致某些线程一直无法获得锁。

  • 支持中断响应。可重入锁允许在等待锁的过程中响应中断。

  • 支持多条件变量。可重入锁可以为每个条件变量创建一个等待队列,并且可以在条件变量上等待或者唤醒指定数量的线程。

优点:可以重复获取锁避免死锁,性能好,可扩展(公平锁非公平锁、可重入读写锁等)
缺点:代码复杂

  1. 原子操作:原子操作是一种无需加锁的线程安全操作方式,可以保证多个线程同时访问同一个变量时,仍能保证数据的一致性。Java中通过使用原子操作类中的原子操作方法,实现线程安全。

优点:保证操作的完整性,不需要加锁,保证数据的一致性和可见性
缺点:不能保证并发访问的顺序,不能保证数据的原子性(如果操作的数据比较大,仍然需要加锁来保证原子性),实现比较复杂

  1. 线程安全的集合类:Java提供了一些线程安全的集合类,例如Vector、CopyOnWriteArrayList、Hashtable、ConcurrentHashMap等。

Vector:可以理解为线程安全的ArrayList,提供的方法与ArrayList相似,使用synchronized关键字实现。比较古老,可以类比HashTable和HashMap的关系。

CopyOnWriteArrayList:线程安全的ArrayList,将原有的数组复制一份,然后在新数组上进行修改操作,最后再赋给原的数组引用。相对Vector更加高效。
原子操作类:包括AtomicInteger、AtomicLong、AtomicBoolean在内7种。

需要针对具体情况选择合适的线程安全技术和方法,保证多个线程能够正确、高效地访问共享资源。

3. 不同的线程安全实现方法有什么区别?

不同的线程安全实现方法有不同的适用场景和性能表现。比如,synchronized关键字适用于对临界区进行加锁,可以保证线程安全,但是性能可能会受到影响;而使用ConcurrentHashMap等线程安全的集合类,则可以在高并发情况下提高性能和并发性能。

| 粒度| 性能| 使用难易
—|—|—|—
synchronized| 修饰方法或代码块,作用对象是整个类或者整个方法、类中的某个成员变量或者代码块| 性能较差,不适合高并发场景。|
只需在需要同步的方法或代码块前加上synchronized关键字
volatile| 修饰变量,作用对象是变量| 频繁读写时开销大| 只需要在变量前加上volatile关键字
ReentrantLock可重入锁| 作用对象是某个变量或者某个代码块| 性能较好,适合高并发场景。| 需要自己手动加锁和释放锁
原子操作| 作用对象是某个变量或者某个代码块| 相对较低| 实现比较复杂,需要对硬件平台和操作系统进行深入了解,对开发人员的要求比较高

4. 如何实现HashMap线程安全?

  1. HashMap和Hashtable
区别HashMapHashtable
线程安全性不安全,需要进行额外的同步操作安全
null值允许key和value都为null不允许
初始容量和扩容机制默认初始容量为16,扩容机制是元素数量大于负载因子和数组长度的乘积时,将数组长度翻倍
默认初始容量为11,扩容机制是元素数量大于数组长度时,将数组长度翻倍再加1
遍历方式通过Iterator实现通过Enumeration实现
  1. HashTable的常见问题:
    HashTable线程安全而HashMap线程不安全:Hashtable采用了同步机制来保证线程安全,即在每个公共方法上使用了synchronized关键字来确保同一时间只能有一个线程操作Hashtable。而HashMap则没有采用这种同步机制。

HashTable公共方法源码:

// 判断Hashtable中是否存在某个value
public synchronized boolean contains(Object value) {
    if (value == null) {
        throw new NullPointerException();
    }

    Entry<?,?> tab[] = table;
    for (int i = tab.length ; i-- > 0 ;) {
        for (Entry<?,?> e = tab[i] ; e != null ; e = e.next) {
            if (e.value.equals(value)) {
                return true;
            }
        }
    }
    return false;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

HashTable的key和value不允许为null:对于 Hashtable,插入元素时,如果该桶中已经存在元素,则通过 equals方法比较新插入的
key 和桶中已经存在的 key 是否相等,在比较 key 是否相等时需要调用 key 的 equals
方法,如果key为null则没有equals方法。Hashtable的值不允许为null,是因为在Hashtable内部,值被存储在一个Object类型的数组中,数组中的每个元素是一个单独的值对象,而不是一个值的引用。因此,如果允许值为null,将导致无法区分数组中的空槽和实际存储了null值的槽。这可能会导致在对Hashtable进行操作时出现意外的结果。为了避免这种情况,Hashtable不允许null值。

HashMap的key和value允许为null:HashMap的设计目标是尽可能提供高效的查找、插入和删除操作,因此对值的类型没有限制。这样可以让使用者在需要时自由地将null作为值来使用,增加了灵活性。在HashMap中,如果key为null,则它的哈希值为0,因此会将其放在哈希表的第0个位置。

  1. 实现HashMap的线程安全
  • 使用 ConcurrentHashMap
    这是Java提供的线程安全的HashMap实现。它通过分段锁(Segment)的方式来实现线程安全,多个线程可以同时访问不同的Segment,从而提高并发度。

ConcurrentHashMap与HashTable的区别:Hashtable 是基于 synchronized 实现线程安全,
ConcurrentHashMap 使用了分段锁的方式来实现线程安全。如果需要在多线程环境下使用哈希表,推荐使用 ConcurrentHashMap。

  • 使用 Collections.synchronizedMap
    这是Java提供的一个工具类,用于将一个非线程安全的Map包装成一个线程安全的Map。它通过对Map的操作加上同步锁(使用synchronized关键字)的方式来实现线程安全。

使用Collections.synchronizedMap实现线程安全代码示例:

Map<String, String> synchronizedMap = Collections.synchronizedMap(new HashMap<>());

synchronizedMap.put("key1", "value1");
synchronizedMap.put("key2", "value2");

String value = synchronizedMap.get("key1");
System.out.println(value);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 使用锁机制
    可以自己实现锁机制来保证HashMap的线程安全。比如可以使用synchronized关键字或者ReentrantLock来实现锁机制,保证同一时刻只有一个线程访问HashMap。

在多线程环境下,推荐使用ConcurrentHashMap,因为它的并发度更高,性能更优,但需要注意一些细节问题,如在遍历时需要使用迭代器等。而如果是简单的线程安全需求,可以考虑使用Collections.synchronizedMap。

学习网络安全技术的方法无非三种:

第一种是报网络安全专业,现在叫网络空间安全专业,主要专业课程:程序设计、计算机组成原理原理、数据结构、操作系统原理、数据库系统、 计算机网络、人工智能、自然语言处理、社会计算、网络安全法律法规、网络安全、内容安全、数字取证、机器学习,多媒体技术,信息检索、舆情分析等。

第二种是自学,就是在网上找资源、找教程,或者是想办法认识一-些大佬,抱紧大腿,不过这种方法很耗时间,而且学习没有规划,可能很长一段时间感觉自己没有进步,容易劝退。

如果你对网络安全入门感兴趣,那么你需要的话可以点击这里

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/知新_RL/article/detail/650057
推荐阅读
相关标签