Java多线程修改数据

本文通过示例介绍了多线程环境下对共享资源进行操作的安全问题,从不安全的实现开始,逐步引入`synchronized`、读写锁以及线程池来提高并发性能和安全性。最后,展示了使用`Callable`和`Future`获取线程返回值的方式,强调了线程池和并发控制在处理大量任务时的重要性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

问题定义: 定义一个类Number,其中包括变量num。多个线程对num进行读取和减1的操作。

一、不安全的实现

将num定义成静态变量:

public class Number {
    public static int num = 30;
    public static AtomicInteger cnt = new AtomicInteger(0);
}

为了更好地看出线程多num的操作是否正确,用cnt来记录对num修改的次数,每有一个线程对num减去了1,就将cnt+1。cnt是AtomicInteger类型,使用了CAS来保证线程安全,因此cnt一定是正确的数值。

线程安全: 在多线程环境下能保证执行结果永远是正确的。

CAS: 假设要修改数据A,首先记录数据A的内存地址V,之后读出数据记作A,将A改为B。写回内存前检查V处的数据是否仍为A,若是则将B写回。若不是重新执行读出数据、修改、判断的操作。这是一种乐观并发控制。

乐观并发控制: 假设不会有多个线程同时操作同一数据(所以称为“乐观”),在操作数据前不加锁,操作结束提交之前检查这个数在这期间有没有修改过。相对的是悲观并发控制,不论读取数据都先加锁。

对num进行操作的线程:

