MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java多线程下的资源竞争与解决办法

2022-03-086.6k 阅读

Java多线程下的资源竞争问题剖析

多线程资源竞争的概念

在Java的多线程编程中,资源竞争(Race Condition)是指当多个线程同时访问和修改共享资源时,最终的执行结果依赖于这些线程执行的相对时间顺序。这种不确定性会导致程序出现难以调试和预测的错误。共享资源可以是内存中的变量、文件、数据库连接等。

例如,假设有两个线程同时对一个共享的计数器变量进行加1操作。理想情况下,这个操作应该是原子的,即要么完全执行,要么完全不执行。但在多线程环境中,如果没有适当的同步机制,就可能出现以下情况:线程A读取了计数器的值,此时线程B也读取了相同的值,然后线程A对其加1并写回,接着线程B也对其加1并写回。这样,尽管进行了两次加1操作,但计数器只增加了1,这就是典型的资源竞争问题。

产生资源竞争的原因

  1. 线程执行的不确定性:操作系统的线程调度算法决定了线程何时获得CPU时间片来执行。由于线程调度的不确定性,多个线程对共享资源的访问顺序难以预测。在单核CPU上,线程是通过时间片轮转的方式交替执行;在多核CPU上,多个线程可能同时在不同的核心上执行。这种执行顺序的不确定性为资源竞争创造了条件。
  2. 共享资源的存在:当多个线程需要访问和修改同一个资源时,就有可能发生资源竞争。如果这些线程对共享资源的访问没有进行协调,就会导致数据不一致等问题。比如多个线程同时向一个文件写入数据,如果没有同步,文件内容可能会变得混乱。
  3. 非原子操作:Java中的许多操作,如对基本数据类型(除了longdouble在某些情况下)的简单读写操作通常被认为是原子的。但像i++这样看似简单的操作,实际上包含了读取、增加和写回三个步骤,不是原子的。多个线程同时执行这样的非原子操作,就容易引发资源竞争。

资源竞争带来的问题

数据不一致

数据不一致是资源竞争最常见的问题之一。例如,在一个银行转账的场景中,从账户A向账户B转账100元,涉及到两个操作:从账户A减去100元,向账户B加上100元。如果这两个操作在多线程环境下没有同步,可能会出现账户A已经减去100元,但账户B还未加上100元的情况,导致银行系统中总金额减少,这显然是不符合业务逻辑的。

以下是一个简单的代码示例来演示数据不一致问题:

public class DataInconsistencyExample {
    private static int accountA = 1000;
    private static int accountB = 1000;

    public static void transfer() {
        // 从账户A减去100元
        accountA = accountA - 100;
        // 模拟一些业务处理时间
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 向账户B加上100元
        accountB = accountB + 100;
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                transfer();
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                transfer();
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("账户A余额: " + accountA);
        System.out.println("账户B余额: " + accountB);
        System.out.println("总金额: " + (accountA + accountB));
    }
}

在上述代码中,transfer方法模拟了转账操作。由于没有同步机制,在多次执行main方法时,可能会发现总金额不等于预期的2000000(2000 * 1000),这就是数据不一致问题。

程序异常和崩溃

资源竞争还可能导致程序抛出异常甚至崩溃。例如,多个线程同时访问一个有限大小的缓冲区,如果没有正确的同步,可能会导致缓冲区溢出或下溢。当一个线程在缓冲区已满的情况下继续写入数据,就会引发缓冲区溢出异常,导致程序崩溃。

以下是一个简单的缓冲区示例:

public class BufferOverflowExample {
    private static final int MAX_SIZE = 10;
    private static int[] buffer = new int[MAX_SIZE];
    private static int index = 0;

