Java锁机制深度解析与实战应用

引言

在多线程编程中,确保数据的并发访问安全是至关重要的。Java作为支持多线程编程的主流语言,提供了多种内置和高级锁机制来解决共享资源的竞争问题,从而保证线程间的同步与协作。本文将全面探讨Java中的锁机制,从基本概念、类型到具体实现方法,并结合实际应用场景进行说明。

一、synchronized关键字

 Java中的synchronized关键字是一种内置锁机制,用于保证多线程环境下的线程安全。它提供了简单易用的互斥访问控制,确保同一时刻只有一个线程可以执行特定代码块。


下面是一个使用synchronized关键字的示例代码:

public class SynchronizedExample {
    private int counter;

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

    public synchronized int getCount() {
        return counter;
    }
}

在上面的示例中,increment() 和 getCount() 方法都使用了 synchronized 关键字。这意味着当一个线程正在执行其中一个方法时,其他线程想要执行另一个方法将被阻塞,直到当前线程执行完毕并释放锁。


synchronized 关键字可以用于以下场景:

  • 修饰实例方法:如示例中的 increment() 和 getCount() 方法。当一个线程访问某个对象的同步方法时,其他线程对该对象的其他同步方法访问将被阻塞。
  • 修饰静态方法:可以使用 synchronized 关键字修饰静态方法,以实现对整个类的同步访问。
public class SynchronizedStaticExample {
    private static int counter;

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

    public static synchronized int getCount() {
        return counter;
    }
}

  • 修饰代码块:可以使用 synchronized 关键字修饰代码块,以实现对特定资源的同步访问。
public class SynchronizedBlockExample {
    private int counter;
    private Object lock = new Object();

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

    public int getCount() {
        synchronized(lock) {
            return counter;
        }
    }
}

在上面的示例中,increment() 和 getCount() 方法内部的同步代码块使用了同一个锁对象 lock。这意味着当一个线程访问其中一个同步代码块时,其他线程想要访问另一个同步代码块将被阻塞,直到当前线程执行完毕并释放锁。


二、内置锁(Intrinsic Locks or Monitor Locks)

 Java内置锁(Intrinsic Locks 或 Monitor Locks)是基于JVM实现的一种同步机制,每个Java对象都可以关联一个内置锁。当线程试图访问被 synchronized 关键字修饰的方法或代码块时,会尝试获取该对象的内置锁,如果成功,则可以执行相应的临界区代码;如果失败(即锁已被其他线程持有),则当前线程将进入阻塞状态,等待锁释放。


以下是一个使用Java内置锁的示例代码:

public class IntrinsicLockExample {
    private int counter = 0;

    // 同步实例方法,隐式使用 this 对象作为锁
    public synchronized void increment() {
        counter++;
    }

    // 同步代码块,显式指定锁对象
    public void incrementWithBlock(Object lock) {
        synchronized (lock) {
            counter++;
        }
    }

    // 获取当前计数
    public synchronized int getCount() {
        return counter;
    }

    // 示例类中的静态变量和对应的同步方法
    private static int staticCounter = 0;
    
    public static synchronized void incrementStatic() {
        staticCounter++;
    }
}

// 使用示例
public class Main {
    public static void main(String[] args) {
        IntrinsicLockExample example = new IntrinsicLockExample();
        
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();
            }
        });

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

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

        System.out.println("Final count: " + example.getCount());  // 输出结果应为2000

        // 静态成员的同步示例
        for (int i = 0; i < 500; i++) {
            IntrinsicLockExample.incrementStatic();
        }

        System.out.println("Final static count: " + IntrinsicLockExample.staticCounter);  // 输出结果应为500
    }
}

详细解释:

  • 在 increment() 方法中,使用了 synchronized 关键字修饰,这意味着每次只有一个线程可以执行这个方法,确保了对 counter 变量的并发访问安全。
  • incrementWithBlock() 方法展示了如何在代码块级别使用内置锁,通过传入一个 Object 类型的锁参数,多个方法可以共享同一把锁,达到同步的目的。
  • getCount() 方法同样使用了 synchronized,保证了读取 counter 的线程安全。
  • incrementStatic() 是一个同步静态方法,它使用的是类级别的内置锁,因此在同一时刻只能有一个线程修改 staticCounter 变量。

三、显示锁(Lock)

 Java显示锁(显示锁通常指的是java.util.concurrent.locks.Lock接口及其实现类)提供了比内置锁(synchronized关键字)更强大和灵活的线程同步机制。显示锁允许程序员更加精确地控制线程的加锁和解锁行为,支持中断请求,以及非阻塞式的尝试获取锁等特性。


