• 数据库锁和java锁介绍(表级锁/行级锁/页级锁/共享锁/独占锁/意向锁/记录锁/间隙锁/NK锁/乐观锁/悲观锁/公平锁/非公平锁/互斥锁/读写锁/可重入锁/自旋锁/分段锁/偏向锁/轻量级锁/重量级别


    java的主要两种加锁机制

    synchronized 关键字
    java.util.concurrent.Lock (Lock是一个接口,ReentrantLock是该接口一个很常用的实现)
    这两种机制的底层原理存在一定的差别,synchronized 关键字通过一对字节码指令 monitorenter/monitorexit 实现, 这对指令被 JVM 规范所描述;java.util.concurrent.Lock 通过 Java 代码搭配sun.misc.Unsafe 中的本地调用实现的。

    数据库锁和java锁

    在java开发中会遇到多线程的问题,这时需要用锁来避免多线程问题,实现数据的原子性操作,保证数据一致性和数据稳定性。常见的锁的种类有以下几种:

    1.表级锁/行级锁/页级锁

    2.共享锁/独占锁

    3.意向锁

    4.记录锁

    5.间隙锁

    6.NK锁

    7.乐观锁/悲观锁

    8.公平锁/非公平锁

    9.互斥锁/读写锁

    10.可重入锁

    11.自旋锁

    12.分段锁

    13.偏向锁/轻量级锁/重量级别锁

    14.分布式锁

    锁的分类
    性质 种类 具体种类
    按照粒度划分 表级锁/行级锁/页级锁
    共享锁/独占锁是行级锁

    意向锁是表级锁

    按照锁定范围划分 记录锁/间隙锁/NK锁 记录锁/间隙锁/NK锁都是行锁
    按照锁的制造方 乐观锁/悲观锁
    按照获取锁的顺序 公平锁/非公平锁
    内外层函数是否可以获取同一个锁 可重入锁
    阻塞/轮训 互斥锁/自旋锁
    读写共存状态/读写顺序 读写锁
    是否支持多线程安全并且高效 分段锁
    按照线程数目多少、线程响应时间长短、锁占用时间长短 偏向锁/轻量级锁/重量级锁
    Mysql底层存储引擎不同,支持锁的粒度和原理也不同。Mysql的底层有MyIsam和Innodb。MyIsam支持表锁,Innodb支持行锁和表锁。MySql默认的存储引擎是Innodb。接下来我们详细讲述上述14种锁:

    一、表级锁/行级锁/页级锁

    表级锁:实现简单、加锁放锁时间短,避免了死锁问题,但是由于锁的范围大造成资源的争夺率高,主要Isam引擎使用。所以一般用在非高并发的业务场景中。如果需要优化表级锁,需要加大并发量,可以缩短表级锁查询的时间,myIsam的写优先级高于读优先级,而且读写的优先级别可以灵活设置。

    行级锁:粒度小,资源争夺率低,支持高并发性能,会带来死锁问题,主要为Inndb引擎用。当行级锁不起作用的时候,innodb也启用表级锁,可以用专用的参数配置为只支持表级锁,但是不能支持高并发的业务场景。只有mysql有索引,行锁才起作用,否则行锁不起作用。有以下几点需要特别注意:

    (1)在不通过索引条件查询的时候,InnoDB确实使用的是表锁,而不是行锁。
    (2)由于MySQL的行锁是针对索引加的锁,不是针对记录加的锁,所以虽然是访问不同行的记录,但是如果是使用相同的索引键,是会出现锁冲突的。
    (3)当表有多个索引的时候,不同的事务可以使用不同的索引锁定不同的行,另外,不论是使用主键索引、唯一索引或普通索引,InnoDB都会使用行锁来对数据加锁。
    (4)即便在条件中使用了索引字段,但是否使用索引来检索数据是由MySQL通过判断不同执行计划的代价来决定的,如果MySQL认为全表扫描效率更高,比如对一些很小的表,它就不会使用索引,这种情况下InnoDB将使用表锁,而不是行锁。因此,在分析锁冲突时,别忘了检查SQL的执行计划,以确认是否真正使用了索引。

    页级锁:页级锁介于表级锁和行级锁之间,并发处理能力与锁粒度也介于二者之间,并且也会发生死锁,死锁的概率也介于二者之间,使用页级锁的引擎主要是BerkerlyDB存储引擎。

    参考资料:https://www.cnblogs.com/luyucheng/p/6297752.html

    二、共享锁/独占锁

    共享锁和独占锁(Shared and Exclusive Locks):InnoDB的行级锁分为两种类型,共享锁(S锁)和独占锁(X锁),共享锁(S 锁):允许事务获得锁后去读数据,独占锁(X 锁):允许事务获得锁后去更新或删除数据。一个事务获取的共享锁 S 后,允许其他事务获取 S 锁,此时两个事务都持有共享锁 S,但是不允许其他事务获取 X 锁。如果一个事务获取的独占锁(X),则不允许其他事务获取 S 或者 X 锁,必须等到该事务释放锁后才可以获取到。

    三、意向锁

    意向锁(Intention Locks):意向锁是一种表级锁,用来指示接下来的一个事务将要获取的是什么类型的锁(共享还是独占)。意向锁分为意向共享锁(IS)和意向独占锁(IX),当一个事务在需要获取资源锁定的时候,如果遇到的资源已经被独占锁占用,该事务需要在需要锁定的行的表上面添加一个合适的意向锁,如IS或IX。意向锁在高并发时,计算机资源占用率高,会拖跨数据库。

    四、记录锁

    记录锁封锁索引记录,在索引记录上加锁,以阻止其他事物,如果没有索引,Innodb会创建一个隐藏的聚族索引加锁,所以在查询的时候尽量加索引查询。避免产生锁冲突。

    五、间隙锁

    间隙锁(Gap Locks),当用范围条件查询的时候,在某个范围之内的索引上查询,范围内不会存在所有的数据,这时候会把数据分段,按照范围间隙加锁查询。例如:student id 有100、101、102、105、106

    mysql> select * from student where id > 100 for update;
    这时Innodb锁定的不是101、102、105、106,而是(100,101],(101,102],(102,105],(105,106],实际上是因为如 果不锁住这些行,那么如果另一个事务在此时插入了一条103的记录,那会导致第一次的事务两次查询的结果不一样,出现了幻读。

    六、NK锁

    Next-Key Locks,NK 是一种记录锁和间隙锁的组合锁。是记录锁和间隙锁的组合形式,既锁住行也锁住间隙。并且采用的左开右闭的原则。InnoDB 对于查询都是采用这种锁的。

    七、乐观锁/悲观锁

    乐观锁:基于数据的版本version或者数据的状态或时间戳进行更新,在数据更新的时候,老版本为当前版本,才允许把当前的版本更新为新的版本,一般在数据库中用sql语句加乐观锁,而不是先读再写。是一种用户自己实现的一种锁机制,在数据更新提交时发现冲突则返回错误信息。

    悲观锁:悲观锁就是数据库锁机制。悲观锁主要包括行级锁、表级锁和页级锁。

    八、公平锁/非公平锁

    公平锁就是保障了多线程下各线程获取锁的顺序,先到的线程优先获取锁,在这里有个没有获取锁在队列等候的环节,公平锁是按照等候队列中排队的顺序去获取锁,而非公平锁不是严格按照队列排队的顺序获取锁的,可能当前锁被释放后,当前刚要进入队列的线程获取到了锁,而已排队等候的锁还在排队等候中。非公平锁的效率高于公平锁的效率。非公平锁减少了线程的上下文切换的开销,来则获取到锁,从而效率比公平锁的效率高,保证了大的吞吐量,但是会造成线程“饥饿”。公平锁保证了锁的获取按照FIFO原则,代价是进行大量的线程切换。ReentrantLock默认是非公平锁,ReentrantLock提供了构造函数,能够控制锁是否是公平的。

    final Boolean nonfairTryAcquire(int acquires){
    final Thread current=Thread.currentThread();
    int c=getState();
    if(c==0){
    if(!hasQueuedPredecessors()&&compareAndSetState(0,acquires)){
    setExclusiveOwnerThread(current);
    return true;
    }
    }else if(current==getExclusiveOwnerThread() ){
    int nextc=c+acquires;
    if(nextc<0){
    throw new Error("maxinum lock count exceeded");
    }
    setState(nextc);
    return true;
    }
    return false;
    }

    在获取锁的时候判断hasQueuedPredecessors()方法,即判断同步对列中是否有前驱节点,如果没有则获取锁,如果有则不能获取到锁,保证FIFO,先入队列的先获取到锁。上述代码锁是公平锁,如果把hasQueuedPredecessors()的判断去掉,则是非公平锁。

    参考资料:https://blog.csdn.net/zhilinboke/article/details/83104597

    九、互斥锁/读写锁

    互斥锁:无法获取琐时,进线程立刻放弃剩余的时间片并进入阻塞(或者说挂起)状态,同时保存寄存器和程序计数器的内容(保存现场,上下文切换的前半部分),当可以获取锁时,进线程激活,等待被调度进CPU并恢复现场(上下文切换下半部分)

    上下文切换会带来数十微秒的开销,不要在性能敏感的地方用互斥锁

    互斥锁在Java中的具体实现就是ReentrantLock:

    读写锁:

    1)多个读者可以同时进行读2)写者必须互斥(只允许一个写者写,也不能读者写者同时进行)3)写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)

    读写锁在Java中的具体实现就是ReadWriteLock

    十、可重入锁

    可重入锁,也叫做递归锁,指的是同一线程外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞。重入的实现需要解决以下了两个问题:

    1.线程再次获取锁。锁需要去识别获取锁的线程是否是当前占据锁的线程,如果是,则再次成功获取。

    2.锁的最终释放。线程重复了n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放的时候,计数自减,当前计数等于0时表示锁已经成功释放。

    获取锁的代码如下:

    final Boolean nonfairTryAcquire(int acquires){
    final Thread current=Thread.currentThread();
    int c=getState();
    if(c==0){
    if(compareAndSetState(0,acquires)){
    setExclusiveOwnerThread(current);
    return true;
    }
    }else if(current==getExclusiveOwnerThread() ){
    int nextc=c+acquires;
    if(nextc<0){
    throw new Error("maxinum lock count exceeded");
    }
    setState(nextc);
    return true;
    }
    return false;
    }

    该方法增加了再次获取同步状态的处理逻辑:通过判断当前线程是否为获取锁的线程来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回true,表示获取同步状态成功。而起从获取锁的方式开,上述代码的锁是非公平可重入锁。

    释放锁的代码如下:

    public final boolean tryRelease(int releases){
    int c=getState()-releases;
    if(Thread.currentThread()!=getExclusiveOwnerThread()){
    throw new IllegalMonitorStateException();
    }
    boolean free=false;
    if(c==0){
    free=true;
    setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
    }
    如果该锁获取了n次,那么前n-1次释放都为false,而只有同步状态完全释放了,才能返回true。

    Synchronized和ReenTrantLock是可重入锁。

    十一、自旋锁

    自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式获取锁,这样的好处是减少上下文切换的消耗,缺点是循环会消耗CPU.一般应用于加锁时间很短(1ms左右或更低)的场景,使用自旋锁需要慎重。

    十二、分段锁

    ConcurrectHashMap采用分段锁技术提高并发访问的效率并且保证线程安全。

    (1)HashMap线程不安全,在多线程条件下进行put操作可能导致程序死循环,cpu的使用率达到100%。

    (2) HashTable是线程安全的,但是多线程情况下,效率低。因为HashTable是通过synchronized保证线程安全,当一个线程访问HashTable同步方法的时候,另外一个线程也访问HashTable的同步方法,会进入轮询或者是阻塞状态。所以线程安全但是效率低下。

    (3)ConcurrectHashMap是线程安全的而且是高效的。因为ConcurrectHashMap采用分段锁技术,把数据分成一段一段存储,而且每一段数据都配一把锁,不同的数据段锁不同,当一个线程占用锁访问其中一个数据段的时候,其他数据段也能被其他线程访问。

    ConcurrectHashMap的结构
    ConcurrectHashMap由Segment数组和HashEntry数组结构组成。Segment是可重入锁,在ConcurrectHashMap中扮演锁的角色;HashEntry是键值对,一个ConcurrectHashMap中有一个Segment数组,即有跟多锁,每个segment里有一个HashEntry数组,每个HashEntry是一个链表结构结构的元素。

    ConcurrectHashMap的segment的定位
    ConcurrectHashMap对Segment的定位是通过对数据的散列再散列,数据通过散列再散列实现数据散列均匀,可以把数据分散在不同的segment中,segment的数目多,并发的线程的数目就越多,减少散列冲突,提高容器的存取效率。

    源码再散列的代码如下:


    private int hash(Object k) {
    int h = hashSeed;

    if ((0 != h) && (k instanceof String)) {
    return sun.misc.Hashing.stringHash32((String) k);
    }

    h ^= k.hashCode();

    // Spread bits to regularize both segment and index locations,
    // using variant of single-word Wang/Jenkins hash.
    h += (h << 15) ^ 0xffffcd7d;
    h ^= (h >>> 10);
    h += (h << 3);
    h ^= (h >>> 6);
    h += (h << 2) + (h << 14);
    return h ^ (h >>> 16);
    }

    源码定位Segment的代码如下:

    /**
    * 用再散列的hash值定位到某个锁segment
    */
    @SuppressWarnings("unchecked")
    private Segment<K,V> segmentForHash(int h) {
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    return (Segment<K,V>) UNSAFE.getObjectVolatile(segments, u);
    }

    ConcurrectHashMap的操作
    (1)get操作

    public V get(Object key) {
    Segment<K,V> s; // manually integrate access methods to reduce overhead
    HashEntry<K,V>[] tab;
    int h = hash(key);
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
    (tab = s.table) != null) {
    for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
    (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
    e != null; e = e.next) {
    K k;
    if ((k = e.key) == key || (e.hash == h && key.equals(k)))
    return e.value;
    }
    }
    return null;
    }

    注意:1.HashTable容器的get操作是需要加锁的,而ConcurrectHashMap的读操作不需要加锁

    为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对volatile变量进行操作,JVM就会向处理器发送一条指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,当某个CPU在写数据时,如果发现操作的变量是共享变量volatile修饰,则会通知其他CPU告知该变量的缓存行是无效的,因此其他CPU在读取该变量时,发现其无效会重新从主存中加载数据。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。避免了脏读,所以ConcurrectHashMap读不需要加锁。所以读到的数据也不会过期,java内存模型的happen-before原则,对volatile字段的写入操作先于读操作,即使两个线程同时修改和获取volatile变量,get操作获取的也是最新的值,这是用volatile替换锁的经典应用场景。

    (2)put操作

    源码:

    public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
    throw new NullPointerException();
    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
    (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
    s = ensureSegment(j);
    return s.put(key, hash, value, false);
    }
    ConcurrectHashMap写操作需要加锁,先定位Segment,再判断Segment的HashEntry是否需要扩容,如果需要扩容,创建一 个容量是原来容量量倍的数组,将原来数组里的元素进行再散列插入到新的数组里,ConcurrectHashMap不是对整个容器进行 扩容,而只是对某个segment进行扩容。实现高效扩容。

    (3)size操作

    ConcurrectHashMap在进行count操作的时候,会对每个segment进行count,然后求和,再多线程的情况下虽然每个volatile修饰的count获取的是最新值,但是在累加的时候可能count发生了变化,这样统计的size不准确,为此 ConcurrectHashMap有个modcount,在每个segment进行put、remove、clean时候,modcount都会加1,这时统计size前后比较modcout是否发生变化,超过两次发生变化,则采用加锁的方式统计size,如果两次以内modcout没有发生变化,则以不加锁统计的为准。

    而在迭代时,ConcurrentHashMap使用了不同于传统集合的快速失败迭代器(见之前的文章《JAVA API备忘---集合》)的另一种迭代方式,我们称为弱一致迭代器。在这种迭代方式中,当iterator被创建后集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是在改变时new新的数据从而不影响原有的数据,iterator完成后再将头指针替换为新的数据,这样iterator线程可以使用原来老的数据,而写线程也可以并发的完成改变,更重要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键。

    十三、偏向锁/轻量级别锁/重量级别锁

    参考资料:https://blog.csdn.net/lengxiao1993/article/details/81568130#Bulk_Rebias_192

    在存在多线程竞争的时候偏向锁会升级为轻量级别锁,轻量级别锁会升级为重量级别锁。

    java类占用字节:

    三种锁的优缺点比较

    锁 优点 缺点 适用场景
    偏向锁 加锁和解锁不需要资源消耗,相对与非同步的时间消耗仅多几十纳秒 在锁竞争激烈的条件下,会增加解除偏向锁的消耗 适用于有一个线程的情况
    轻量级锁 线程获取不到锁不会阻塞,执行效率高 竞争激烈获取不到锁的线程会进入轮训状态,cpu的消耗高 适用于锁的占用时间短,并发效率高的情况,响应时间短的情况
    重量级锁 获取不到锁的线程不会进入轮训状态,而是进入阻塞状态 获取不到锁的线程会进入阻塞状态,增加上下文切换的消耗 适用于锁的占用时间长,响应时间长,并发效率高的场景
    十四、分布式锁

    1.基于redis实现分布式锁
    不同的进程必须以相互排斥的方式使用共享资源进行操作。同一个系统有多个线程共享同一个资源或多个不同的系统共享同一个资源,都要保持线程间的互斥,从而保证数据的一致性。

    redis分布式事务锁的几个性能考核指标

    1.安全性:任何时候,只有一个客户端持有锁。只有持有锁的客户端可以删除锁,其他客户端不能删除锁。

    2.无死锁:即使客户端宕机或重启或其他原因导致获取到锁缺无法释放锁,这时候其他客户端不能一直等导致死锁,其他客户端可以重新获取到锁并且释放锁。

    3.容错性:当部分redis节点宕机,客户端仍然可以获取锁和释放锁。

    情况1:在只有一个节点(单例)、非分布式的、单点的、保证永不宕机的时候分布式锁的实现

    命令:SET KEY VALUE [EX seconds] [PX milliseconds] [NX|XX]
    参数解释:
    • EX seconds - 设置指定的到期时间,单位为秒。
    • PX milliseconds - 设置指定到期时间,单位为毫秒。
    • NX - 只在键不存在时,才对键进行设置操作。
    • XX - 只在键已经存在时, 才对键进行设置操作。

    执行指令

    SET resource_name my_random_value NX PX 30000
    获取到锁,只有resource_name(key)不存在的情况下(NX)键值进行设置,超时时间为30000ms(PX限制),这个key的值设为“my_random_value”。这个值必须在所有获取锁请求的客户端里保持唯一。 基本上这个随机值就是用来保证能安全地释放锁,我们可以用下面这个Lua脚本来告诉Redis:删除这个key当且仅当这个key存在而且值是我期望的那个值。

    if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
    else
    return 0
    end
    这个很重要,因为这可以避免误删其他客户端得到的锁,KEYS[1]的值为my_key,ARGV[1]的值为my_random_uuid,举个例子,一个客户端拿到了锁,被某个操作阻塞了很长时间,过了超时时间后自动释放了这个锁,然后这个客户端之后又尝试删除这个其实已经被其他客户端拿到的锁。所以单纯的用DEL指令有可能造成一个客户端删除了其他客户端的锁,用上面这个脚本可以保证每个客户端都用一个随机字符串’签名’了,这样每个锁就只能被获得锁的客户端删除了。

    这个随机字符串my_random_value应该用什么生成呢?我假设这是从/dev/urandom生成的20字节大小的字符串,但是其实你可以有效率更高的方案来保证这个字符串足够唯一。比如你可以用RC4加密算法来从/dev/urandom生成一个伪随机流。还有更简单的方案,比如用毫秒的unix时间戳加上客户端id,这个也许不够安全,但是也许在大多数环境下已经够用了。key值的超时时间,也叫做”锁有效时间”。这个是锁的自动释放时间,也是一个客户端在其他客户端能抢占锁之前可以执行任务的时间,这个时间从获取锁的时间点开始计算。 所以现在我们有很好的获取和释放锁的方式,在一个非分布式的、单点的、保证永不宕机的环境下这个方式没有任何问题,接下来我们看看无法保证这些条件的分布式环境下我们该怎么做。

    情况2:在多个节点(多例)、分布式的、非单点的、有宕机节点的时候分布式锁的实现

    如果redis的主节点宕机怎么办?可以增加一个从节点来使用,但是这时有互斥的安全性问题,因为redis的复制是异步的。 例如:

    1.主节点A获取到锁x

    2.主节点A在把key值写入给从节点B前发生宕机

    3.从节点B变成了主节点B。

    4. 主节点B又获取到了锁x(因为主节点B认为key值不存在,如果redis的复制是同步的,那么主节点B会认为有key值,则会获取新的非x锁),这样有两个主节点A和B持有了相同的锁,违反互斥安全性。

    面对此种情况,redis的作者antirez提出了redLock算法。具体方法流程见如下链接:

    http://ifeve.com/redis-lock/

    https://redis.io/topics/distlock#distributed-locks-with-redis

    2.基于zookeeper实现分布式锁

    利用 zookeeper 的有序节点的特性,基本思路:

    1、创建节点locks

    2、所有客户端调用createNode方法在locks节点下创建临时顺序节点,释放锁的时候删除该临时节点,例如如果是读请求,就创建IP1-R-001节点;如果是写请求,就创建IP2-W-002节点。

    3、客户端调用getChildren(“locks”)来获取locks下面的所有子节点,注意此时不用设置任何Watcher。

    4、客户端获取到所有的子节点path之后,如果发现自己创建的子节点序号001最小,那么就认为该客户端获取到了锁。

    5、如果发现自己创建的节点003并非locks所有子节点中最小的,说明自己还没有获取到锁,此时客户端需要找到比自己小的那个节点001,然后对其调用exist()方法,同时对其注册事件监听器Watcher。

    比自己小的节点的规则:
    读请求:向比自己序号小的最后一个写请求节点注册watcher监听
    写请求:相比自己序号小的最后一个节点注册watcher监听

    6、之后,让这个被关注的节点删除,则客户端的Watcher会收到相应通知,此时再次判断自己创建的节点是否是locks子节点中序号最小的,如果是则获取到了锁,如果不是则重复以上步骤继续获取到比自己小的一个节点并注册监听。

    优点:具备高可用、可重入、阻塞锁特性。客户端宕机等异常情况下,当前客户端持有的锁可实时释放,解决死锁问题。跟据Zookeeper官方API自定义实现,有问题方便排查。
    缺点:因为需要频繁的创建和删除节点,高并发下,性能上不如redis方式。

    参考文献:https://www.cnblogs.com/wuzhenzhao/p/9996522.html
    ————————————————
    版权声明:本文为CSDN博主「阿尔法小师妹」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/weixin_41926301/article/details/90756710

  • 相关阅读:
    C# 控件缩写大全+命名规范+示例
    Database Link详解
    DataTable.Compute 方法
    Dotfuscator Professional Edition 4.9.7500.9484 混淆工具破解版+使用教程
    C#中的委托和事件(续)
    Jquery 获取元素内容
    ASP.NET 常用类
    JavaScript动态生成访问方法
    LINQ查询操作符
    HTML乱码问题和header结构
  • 原文地址:https://www.cnblogs.com/xiurui12345/p/16172085.html
Copyright © 2020-2023  润新知