    public static void write(int value) {
        if (index >= MAX_SIZE) {
            throw new RuntimeException("缓冲区溢出");
        }
        buffer[index++] = value;
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 20; i++) {
                write(i);
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 20; i++) {
                write(i);
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在这个示例中,write方法向缓冲区写入数据。如果两个线程同时快速写入数据,很可能在没有正确同步的情况下,导致index超过MAX_SIZE,从而抛出“缓冲区溢出”异常。

解决资源竞争的办法

使用synchronized关键字

  1. 对象锁synchronized关键字可以用于方法和代码块。当修饰实例方法时,它使用对象的实例作为锁。例如,对于前面的银行转账示例,可以通过在transfer方法上添加synchronized来解决数据不一致问题。
public class SynchronizedTransferExample {
    private static int accountA = 1000;
    private static int accountB = 1000;

    public static synchronized void transfer() {
        accountA = accountA - 100;
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        accountB = accountB + 100;
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                transfer();
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                transfer();
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("账户A余额: " + accountA);
        System.out.println("账户B余额: " + accountB);
        System.out.println("总金额: " + (accountA + accountB));
    }
}

在上述代码中,transfer方法被synchronized修饰,这意味着同一时间只有一个线程可以执行该方法,从而保证了转账操作的原子性,避免了数据不一致问题。

  1. 类锁:当synchronized修饰静态方法时,它使用类的Class对象作为锁。例如:
public class ClassLevelSynchronization {
    private static int counter = 0;

    public static synchronized void increment() {
        counter++;
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("计数器的值: " + counter);
    }
}

在这个例子中,increment方法是静态的且被synchronized修饰,所以使用的是类锁。所有线程访问这个静态方法时,都需要获取类的Class对象锁,从而保证了counter的正确递增。

  1. 同步代码块:除了修饰方法,synchronized还可以用于同步代码块。这种方式更加灵活,可以指定具体的锁对象。例如:
public class SynchronizedBlockExample {
    private static int value = 0;
    private static final Object lock = new Object();

    public static void increment() {
        synchronized (lock) {
            value++;
        }
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("value的值: " + value);
    }
}

在上述代码中,synchronized代码块使用lock对象作为锁。只有获取到lock锁的线程才能执行代码块中的value++操作,从而避免了资源竞争。

使用ReentrantLock

  1. 基本使用ReentrantLock是Java 5.0引入的一种可重入的互斥锁,它提供了比synchronized更灵活的锁控制。例如:
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {
    private static int counter = 0;
    private static final ReentrantLock lock = new ReentrantLock();

    public static void increment() {
        lock.lock();
        try {
            counter++;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("计数器的值: " + counter);
    }
}

在这个示例中,ReentrantLocklock方法用于获取锁,unlock方法用于释放锁。为了确保锁一定会被释放,通常将unlock放在finally块中。这样,在多线程环境下,counter的递增操作就不会出现资源竞争问题。

  1. 锁的公平性ReentrantLock可以设置为公平锁或非公平锁。公平锁会按照线程请求锁的顺序来分配锁,而非公平锁则不保证顺序,有可能刚请求锁的线程就获得锁。默认情况下,ReentrantLock是非公平锁,因为非公平锁在大多数情况下性能更好。设置公平锁的方式如下:
import java.util.concurrent.locks.ReentrantLock;

public class FairReentrantLockExample {
    private static int counter = 0;
    private static final ReentrantLock lock = new ReentrantLock(true); // true表示公平锁

    public static void increment() {
        lock.lock();
        try {
            counter++;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        // 多线程操作counter
        //...
    }
}

虽然公平锁能保证线程获取锁的公平性,但由于线程切换等开销,性能可能会比非公平锁略低。在实际应用中,需要根据具体场景选择合适的锁类型。

使用Atomic

  1. AtomicInteger示例:Java的java.util.concurrent.atomic包提供了一系列原子类,如AtomicIntegerAtomicLong等。这些类通过硬件级别的原子操作来保证数据的一致性,避免了资源竞争。例如:
import java.util.concurrent.atomic.AtomicInteger;

public class AtomicIntegerExample {
    private static AtomicInteger counter = new AtomicInteger(0);

    public static void increment() {
        counter.incrementAndGet();
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("计数器的值: " + counter.get());
    }
}

在上述代码中,AtomicIntegerincrementAndGet方法是原子操作,它会自动保证在多线程环境下的正确递增,无需额外的同步机制。

  1. AtomicReference:对于引用类型,AtomicReference可以保证对引用的原子操作。例如,假设有一个自定义的类User,并且需要在多线程环境下安全地更新User对象的引用:
import java.util.concurrent.atomic.AtomicReference;

class User {
    private String name;

    public User(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

public class AtomicReferenceExample {
    private static AtomicReference<User> userRef = new AtomicReference<>(new User("初始用户"));

    public static void updateUser() {
        User newUser = new User("新用户");
        userRef.compareAndSet(userRef.get(), newUser);
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            updateUser();
        });
        Thread thread2 = new Thread(() -> {
            updateUser();
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("当前用户: " + userRef.get().getName());
    }
}

在这个示例中,AtomicReferencecompareAndSet方法会比较当前引用和预期引用,如果相同则更新为新的引用。这样可以在多线程环境下安全地更新User对象的引用。

使用线程安全的集合类

  1. ConcurrentHashMap:在多线程环境下使用HashMap可能会导致资源竞争问题,因为HashMap不是线程安全的。而ConcurrentHashMap则是线程安全的。例如:
import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapExample {
    private static ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

    public static void putData(String key, Integer value) {
        map.put(key, value);
    }

    public static Integer getData(String key) {
        return map.get(key);
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            putData("key1", 1);
        });
        Thread thread2 = new Thread(() -> {
            putData("key2", 2);
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("key1的值: " + getData("key1"));
        System.out.println("key2的值: " + getData("key2"));
    }
}

在上述代码中,ConcurrentHashMap保证了在多线程环境下对map的读写操作是线程安全的,不会出现资源竞争问题。

  1. CopyOnWriteArrayListArrayList不是线程安全的,在多线程环境下使用可能会出现问题。CopyOnWriteArrayList则通过在修改时复制底层数组的方式来保证线程安全。例如:
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

public class CopyOnWriteArrayListExample {
    private static List<String> list = new CopyOnWriteArrayList<>();

    public static void addElement(String element) {
        list.add(element);
    }

    public static String getElement(int index) {
        return list.get(index);
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            addElement("元素1");
        });
        Thread thread2 = new Thread(() -> {
            addElement("元素2");
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("第一个元素: " + getElement(0));
        System.out.println("第二个元素: " + getElement(1));
    }
}

在这个示例中,CopyOnWriteArrayList确保了在多线程环境下对list的添加和读取操作的线程安全性。

使用线程局部变量(ThreadLocal

  1. 基本概念ThreadLocal为每个线程提供了独立的变量副本,从而避免了多线程之间对共享变量的竞争。每个线程都可以独立地修改自己的副本,而不会影响其他线程的副本。例如,假设有一个需要在多线程环境下使用的数据库连接对象,每个线程都应该有自己独立的连接,以避免资源竞争。
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class ThreadLocalConnectionExample {
    private static final ThreadLocal<Connection> connectionThreadLocal = ThreadLocal.withInitial(() -> {
        try {
            return DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "root", "password");
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    });

    public static Connection getConnection() {
        return connectionThreadLocal.get();
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            Connection conn1 = getConnection();
            // 使用conn1进行数据库操作
        });
        Thread thread2 = new Thread(() -> {
            Connection conn2 = getConnection();
            // 使用conn2进行数据库操作
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,ThreadLocalwithInitial方法为每个线程初始化一个数据库连接。每个线程通过getConnection方法获取的连接都是自己独立的副本,从而避免了资源竞争。

  1. 注意事项ThreadLocal虽然能有效避免资源竞争,但也需要注意内存泄漏问题。如果ThreadLocal对象的生命周期比线程长,并且线程一直存活,那么ThreadLocal中的数据也会一直占用内存。因此,在使用完ThreadLocal后,应该及时调用remove方法清除线程本地变量。例如:
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class ThreadLocalCleanupExample {
    private static final ThreadLocal<Connection> connectionThreadLocal = ThreadLocal.withInitial(() -> {
        try {
            return DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "root", "password");
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    });

    public static Connection getConnection() {
        return connectionThreadLocal.get();
    }

    public static void cleanup() {
        connectionThreadLocal.remove();
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            Connection conn1 = getConnection();
            // 使用conn1进行数据库操作
            cleanup();
        });
        Thread thread2 = new Thread(() -> {
            Connection conn2 = getConnection();
            // 使用conn2进行数据库操作
            cleanup();
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在这个示例中,cleanup方法调用remove方法清除线程本地变量,以避免潜在的内存泄漏。

性能考量与选择合适的解决方案

synchronizedReentrantLock的性能比较

  1. 竞争程度较低时:在竞争程度较低的情况下,synchronized的性能表现较好。因为synchronized是Java的内置关键字,在字节码层面就进行了支持,其实现相对轻量级。例如,在一个只有少量线程偶尔访问共享资源的场景中,synchronized的开销相对较小。
  2. 竞争程度较高时:当竞争程度较高时,ReentrantLock通常表现更优。ReentrantLock提供了更灵活的锁控制,如可中断的锁获取、公平锁与非公平锁的选择等。在高竞争环境下,ReentrantLock可以通过合理的配置来减少线程的等待时间,提高整体性能。例如,在一个多线程频繁访问共享资源的高并发系统中,使用ReentrantLock并设置为非公平锁,可能会获得更好的性能。

Atomic类与锁机制的性能比较

  1. 简单操作:对于简单的原子操作,如对基本数据类型的增减操作,Atomic类的性能通常优于使用锁机制。Atomic类利用硬件级别的原子指令来实现操作,避免了锁带来的线程上下文切换等开销。例如,AtomicIntegerincrementAndGet方法在多线程环境下的性能要比使用synchronizedReentrantLock保护的普通int变量的递增操作好。
  2. 复杂操作:然而,当操作涉及到多个步骤或者需要复杂的逻辑判断时,锁机制可能更合适。因为Atomic类主要针对简单的原子操作进行优化,对于复杂操作,可能需要使用锁来保证操作的原子性和一致性。例如,在一个涉及到多个变量的复杂计算并且需要保证数据一致性的场景中,使用锁来同步整个操作可能更易于实现和维护。

线程安全集合类的性能特点

  1. ConcurrentHashMapConcurrentHashMap在多线程环境下具有较高的并发性能。它采用了分段锁的机制,允许多个线程同时访问不同的段,从而提高了并发度。在读取操作方面,ConcurrentHashMap不需要加锁,因此读操作的性能非常高。而在写操作时,只有需要修改的段会被锁定,不会影响其他段的访问。
  2. CopyOnWriteArrayListCopyOnWriteArrayList适合读多写少的场景。由于写操作时需要复制整个数组,所以写操作的开销较大。但是,读操作时不需要加锁,直接读取数组,因此读操作的性能较好。在一些需要频繁读取且偶尔进行写操作的场景中,如日志记录等,CopyOnWriteArrayList是一个不错的选择。

选择合适解决方案的考虑因素

  1. 业务场景:首先要根据具体的业务场景来选择解决方案。如果业务操作简单且对性能要求极高,如对计数器的频繁递增操作,Atomic类可能是最佳选择。如果业务逻辑复杂,涉及多个步骤的原子操作,如银行转账等,锁机制(synchronizedReentrantLock)可能更合适。
  2. 并发程度:并发程度也是一个重要的考虑因素。在低并发场景下,简单的synchronized可能就足以满足需求,并且实现简单。而在高并发场景下,需要选择更高效的并发控制机制,如ReentrantLockConcurrentHashMap等。
  3. 代码复杂度:从代码复杂度的角度来看,synchronizedAtomic类的使用相对简单,而ReentrantLock的使用相对复杂,需要手动获取和释放锁。如果项目对代码的可维护性要求较高,并且并发控制相对简单,应优先选择简单的解决方案。

综上所述,在Java多线程编程中,解决资源竞争问题需要综合考虑性能、业务场景和代码复杂度等多个因素,选择最合适的解决方案,以确保程序的正确性和高效性。