以下是使用java.util.concurrent.locks.ReentrantLock作为显示锁的一个示例代码:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class DisplayedLockExample {
    private final Lock lock = new ReentrantLock();

    private int counter;

    public void increment() {
        lock.lock(); // 加锁
        try {
            counter++;
        } finally {
            lock.unlock(); // 无论何时都要确保解锁
        }
    }

    public int getCount() {
        lock.lock(); // 同样需要加锁保护
        try {
            return counter;
        } finally {
            lock.unlock();
        }
    }

    // 显示锁还提供了更多的控制方式,比如尝试获取锁,支持中断等
    public void tryIncrementWithTimeout(int timeout, TimeUnit unit) throws InterruptedException {
        if (lock.tryLock(timeout, unit)) {
            try {
                counter++;
            } finally {
                lock.unlock();
            }
        } else {
            System.out.println("未能在规定时间内获取到锁");
        }
    }
}

// 使用示例
public class Main {
    public static void main(String[] args) {
        DisplayedLockExample example = new DisplayedLockExample();

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();
            }
        });

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

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

        System.out.println("Final count: " + example.getCount());  // 输出结果应为2000
    }
}

详细解释:

  • DisplayedLockExample 类中定义了一个 ReentrantLock 对象作为显示锁。
  • increment() 和 getCount() 方法在修改或读取 counter 变量时,均首先获取锁,然后在 finally 语句块中确保无论如何都能释放锁,这是一种最佳实践,确保即使在异常情况下也能正确释放锁。
  • tryIncrementWithTimeout() 方法演示了显示锁的超时获取功能,它尝试在指定时间内获取锁,若超过设定时间仍无法获取,则不再等待并继续执行后续逻辑。


显示锁相比内置锁的优势:

  1. 可以尝试非阻塞地获取锁,比如 tryLock() 和 tryLock(long timeout, TimeUnit unit) 方法。
  2. 支持中断,线程在等待锁时可以响应中断请求,这对于那些需要取消长时间等待的任务非常有用。
  3. 可以实现公平锁策略,即按照线程请求锁的顺序来分配锁,避免“饥饿”现象。
  4. 提供了锁的监听和唤醒机制,可通过 Condition 对象实现更复杂的同步结构

四、读写锁(Read-Write Locks)

 Java的读写锁(Read-Write Locks)是一种特殊的锁机制,它允许多个读取者同时访问共享资源,但在写入者访问时会排斥所有读取者和其他写入者。这使得在读多写少的情况下,系统的并发性能得到显著提升。Java中实现读写锁的主要类是java.util.concurrent.locks.ReadWriteLock,以及它的标准实现java.util.concurrent.locks.ReentrantReadWriteLock。


以下是一个使用ReentrantReadWriteLock的示例代码及其详细解释:

import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    private final Lock readLock = lock.readLock();
    private final Lock writeLock = lock.writeLock();
    private int sharedResource;

    public void read() {
        readLock.lock(); // 获取读锁
        try {
            // 多个线程可以同时在这里读取数据
            System.out.println("Reading the resource: " + sharedResource);
        } finally {
            readLock.unlock(); // 释放读锁
        }
    }

    public void update() {
        writeLock.lock(); // 获取写锁
        try {
            // 当有线程在执行这里的写操作时,其他所有读写线程都会被阻塞
            sharedResource++;
            System.out.println("Updated the resource to: " + sharedResource);
        } finally {
            writeLock.unlock(); // 释放写锁
        }
    }

    // 使用示例
    public static void main(String[] args) {
        ReadWriteLockExample example = new ReadWriteLockExample();
        ExecutorService executor = Executors.newFixedThreadPool(10);

        // 创建大量读取任务
        for (int i = 0; i < 20; i++) {
            executor.submit(() -> example.read());
        }

        // 创建少量写入任务
        for (int i = 0; i < 5; i++) {
            executor.submit(() -> example.update());
        }

        // 关闭线程池
        executor.shutdown();
    }
}

详细解释:

  • ReadWriteLockExample 类中定义了一个 ReentrantReadWriteLock 对象,并从中提取出读锁 readLock 和写锁 writeLock。
  • read() 方法获取读锁后读取共享资源 sharedResource,此时如果有多个线程同时调用 read() 方法,它们可以同时执行,因为读锁是共享的。
  • update() 方法获取写锁后更新 sharedResource,在执行写操作时,所有其他尝试获取读锁或写锁的线程都会被阻塞,直到写操作完成并释放写锁为止。
  • 在主方法中,我们创建了一个线程池,提交了大量的读任务和少量的写任务,模拟了读多写少的场景,这时读写锁可以有效提高系统并发性能。