public class UnsafeImpl implements Runnable {
    @Override
    public void run() {
        while (Number.num != 0) {
            Number.num--;
            System.out.println("这是第"+ Number.cnt.incrementAndGet()+"次修改,"+Thread.currentThread().getName() + ",剩余" + Number.num);
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

sleep(100)是为了更好看出线程的调度效果。在运行结果中,可以看到对num修改的过程不正确,比如会有多个线程修改后值不变,导致最终修改的次数>30
多个线程修改过后num不变
num值为30,却进行了43次减1操作

二、使用synchronized

将对num的操作封装到Number类中,使用synchronized修饰两个方法,保证了同一时间只有一个线程会执行这一方法。一个线程在调用dec(), get()方法之前,会先尝试获取NumberSync对象拥有的锁,获取了锁才能执行方法,获取失败则将自己挂起,等待占有锁的线程发出通知。执行方法后,线程会释放自己占有的锁并通知其他想要拥有锁的线程。

public class NumberSync {
    private int num = 30;
    public static AtomicInteger cnt = new AtomicInteger(0);
    synchronized public void dec() {
        num--;
        System.out.println("这是第" + NumberSync.cnt.incrementAndGet() + "次修改," + Thread.currentThread().getName() + ",剩余" + num);
    }
    synchronized public int get() {
        return num;
    }
}

synchronized不需要手动释放锁,因此方法中的代码不变。

用于操作Number的类,要将同一个Number对象作为自己的成员变量:

public class SyncImpl implements Runnable {
    private NumberSync numberSync;
    public SyncImpl(NumberSync numberSync) {
        this.numberSync = numberSync;
    }

    @Override
    public void run() {
        while (numberSync.get() != 0) {
            numberSync.dec();
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
三、使用读写锁

在上面一种实现中,dec(), get()方法都只允许同一时刻只有一个线程在执行。事实上,可以有多个线程同时执行get()方法,但只允许有一个线程执行dec()方法,且这个时期不能有线程在执行get()方法。这种要求可以使用读写锁来实现。将Number类修改如下:

public class NumberRWLock {
    private int num = 30;
    public static AtomicInteger cnt = new AtomicInteger(0);
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    private final Lock readLock = lock.readLock();
    private final Lock writeLock = lock.writeLock();
    public void dec() {
        writeLock.lock();
        --num;
        writeLock.unlock();
    }
    public int get() {
        readLock.lock();
        try {
            return num;
        } finally {
            readLock.unlock();
        }
    }
}

首先创建ReentrantReadWriteLock对象,之后用这个对象创建readLock和writeLock对象,分别对应读锁和写锁。在写数据的方法中加写锁,在只有读取操作的方法中加读锁。要注意,return语句不会自动释放锁,因此需要用try catch包围return语句,在方法结束后手动释放读锁。

用这种方法允许多线程同时读取num,提高了效率。下面将这种方法与使用synchronized的方法进行一个对比,创建10个线程,每个线程调用100000次get()方法。将Number类中的cnt改为基本类型int,在get()方法中每次将cnt+1。

public class NumberRWLock {
    private int num = 30;
    public int cnt = 0;
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    private final Lock readLock = lock.readLock();
    private final Lock writeLock = lock.writeLock();
    public void dec() {
        writeLock.lock();
        --num;
        writeLock.unlock();
    }
    public int get() {
        readLock.lock();
        try{
            ++cnt;
            return num;
        } finally {
            readLock.unlock();
        }
    }
}

由于synchronized修饰的方法是线程安全的,因此所有线程结束后cnt的值为10*100000 = 1000000。而使用读写锁的方法中,所有线程结束后cnt值为829751。说明确实存在对get()方法的并发调用。

四、使用线程池

在一些应用中,要处理大量的小任务,比如web服务器,面对1000个请求,按照前面的方法需要创建1000个线程,分别处理每个请求。然而线程的创建、销毁、调度开销很大,创建太多线程是不可行的。解决这种问题可以使用线程池。

线程池维护一定数量的线程,将多个任务提交给线程池,线程池利用自己拥有的线程陆续处理各个任务。比如固定3个线程的线程池处理10个任务,会先处理前3个,有一个任务完成后再处理第4个任务,……

使用线程池代码如下:

public class Main {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        NumberSync numberSync = new NumberSync();
        for (int i = 0; i < 6; ++i) {
            executorService.submit(new ThreadPool(numberSync, Integer.toString(i)));
        }
        executorService.shutdown();
    }
}

首先定义线程池对象executorService,可以定义固定大小的线程池,也可定义根据任务数动态调整的(Executors.CachedThreadPool方法)。最后线程池对象要调用shundown()方法,否则线程池会一直存在,程序会一直运行。

要执行的任务定义在ThreadPool类中,这个类同样要实现Runnable接口:

public class ThreadPool implements Runnable {
    private NumberSync numberSync;
    private String name;
    public ThreadPool(NumberSync numberSync, String name) {
        this.numberSync = numberSync;
        this.name = name;
    }
    @Override
    public void run() {
        while (numberSync.get() != 0) {
            numberSync.dec();
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

以上代码,不论执行多少次,都只有任务0,任务1,任务2在执行,因为线程池中固定只有三个线程,线程池会先执行完前三个任务,之后执行任务3~5,但此时num已经减到0了,因此不会后三个任务不会执行。

五、使用Callable和Future

Runnable接口的run()方法没有返回值,多线程任务要想返回一个值需要在实现Runnable接口的类中设置一个成员变量,之后读取这个变量,比较麻烦。因此可以实现Callable<>接口,实现其中的call()方法作为多线程的任务。Callable<>是一个泛型接口,可以指定任意对象类型作为call()方法的返回值。

比如要计算每个线程对num的修改次数并返回,实现Callable<>的代码为:

public class CallableImpl implements Callable<Integer> {
    private NumberSync numberSync;

    public CallableImpl(NumberSync numberSync) {
        this.numberSync = numberSync;
    }
    @Override
    public Integer call() throws Exception {
        AtomicInteger count = new AtomicInteger(0);
        while (numberSync.get() != 0) {
            numberSync.dec();
            count.incrementAndGet();
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        return count.get();
    }
}

实现了Callable<>接口的类不能用来创建线程,只能作为任务提交给线程池。当线程池对象调用submit()方法时,会返回一个Future<>对象,用来接收任务的返回值。

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        NumberSync numberSync = new NumberSync();
        // 以下语句报错
        // Thread t = new Thread(new CallableImpl(numberSync));
        // 正确写法
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        List<Future<Integer>> futureList = new ArrayList<>();
        for (int i = 0; i < 3; ++i) {
            futureList.add(executorService.submit(new CallableImpl(numberSync)));
        }
        executorService.shutdown();
        for (int i = 0; i < 3; ++i) {
            System.out.println(futureList.get(i).get());
        }
    }
}

对Future<>对象调用get()方法会得到线程执行的返回值,若线程还没有执行完,则这个调用get()方法的线程会阻塞。比如将上面的代码改为:

ExecutorService executorService = Executors.newFixedThreadPool(3);
List<Future<Integer>> futureList = new ArrayList<>();
for (int i = 0; i < 3; ++i) {
    futureList.add(executorService.submit(new CallableImpl(numberSync)));
    System.out.println(futureList.get(i).get());
}
executorService.shutdown();

得到的结果总是任务1执行30次,任务2和3执行0次。因为主线程在for循环中调用get()方法,这时子线程1还没有执行完任务1,主线程阻塞,直到子线程1(任务1)执行完,之后任务2和3提交到线程池中,但此时num已经减为0,因此任务2,3不会执行。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值