HOOOS

别再瞎用 Semaphore 了!结合真实案例,教你用它优化数据库、缓存、网络连接

0 54 爱琢磨的程序猿老李 Java并发Semaphore性能优化
Apple

你好,我是爱琢磨的程序猿老李。今天咱们聊聊 Java 并发工具类 Semaphore(信号量)。很多开发者觉得 Semaphore 不就是控制并发线程数嘛,有啥难的?但真要用好它,在实际项目中发挥它的威力,可没那么简单。老李我就结合几个真实的项目案例,给你好好说道说道,Semaphore 到底怎么用才能真正提升系统的性能和稳定性。

Semaphore 是什么?

咱们先来简单回顾一下 Semaphore 的基本概念。Semaphore,中文叫信号量,顾名思义,它就像一个交通信号灯,控制着车辆(线程)的通行。它维护了一个许可集(permits),线程想要访问共享资源,就必须先从 Semaphore 那里获取一个许可。如果许可数量为 0,线程就得等着,直到有其他线程释放许可。

Semaphore 的基本用法非常简单,主要就两个操作:

  • acquire():获取一个许可。如果当前没有可用许可,线程就会阻塞,直到有可用许可为止。
  • release():释放一个许可,将其归还给 Semaphore。

来,看段代码,感受一下:

import java.util.concurrent.Semaphore;

public class SemaphoreDemo {

    private static final int MAX_CONNECTIONS = 5;
    private static final Semaphore semaphore = new Semaphore(MAX_CONNECTIONS, true); // 允许5个线程同时访问

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(() -> {
                try {
                    semaphore.acquire(); // 获取许可
                    System.out.println(Thread.currentThread().getName() + " 正在访问数据库...");
                    Thread.sleep(2000); // 模拟数据库访问
                    System.out.println(Thread.currentThread().getName() + " 访问数据库完毕.");
                    semaphore.release(); // 释放许可
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                }
            });
            thread.setName("Thread-" + i);
            thread.start();
        }
    }
}

这段代码模拟了多个线程同时访问数据库的场景。我们创建了一个 Semaphore,初始许可数量为 5,表示最多允许 5 个线程同时访问数据库。每个线程在访问数据库前,先调用 acquire() 方法获取许可,访问完成后,再调用 release() 方法释放许可。这样,就能保证同时访问数据库的线程数不会超过 5 个,避免了数据库连接过多导致的性能问题。

注意到 new Semaphore(MAX_CONNECTIONS, true) 中的 true 了吗?这个参数表示 Semaphore 是否采用公平策略。如果设置为 true,则 Semaphore 会按照线程请求的顺序来分配许可,也就是先到先得。如果设置为 false,则 Semaphore 不保证分配许可的顺序,哪个线程抢到了就是谁的。一般情况下,为了避免线程饥饿,建议使用公平策略。

真实案例:Semaphore 优化数据库连接

光说不练假把式,咱们来看一个真实的案例。老李我之前参与过一个电商系统的开发,这个系统在高峰期经常出现数据库连接超时的问题。经过排查,发现是数据库连接池配置不合理,导致大量线程在等待数据库连接,最终超时。

怎么解决这个问题呢?我们首先想到的就是优化数据库连接池的配置,比如增大最大连接数、调整连接超时时间等等。但是,这些方法都有局限性。如果最大连接数设置得太大,可能会导致数据库负载过高;如果连接超时时间设置得太长,又可能会导致线程长时间阻塞,影响系统的响应速度。

这时候,Semaphore 就派上用场了。我们可以用 Semaphore 来控制同时访问数据库的线程数,将这个数量限制在一个合理的范围内,既能保证数据库的负载不会过高,又能避免线程长时间阻塞。具体怎么做呢?

  1. 改造数据库连接池

我们自定义了一个数据库连接池,在连接池内部维护了一个 Semaphore。连接池初始化的时候,Semaphore 的许可数量等于连接池的最大连接数。

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.LinkedList;
import java.util.concurrent.Semaphore;

public class MyConnectionPool {

    private final int poolSize;
    private final LinkedList<Connection> connections = new LinkedList<>();
    private final Semaphore semaphore;