五、条件变量(Condition Objects)

 在Java中,条件变量是通过java.util.concurrent.locks.Condition接口实现的,它与锁(如ReentrantLock)一起使用,允许线程等待满足特定条件时被唤醒。下面是一个使用Condition对象的示例代码及详细解释:

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ConditionVariableExample {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();

    // 共享资源,模拟队列为空的情况
    private boolean isEmpty = true;

    public void produce() {
        lock.lock();
        try {
            // 当队列为空时,生产者线程等待condition被signal
            while (!isEmpty) {
                condition.await();  // 线程在此处释放锁并进入等待状态
            }

            // 生产商品逻辑...
            System.out.println("Produced an item, queue is no longer empty.");
            isEmpty = false;  // 更新条件

            // 唤醒所有等待此condition的消费者线程
            condition.signalAll();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock();  // 不论如何都要确保解锁
        }
    }

    public void consume() {
        lock.lock();
        try {
            // 当队列非空时,消费者线程等待condition被signal
            while (isEmpty) {
                condition.await();  // 线程在此处释放锁并进入等待状态
            }

            // 消费商品逻辑...
            System.out.println("Consumed an item, queue is now empty.");
            isEmpty = true;  // 更新条件

            // 唤醒所有等待此condition的生产者线程
            condition.signalAll();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock();  // 不论如何都要确保解锁
        }
    }

    // 使用示例
    public static void main(String[] args) throws InterruptedException {
        ConditionVariableExample example = new ConditionVariableExample();

        Thread producer = new Thread(example::produce);
        Thread consumer = new Thread(example::consume);

        producer.start();
        consumer.start();

        // 确保生产者先启动并改变条件
        Thread.sleep(100);
        example.produce();  // 手动调用一次生产方法,以初始化条件变化

        producer.join();
        consumer.join();
    }
}

详细解释:

  • ConditionVariableExample 类中定义了一个 ReentrantLock 对象和从该锁对象创建的一个 Condition 对象。
  • 在 produce() 方法中,当共享资源(这里用 isEmpty 标记队列是否为空)为真(即队列为空)时,生产者线程调用 condition.await() 方法进入等待状态,并释放锁。这样,其他线程可以修改 isEmpty 的值。
  • 当 consume() 方法中的消费者线程检测到 isEmpty 为假(即队列非空),则消费者线程调用 condition.await() 进入等待状态。
  • 当条件发生变化时,比如生产者生产了物品使得队列不再为空,则会调用 condition.signalAll() 来唤醒所有等待该条件的线程。
  • 注意,每个方法都在 try-finally 结构中管理锁的加锁和解锁操作,确保即使在异常情况下也能正确释放锁。

六、乐观锁(Optimistic Locking)

 乐观锁在Java中的实现通常依赖于原子变量类(如java.util.concurrent.atomic包下的类)或数据库事务中的版本号机制。乐观锁的假设是大多数情况下数据不会发生冲突,因此在修改数据前并不加锁,而是在更新时检查在此期间是否有其他线程修改过该数据。如果发现数据未被修改,则执行更新操作;否则则需要重新读取、验证并尝试更新。


以下是一个使用AtomicInteger作为乐观锁机制实现的简单示例:

import java.util.concurrent.atomic.AtomicInteger;

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

    public void increment() {
        // 读取当前值
        int currentValue;
        do {
            // 获取一个可能过时的值
            currentValue = counter.get();
            
            // 检查在此期间是否已经被其他线程修改过
        } while (!counter.compareAndSet(currentValue, currentValue + 1));  // 如果当前值未变,则更新为原值+1

        System.out.println("Counter incremented to: " + counter.get());
    }

    public static void main(String[] args) {
        OptimisticLockingExample example = new OptimisticLockingExample();

        Thread thread1 = new Thread(example::increment);
        Thread thread2 = new Thread(example::increment);

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

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

详细解释:

  • 在这个例子中,我们使用了AtomicInteger来模拟一个计数器,它具有原子性的get()和compareAndSet()方法。
  • compareAndSet()方法(也称为CAS操作)尝试将当前值与预期值进行比较,如果当前值等于预期值,则以原子方式更新为新值。这里的预期值就是我们在循环开始时获取到的currentValue。
  • 当多个线程同时调用increment()方法时,它们都会尝试更新计数器。如果在某一线程尝试更新之前,计数器已被其他线程更新,则其compareAndSet()会失败,并且该线程将继续下一次循环,再次获取最新的currentValue并尝试更新。
  • 这样,在并发环境下,乐观锁通过不断的重试确保了最终只有一个线程成功地进行了原子性更新,从而实现了线程安全的计数操作。

七、悲观锁(Pessimistic Locking)

 悲观锁在Java中通常表现为获取到一个锁后,其他线程尝试访问该资源时会立即被阻塞,直到持有锁的线程释放锁。最直接的例子就是使用synchronized关键字修饰的方法或代码块。但为了更好地说明数据库层面的悲观锁实现,这里提供一个基于JDBC和Hibernate的示例:


JDBC示例(通过SQL的SELECT ... FOR UPDATE实现悲观锁):

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public class PessimisticLockingExampleJDBC {
    private static final String URL = "jdbc:mysql://localhost:3306/mydb";
    private static final String USER = "root";
    private static final String PASSWORD = "password";

    public void updateDataWithPessimisticLock(int id) {
        try (Connection connection = DriverManager.getConnection(URL, USER, PASSWORD)) {
            connection.setAutoCommit(false); // 关闭自动提交,开始事务

            // 使用FOR UPDATE来获取悲观锁
            String sql = "SELECT * FROM my_table WHERE id = ? FOR UPDATE";
            PreparedStatement preparedStatement = connection.prepareStatement(sql);
            preparedStatement.setInt(1, id);
            preparedStatement.execute();

            // 假设我们从查询结果中获取了数据,并准备更新它...
            // 更新操作
            String updateSql = "UPDATE my_table SET column = ? WHERE id = ?";
            preparedStatement = connection.prepareStatement(updateSql);
            preparedStatement.setString(1, "new_value");
            preparedStatement.setInt(2, id);
            preparedStatement.executeUpdate();

            connection.commit();  // 提交事务,释放锁
        } catch (SQLException e) {
            // 处理异常并回滚事务
            try {
                if (connection != null) {
                    connection.rollback();
                }
            } catch (SQLException ex) {
                ex.printStackTrace();
            }
            e.printStackTrace();
        }
    }
}

Hibernate示例(通过Session的锁定方法实现悲观锁):

import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.Configuration;

public class PessimisticLockingExampleHibernate {
    private SessionFactory sessionFactory;

    public PessimisticLockingExampleHibernate() {
        Configuration configuration = new Configuration().configure();
        sessionFactory = configuration.buildSessionFactory();
    }

    public void updateDataWithPessimisticLock(Integer id) {
        try (Session session = sessionFactory.openSession()) {
            // 开始事务
            session.beginTransaction();

            // 加载实体并显式地请求悲观锁
            MyEntity entity = session.get(MyEntity.class, id, LockMode.PESSIMISTIC_WRITE);

            // 假设我们在这里修改了entity的属性值...
            entity.setProperty("new_value");

            // 提交事务,同时释放锁
            session.getTransaction().commit();
        } catch (Exception e) {
            // 如果发生异常,则回滚事务
            if (session != null && session.getTransaction() != null && session.getTransaction().isActive()) {
                session.getTransaction().rollback();
            }
            e.printStackTrace();
        }
    }
}

@Entity
@Table(name = "my_table")
public class MyEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    // 其他属性及getter、setter省略...
}

详细解释:

  • 在JDBC示例中,通过设置事务并执行SELECT ... FOR UPDATE语句,在读取记录的同时获得了对该记录的悲观锁,使得其他事务无法修改这条记录,直至当前事务结束。
  • 在Hibernate示例中,通过session.get()方法加载实体对象时指定了LockMode.PESSIMISTIC_WRITE模式,这样在获取实体时即获得了一把悲观写锁,阻止其他事务对同一实体进行并发修改。

八、自旋锁(Spin Locks)

 自旋锁在Java中主要用于解决线程间短时间的同步问题,尤其适用于等待时间极短并且CPU资源相对充足的场景。在Java中并没有直接提供名为“自旋锁”的API,但是可以通过循环和volatile关键字模拟实现自旋锁的行为。以下是一个简单的自旋锁示例:

public class SpinLock {
    private volatile boolean isLocked = false;

    public void lock() {
        while (true) {
            if (!isLocked) { // 当锁未被占用时
                if (compareAndSet(false, true)) { // 使用CAS操作尝试获取锁
                    break; // 成功获取锁后退出自旋
                }
            }
            // 锁被占用时,继续循环(自旋)
        }
    }

    public void unlock() {
        isLocked = false; // 释放锁
    }

    // 使用AtomicBoolean或Unsafe等工具类提供的原子操作来实现compareAndSet
    private boolean compareAndSet(boolean expect, boolean update) {
        return java.util.concurrent.atomic.AtomicBoolean.compareAndSet(this.isLocked, expect, update);
    }