    public MyConnectionPool(int poolSize, String url, String user, String password) throws SQLException {
        this.poolSize = poolSize;
        this.semaphore = new Semaphore(poolSize, true);
        for (int i = 0; i < poolSize; i++) {
            Connection connection = DriverManager.getConnection(url, user, password);
            connections.add(connection);
        }
    }

    public Connection getConnection() throws InterruptedException, SQLException {
        semaphore.acquire(); // 获取许可
        synchronized (connections) {
            if (!connections.isEmpty()) {
              return connections.removeFirst();
            }
        }
        return null; //一般不会走到这里,除非连接池配置有问题
    }

    public void releaseConnection(Connection connection) {
        if (connection != null) {
            synchronized (connections) {
                connections.addLast(connection);
            }
            semaphore.release(); // 释放许可
        }
    }

    //关闭连接池中所有连接
     public void close(){
        synchronized (connections){
            for(Connection conn : connections){
                try {
                    conn.close();
                } catch (SQLException e) {
                    throw new RuntimeException(e);
                }               
            }
        }
     }
}
  1. 获取和释放连接

当线程需要访问数据库时,先从连接池中获取连接。获取连接的时候,先调用 Semaphore 的 acquire() 方法获取许可。如果获取到了许可,就从连接池中取出一个连接;如果获取不到许可,线程就会阻塞,直到有其他线程释放连接。

当线程使用完连接后,将连接归还给连接池。归还连接的时候,调用 Semaphore 的 release() 方法释放许可。这样,其他等待连接的线程就有机会获取到许可,从而获取到连接。

通过这种方式,我们就用 Semaphore 对数据库连接进行了精细化的控制。无论并发量有多大,同时访问数据库的线程数都不会超过连接池的最大连接数,有效地避免了数据库连接超时的问题,提高了系统的稳定性和响应速度。

真实案例:Semaphore 优化缓存访问

再来看一个案例。老李我还参与过一个新闻资讯类 App 的开发,这个 App 有一个热门新闻排行榜的功能。为了提高访问速度,我们对热门新闻数据做了缓存。但是,在高并发的情况下,缓存的命中率反而下降了,这是为什么呢?

原来,当大量用户同时访问热门新闻排行榜时,如果缓存中没有对应的数据,就会触发缓存的加载操作。多个线程同时去加载缓存,会导致对数据库的重复查询,增加了数据库的压力,反而降低了缓存的命中率。

怎么解决这个问题呢?我们可以用 Semaphore 来控制同时加载缓存的线程数。具体来说,我们可以创建一个 Semaphore,初始许可数量为 1,表示只允许一个线程加载缓存。当线程需要访问缓存时,先尝试获取许可。如果获取到了许可,就说明当前没有其他线程在加载缓存,那么这个线程就可以去加载缓存,并在加载完成后释放许可。如果获取不到许可,就说明当前已经有其他线程在加载缓存了,那么这个线程就直接从缓存中获取数据,或者等待一段时间后重试。

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;

public class CacheManager {

    private final Map<String, Object> cache = new ConcurrentHashMap<>();
    private final Semaphore semaphore = new Semaphore(1, true); // 只允许一个线程加载缓存

    public Object get(String key) throws InterruptedException {
        Object value = cache.get(key);
        if (value == null) {
            if (semaphore.tryAcquire()) { // 尝试获取许可
                try {
                    // 模拟从数据库加载数据
                    System.out.println(Thread.currentThread().getName() + " 正在加载缓存...");
                    Thread.sleep(1000);
                    value = "Data from DB"; // 假设这是从数据库加载的数据
                    cache.put(key, value);
                    System.out.println(Thread.currentThread().getName() + " 加载缓存完毕.");
                } finally {
                    semaphore.release(); // 释放许可
                }
            } else {
              //其他线程正在加载,循环等待,直到有数据
              while(value == null){
                Thread.sleep(100); //稍作等待
                 value = cache.get(key);
              }
            }
        }
        return value;
    }

      public static void main(String[] args) {
        CacheManager cacheManager = new CacheManager();
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(() -> {
                try {
                  Object data = cacheManager.get("hotNews");
                    System.out.println(Thread.currentThread().getName() + " 获取到数据:" + data);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            thread.setName("Thread-" + i);
            thread.start();
        }
    }
}

这段代码中,我们使用 tryAcquire() 方法来尝试获取许可,而不是 acquire() 方法。tryAcquire() 方法会立即返回一个布尔值,表示是否获取到了许可。如果获取到了许可,就返回 true;如果获取不到许可,就返回 false,不会阻塞线程。

通过这种方式,我们就避免了多个线程同时加载缓存的问题,提高了缓存的命中率,降低了数据库的压力,提升了系统的性能。

真实案例:Semaphore 优化网络连接

除了数据库连接和缓存,Semaphore 还可以用来优化网络连接。比如,在爬虫程序中,我们需要控制同时发起的网络请求数量,避免对目标网站造成过大的压力,甚至被封 IP。我们可以用 Semaphore 来限制同时进行的网络连接数。

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.Semaphore;

public class WebCrawler {

    private static final int MAX_CONNECTIONS = 10;
    private static final Semaphore semaphore = new Semaphore(MAX_CONNECTIONS, true);
    private static final HttpClient httpClient = HttpClient.newHttpClient();

    public static void main(String[] args) {
        String[] urls = {"https://www.example.com", "https://www.google.com", "https://www.baidu.com" /*, ... 其他URL */};

        for (String url : urls) {
            Thread thread = new Thread(() -> {
                try {
                    semaphore.acquire();
                    System.out.println(Thread.currentThread().getName() + " 开始爬取: " + url);

                    HttpRequest request = HttpRequest.newBuilder()
                            .uri(URI.create(url))
                            .build();

                    httpClient.send(request, HttpResponse.BodyHandlers.ofString());

                    System.out.println(Thread.currentThread().getName() + " 爬取完成: " + url);
                } catch (IOException | InterruptedException e) {
                    System.err.println("爬取" + url +"失败:" + e.getMessage());
                } finally {
                    semaphore.release();
                }
            });
             thread.setName("Crawler-Thread-" + url.hashCode());
            thread.start();
        }
    }
}

在这个例子中,我们创建了一个 Semaphore,初始许可数量为 10,表示最多允许 10 个线程同时进行网络请求。每个线程在发起网络请求前,先获取许可;请求完成后,释放许可。这样,就能保证同时进行的网络连接数不会超过 10 个,避免了对目标网站造成过大的压力。

Semaphore 使用注意事项

Semaphore 虽好,但使用的时候也要注意一些细节,避免踩坑:

  1. 正确设置许可数量:Semaphore 的许可数量要根据实际情况来设置。如果许可数量设置得太小,可能会导致线程频繁等待,降低系统的吞吐量;如果许可数量设置得太大,又可能起不到限流的作用。一般来说,许可数量可以设置为资源的数量,比如数据库连接池的最大连接数、线程池的核心线程数等。

  2. 正确地获取和释放许可:一定要确保在finally中释放,避免死锁。

  3. 注意 Semaphore 的公平性:Semaphore 有公平和非公平两种模式。公平模式下,Semaphore 会按照线程请求的顺序来分配许可,可以避免线程饥饿,但会降低一定的性能。非公平模式下,Semaphore 不保证分配许可的顺序,性能更高,但可能会导致某些线程一直获取不到许可。一般情况下,建议使用公平模式。

  4. Semaphore不仅可以控制并发数,还可以实现更复杂的同步逻辑,比如生产者-消费者模型。通过精细地控制acquire和release的数量,可以实现很多同步需求。

总结

Semaphore 是 Java 并发编程中一个非常实用的工具,它可以帮助我们控制并发线程数,优化资源访问,提升系统的性能和稳定性。在实际项目中,我们可以用 Semaphore 来优化数据库连接、缓存访问、网络连接等等。老李我希望通过这篇文章,能让你对 Semaphore 有更深入的理解,在实际工作中更好地应用它。

记住,技术没有银弹,Semaphore 也不是万能的。在实际应用中,我们需要根据具体场景来选择合适的并发控制策略,才能达到最好的效果。希望你能多多实践,积累经验,成为一个真正的并发编程高手!

好了,今天就聊到这里。如果你还有其他关于 Semaphore 的问题,或者有其他并发编程方面的困惑,欢迎在评论区留言,老李我一定知无不言,言无不尽!

点评评价

captcha
健康