    // 示例用法
    public static void main(String[] args) {
        final SpinLock spinLock = new SpinLock();

        Thread t1 = new Thread(() -> {
            spinLock.lock();
            try {
                System.out.println("Thread 1 acquired the lock");
                Thread.sleep(1000); // 模拟执行耗时任务
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                spinLock.unlock();
            }
        });

        Thread t2 = new Thread(() -> {
            spinLock.lock();
            try {
                System.out.println("Thread 2 acquired the lock");
            } finally {
                spinLock.unlock();
            }
        });

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

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

详细解释:

  • 在上述代码中,我们创建了一个名为SpinLock的类,其中包含一个volatile布尔变量isLocked,用于表示锁的状态。
  • lock()方法通过不断地检查并试图更新isLocked变量来实现自旋。当isLocked为false时,使用compareAndSet()原子操作将其设置为true,如果成功则说明当前线程获得了锁,并跳出循环。
  • unlock()方法将isLocked设置为false,以释放锁。
  • compareAndSet()方法利用Java的原子性操作(这里假设使用了AtomicBoolean)确保对isLocked变量的修改是原子性的,这可以避免多线程环境下数据竞争的问题。

九、StampedLock

Java中的StampedLock是Java 8引入的一个高性能的并发工具类,它提供了更灵活的读写锁机制,包括悲观读锁、乐观读锁和写锁。每个锁操作都会返回一个戳记(stamp),后续的操作可以通过这个戳记来验证或释放锁。


以下是一个使用StampedLock实现读写锁的示例代码及详细解释:

import java.util.concurrent.locks.StampedLock;

public class StampedLockExample {
    private final StampedLock lock = new StampedLock();

    // 共享资源
    private int sharedResource;

    public void read() {
        long stamp = lock.readLock(); // 获取悲观读锁
        try {
            // 在此块中可以安全地读取sharedResource
            System.out.println("Reading the resource: " + sharedResource);
        } finally {
            lock.unlockRead(stamp); // 释放读锁
        }
    }

    public void optimisticRead() {
        long stamp = lock.tryOptimisticRead(); // 尝试获取乐观读锁
        int localCopy = sharedResource;
        
        if (lock.validate(stamp)) { // 验证在获取戳记后,数据是否被其他线程修改过
            // 如果没有被修改,则可以安全地使用localCopy
            System.out.println("Optimistically reading the resource: " + localCopy);
        } else {
            // 如果有被修改,需要升级到悲观读锁或者重新读取
            long newStamp = lock.readLock();
            try {
                // 现在可以安全地再次读取资源
                localCopy = sharedResource;
                System.out.println("Upgraded to悲观读锁, reading the resource: " + localCopy);
            } finally {
                lock.unlockRead(newStamp); // 释放悲观读锁
            }
        }
    }

    public void write(int newValue) {
        long stamp = lock.writeLock(); // 获取写锁
        try {
            // 在此块中可以安全地更新sharedResource
            sharedResource = newValue;
            System.out.println("Updated the resource to: " + sharedResource);
        } finally {
            lock.unlockWrite(stamp); // 释放写锁
        }
    }

    // 使用示例
    public static void main(String[] args) {
        StampedLockExample example = new StampedLockExample();

        // 创建并启动多个读取者和写入者线程...
    }
}

详细解释:


1.StampedLock提供三种模式的锁:

  • 悲观读锁:通过readLock()方法获取,类似于ReentrantReadWriteLock中的读锁,当有写锁持有时,读锁会阻塞。
  • 乐观读锁:通过tryOptimisticRead()方法尝试获取,该方法立即返回,不会阻塞,但必须在之后调用validate(long stamp)方法确认读取期间是否有写锁发生改变,如果数据被修改则需升级为悲观读锁。
  • 写锁:通过writeLock()方法获取,与ReentrantReadWriteLock类似,独占锁,不允许任何其他读或写锁同时存在。

   

2.示例中read()方法展示了如何使用悲观读锁进行读操作,在读取期间阻止写操作


3.optimisticRead()方法首先尝试乐观读锁,然后检查戳记的有效性。若数据未被更改,则可以直接使用本地缓存的值;否则,为了确保读取到最新数据,需要升级到悲观读锁。


4.write()方法展示了如何使用写锁执行写操作,在写入期间阻止所有其他读写操作。
 

总结

深入理解和熟练掌握Java中的锁机制是构建高效、稳定多线程程序的关键所在。开发者应依据具体的并发场景,权衡锁的开销与安全性,合理选择并应用合适的锁实现。

  • 47
    点赞
  • 42
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小码快撩

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值