2024年3月

写在开头


《耗时2天,写完HashMap》
这篇文章中,我们提到关于HashMap线程不安全的问题,主要存在如下3点风险:

风险1:
put的时候导致元素丢失;如两个线程同时put,且key值相同的情况下,后一个线程put操作覆盖了前一个线程的操作,导致前一个线程的元素丢失。
风险2:
put 和 get 并发时会导致 get 到 null;若一个线程的put操作触发了数组的扩容,这时另外一个线程去get,因为扩容的操作很耗时,这时有可能会卡死或者get到null。
风险3:
多线程下扩容会死循环;多线程下触发扩容时,因为前一个线程已经破坏了原有链表结构,后一个线程再去读取节点,进行链接的时候,很可能发生顺序错乱,从而形成一个环形链表,进而导致死循环。

Hashtable解决线程安全靠谱吗?

那我们怎么办呢?很多小伙伴可能第一时间想到了HashTable,因为它和HashMap拥有者相似的功能,底层也是基于哈希表实现,数组+链表构建,数组容量到达阈值后,同样会自动扩容,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。并且,
Hashtable内部的方法几乎都是synchronized关键字修饰,保证了线程的安全

哇!这样一看,Hashtable简直是解决HashMap线程不安全的天选之子啊!但事实上,因为性能的问题,Hashtable已经在被废弃的边缘了,非常不建议在代码中使用它,原因如下接着往下看。
我们先写一个小小的测试类,来感受一下Hashtable的使用。

【代码示例1】

public class Test {
    public static void main(String[] args) {
        HashMap<Integer, String> map = new HashMap<>();
        map.put(1, "I");
        map.put(2, "love");
        map.put(3, "Java");

        Hashtable<Integer, String> hashtable = new Hashtable<>();
        hashtable.put(1, "JavaBuild");
        for (Map.Entry<Integer, String> entry : hashtable.entrySet()) {
            System.out.println(entry.getKey()+":"+entry.getValue());
        }
    }
}

输出:

1:JavaBuild

然后,我们跟入到put中的原来,去看看它的底层实现

【源码解析1】

 public synchronized V put(K key, V value) {
        // Make sure the value is not null
        if (value == null) {
            throw new NullPointerException();
        }

        // Makes sure the key is not already in the hashtable.
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        @SuppressWarnings("unchecked")
        Entry<K,V> entry = (Entry<K,V>)tab[index];
        for(; entry != null ; entry = entry.next) {
            if ((entry.hash == hash) && entry.key.equals(key)) {
                V old = entry.value;
                entry.value = value;
                return old;
            }
        }

        addEntry(hash, key, value, index);
        return null;
    }

通过这段源码我们能够发现
1、Hashtable哈希值的计算,并没有像HashMap那样重新计算,而是直接取key的hashCode()方法,这样一来它的扰动次数明显降低,hash的重合度更高;
2,index的位置计算中,Hashtable采用了%取余运算,而HashMap采用的是&运算,我们知道位运算直接对内存数据进行操作,不需要转成十进制,处理速度非常快,相比之下Hashtable的效率低下。
3,底层大部分的方法都是synchronized修饰,我们知道用synchronized 来保证线程安全的效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

以上3点足以让我们头也不回的舍弃Hashtable,那么问题来了,除了这个集合类外,我们还有什么选项呢?这时,
ConcurrentHashMap
高高的举起了它的小手!

ConcurrentHashMap

文章写到这些,终于引出了我们今天的主角,ConcurrentHashMap!作为一个效率又高,又能保证线程安全的集合类,它的使用频率非常之高,话不多说,我们先来画一个底层逻辑实现图感受一下它的魅力!
JDK1.8下的ConcurrentHashMap底层实现

哦,对了,虽然我们现在主流的Java版本都是1.8+了,但很多公司在面试的时候,提及ConcurrentHashMap时,有时候还是会问到1.7的底层实现,因此,学有余力的小伙伴,私下里把JDK1.7的底层源码也拿过来读读哈(build哥本地没有安装JDK1.7,就不贴源码解析了)。

JDK1.8中ConcurrentHashMap抛弃了原有的 Segment 分段锁,采用了 CAS + synchronized 来保证并发安全性,底层结构采用Node数组+链表/红黑树,当链表长度达到一定长度后,会转为红黑树,这和HashMap一样。

【PUT源码解析】

public V put(K key, V value) {
    return putVal(key, value, false);
}

/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
    // key 和 value 不能为空
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        // f = 目标位置元素
        Node<K,V> f; int n, i, fh;// fh 后面存放目标位置的元素 hash 值
        if (tab == null || (n = tab.length) == 0)
            // 数组桶为空,初始化数组桶(自旋+CAS)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 桶内为空,CAS 放入,不加锁,成功了就直接 break 跳出
            if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))
                break;  // no lock when adding to empty bin
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            // 使用 synchronized 加锁加入节点
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    // 说明是链表
                    if (fh >= 0) {
                        binCount = 1;
                        // 循环加入新的或者覆盖节点
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
                    else if (f instanceof TreeBin) {
                        // 红黑树
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

源码有点长,大致做了如下几点:

  • 先根据 key 计算出 hashcode;
  • 判断数组桶是否为空,若为空则通过tab = initTable(),初始化数组桶(自旋+CAS);
  • 计算出key的数组桶位置后,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功;
  • 如果当前位置的 “hashcode == MOVED == -1”,则需要进行扩容;
  • 如果都不满足,则利用 synchronized 锁写入数据;
  • 如果数量大于 TREEIFY_THRESHOLD 则要执行树化方法,在 treeifyBin 中会首先判断当前数组长度 ≥64 时才会将链表转换为红黑树。

【源码扩展1】
上面put的时候,若Node数组桶为空时,需要进行初始化,那么我们跟入initTable()中去看一看它的源码实现。

/**
 * Initializes table, using the size recorded in sizeCtl.
 */
private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        // 如果 sizeCtl < 0 ,说明另外的线程执行CAS 成功,正在进行初始化。
        if ((sc = sizeCtl) < 0)
            // 让出 CPU 使用权
            Thread.yield(); // lost initialization race; just spin
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

从源码中我们可以看到,它的初始化是通过CAS和自旋完成的,注意其中的
sizeCtl
私有成员变量,当它的值小于0(准确来说等于-1)时,说明另外的线程执行CAS 成功,正在进行初始化。通过Thread.yield()做线程让步动作,让出CPU的使用权,自旋等待,随着获得资源,进入CAS。

知识点补充

CAS
(compare and swap) 译为:比较与交换

// 如果在这个位置(address) 的值等于 这个值(expectedValue),那么交换(newValue)。
boolean CAS(address,expectedValue,newValue) {
	if(address 的 value == expectedValue) {
		address 的 value = newValue;
		return true;
	}
}

自旋:
所谓的自旋,旨在线程抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度。但经过测算,大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。因此,当某个线程抢占 CPU 失败后,保持就绪状态,一旦锁释放,就会继续抢占。
以上这2点内容,在后面的并发多线程中会着重学习,在这里浅浅点名,让大家明白他们的意思和作用即可。

【源码扩展2】
当链表的长度大于8时,会转为红黑树,而红黑树的实现,是通过底层的TreeBin,我们跟进去看一下。

static final class TreeBin<K,V> extends Node<K,V> {
        TreeNode<K,V> root;
        volatile TreeNode<K,V> first;
        volatile Thread waiter;
        volatile int lockState;
        // values for lockState
        static final int WRITER = 1; // set while holding write lock
        static final int WAITER = 2; // set when waiting for write lock
        static final int READER = 4; // increment value for setting read lock
...
}

TreeBin通过root属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在 ConcurrentHashMap 中TreeBin通过waiter属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。

【Get源码解析】
与put相比,get的源码就简单太多了,大概进行了如下几步操作:
1,根据计算出来的 hash 值寻址,如果在桶上直接返回值;
2,如果是红黑树,按照树的方式获取值;
3,如果是链表,按链表的方式遍历获取值;

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    // key 所在的 hash 位置
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        // 如果指定位置元素存在,头结点hash值相同
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                // key hash 值相等,key值相同,直接返回元素 value
                return e.val;
        }
        else if (eh < 0)
            // 头结点hash值小于0,说明正在扩容或者是红黑树,find查找
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) {
            // 是链表,遍历查找
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

总结

文章写到这里,ConcurrentHashMap的介绍基本讲完了,我们现在来自我总结一下为啥它的效率又高,又能保证线程安全。
以JDK1.8版本阐述:

  1. Node + CAS + synchronized 保证并发安全,每次上锁的颗粒度细到链表或红黑树的根节点,不会影响其他Node的读写,此外CAS是轻量级的,synchronized 也经过了锁升级;
  2. JDK1.7的版本里采用的Segment 分段锁,颗粒度粗不说,Segment 的个数一旦初始化就不能改变。 Segment 数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。而1.8的版本中,Node是一个数组,初始默认为16,后续仍然可以以2的幂次方级别进行扩容,因此,它所支持的并发量要看它数组的真实容量;
  3. 效率高是因为它底层采用了和JDK1.8中HashMap相同的数组+链表/红黑树结构。

结尾彩蛋

如果本篇博客对您有一定的帮助,大家记得
留言+点赞+收藏
呀。原创不易,转载请联系Build哥!

如果您想与Build哥的关系更近一步,还可以关注俺滴公众号“JavaBuild888”,在这里除了看到《Java成长计划》系列博文,还有提升工作效率的小笔记、读书心得、大厂面经、人生感悟等等,欢迎您的加入!

前言

最近有个个人需求是要把多个图片文件合并为一个PDF文件,这样方便用PDF阅读器连续看,避免界面点一下,只会图片放大。(
比如看漫画

主要思路是先把单张图片转换成单个PDF文件,然后把PDF文件进行合并。原先是用WPS的转换工具做的,但WPS每次只能批量转换30张,如果有大量图片文件,用WPS就不太方便了。

现成的工具找不到,索性自己整一个。python的pillow库和pypdf2库就可以实现这样的需求。

安装依赖库

本地的python版本为3.11。注意pypdf2升级到3.0版本后,一些类和方法和版本2有些区别。

python -m pip install PyPDF2==3.0.1 pillow==10.2.0

示例代码

目录结构示例

├── images
│   ├── 00001.jpg
│   ├── 00002.jpg
│   ├── 00003.jpg
│   ├── 00004.jpg
│   ├── 00005.jpg
│   ├── 00006.jpg
│   ├── 00007.jpg
│   ├── 00008.jpg
│   ├── 00009.jpg
│   └── 00010.jpg
├── main.py
└── temp

其中main.py内容如下

from PIL import Image
import os
import re
from PyPDF2 import PdfMerger

images_dir = "images"
tmpdir = "temp"


def trans_jpg2pdf(jpg_list: list) -> list:
    """jpg图片转换成pdf

    Args:
        jpg_list (list): 图片文件列表

    Returns:
        list: 图片转换后的pdf文件列表
    """
    pdf_list = []
    for jpg in jpg_list:
        jpg_path = os.path.join(images_dir, jpg)
        pdf_file = jpg.replace('.jpg', '.pdf')
        pdf_path = os.path.join(tmpdir, pdf_file)
        if os.path.exists(pdf_path):
            os.remove(pdf_path)
        pdf_list.append(pdf_path)
        im = Image.open(jpg_path)
        im.save(pdf_path, 'PDF', resolution=100.0)
    return pdf_list


def merge_pdf(pdf_list: list, result_pdf: str = "result.pdf"):
    """ 合并pdf文件

    Args:
        pdf_list (list): pdf文件列表
        result_pdf (str): 合并后的pdf文件名称
    """
    sorted_list = sorted(pdf_list, key=lambda x: int(re.search(r'\d+', x).group()))
    f_merger = PdfMerger()
    for pdf in sorted_list:
        f_merger.append(pdf)
    if os.path.exists(result_pdf):
        os.remove(result_pdf)
    f_merger.write(result_pdf)


if __name__ == '__main__':
    jpg_list = [f for f in os.listdir(images_dir) if f.endswith(".jpg")]
    pdf_list = trans_jpg2pdf(jpg_list)
    merge_pdf(pdf_list, "result.pdf")

补充

  • webp格式文件转PDF示例
from PIL import Image  
  
im = Image.open("00033.webp")  
rgb_im = im.convert("RGB")  
rgb_im.save("00033.pdf", 'PDF', resolution=100.0)

前言

大家好,这里是白泽。
《Go语言的100个错误以及如何避免》
是最近朋友推荐我阅读的书籍,我初步浏览之后,大为惊喜。就像这书中第一章的标题说到的:“
Go: Simple to learn but hard to master
”,整本书通过分析100个错误使用 Go 语言的场景,带你深入理解 Go 语言。

我的愿景是以这套文章,在保持权威性的基础上,脱离对原文的依赖,对这100个场景进行篇幅合适的中文讲解。所涉内容较多,总计约 8w 字,这是该系列的第七篇文章,对应书中第55-60个错误场景。


本系列的目录大纲为:

Taurus.MVC WebMVC 入门开发教程1:框架下载环境配置与运行

Taurus.MVC WebMVC 入门开发教程2:一个简单的页面呈现

Taurus.MVC WebMVC 入门开发教程3:数据绑定Model

Taurus.MVC WebMVC 入门开发教程4:数据列表绑定List<Model>

Taurus.MVC WebMVC 入门开发教程5:表单提交与数据验证

Taurus.MVC WebMVC 入门开发教程6:路由配置与路由映射

Taurus.MVC WebMVC 入门开发教程7:部分视图和页面片段(结束篇)

前言:

在本篇 Taurus.MVC WebMVC 入门开发教程的第七篇文章中,

我们将深入探讨如何使用部分视图和页面片段来提高代码的复用性和可维护性。

部分视图和页面片段是 Web 开发中常用的技术,能够帮助我们更好地组织和管理页面的结构和内容。

了解部分视图和页面片段

在 Taurus.MVC WebMVC 中,部分视图(Partial View)是一种可在其他视图中重复使用的视图片段。

它们通常用于显示页面的一部分内容,例如页眉、页脚、侧边栏等。

通过使用部分视图,我们可以将页面分割为多个独立的部分,便于管理和维护。

页面片段(Page Fragment)是指页面中的某个具体部分,可以是一个 HTML 片段、一段文本、一个表单等。

通过使用页面片段,我们可以将页面的不同部分进行精细化的管理,使得页面结构更加清晰和灵活。

创建部分视图

在 Taurus.MVC WebMVC 中,创建部分视图非常简单。

首先,在 Views 文件夹下创建一个名为
Shared
的文件夹(如果已经存在则忽略),然后在该文件夹下创建任意的如: foot
.html
的部分视图文件。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<body>
        <footer class="footer" id="footer">
            <div class="container">
                <p class="text-muted">Author : 路过秋天 Copyright © 2016-2096 Powered by Aster All Rights Reserved.</p>
            </div>
        </footer>

</body>
</html>

上述代码是一个简单的
foot.html
部分视图,用于显示网站的页脚内容。

在视图中使用部分视图的页面片段

在需要使用部分视图的视图文件中,使用
Html的 itemref 属性
引入部分视图。

itemref的语法为:
页面名称.节点ID(或标签tag)

<!DOCTYPE html>
<html  xmlns="http://www.w3.org/1999/xhtml">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>欢迎来到 Taurus.MVC WebMVC</title>
</head>
<body>
    <h1>用户列表</h1>
    <ul id="list">
        <li>姓名:${Name},年龄:${Age}</li>
    </ul>
    <div itemref="foot.footer"></div>
</body>
</html>

上述代码将 foot
.html
部分视图的页面片段,通过指定【页面名称.节点ID名称】嵌入到了 i
ndex.html
视图文件中,使得页面可以显示包含在
foot.html
中的内容。

页面片段高级使用:

这次,我们在 Views 文件夹下创建一个名为
Shared
的文件夹(如果已经存在则忽略),

然后在该文件夹下创建任意的如:
myhead.html
的部分视图文件:

<!DOCTYPE html>
<html>
<head>
    <title itemref="title"></title>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta name="keywords" content="taurus,taurus mvc,mvc,asp.net mvc,cyq.data" />
    <meta name="description" content="Taurus.MVC is a simple mvc or webapi framework for asp.net" />
    <meta name="author" content="Aster(路过秋天)" />
<link rel="stylesheet" href="/Views/styles/css/bootstrap.min.css" />
<script src="/Views/styles/js/bootstrap.min.js"></script> </head>
</html>

上述代码定义了一个名为
head.html
的部分视图文件,用于统一页面的head部分。

同样 head 里包含了在各页面通用的样式和脚本。

下面我们试着来引用它:

回到我们之前教程里创建的 index.html 页面,在 head 标签添加 itemref 属性,并指向 myhead.head:

<!DOCTYPE html>
<html  xmlns="http://www.w3.org/1999/xhtml">
<head itemref="myhead.head">
    <title>欢迎来到 Taurus.MVC WebMVC</title>
</head>
<body>
    <h1>用户列表</h1>
    <ul id="list">
        <li>姓名:${Name},年龄:${Age}</li>
    </ul>
    <div itemref="foot.footer"></div>
</body>
</html>

完成代码后,接下来,我们来看看程序运行的结果:

运行程序:

此时我们运行程序,输出结果和预期的一致,如下图:

在结果输出中,我们可以清晰的看到,head 节点已被替换成页面片段的节点。

同时标题,仍保留了 index.html 的标题。

这是怎么实现的呢?

由于不同的面页,有不同的标题,或者总有区异化的地方,对于这种情况,我们可以在部分视图中使用 itemref,再次引用回去。

因此,我们可以看到,在 head.html 中,也使用了 itemref 属性,它指向了 title 标签(注意,这里直接没有使用页面名称 xxx.即当前页面)。

通过二次引用,title 标签被指向 index 原来的页面的标签,于是显示了:
"欢迎来到 Taurus.MVC WebMVC“的标题。 有点类似于定义虚方法,留给引用者重写该内容。

需要注意的是,上述示例中,使用是使用标签的 tag 属性引用的,而不是ID,对于这种情况,框架默认会取第1个tag。

因此,如果你在程序中,通过xxx.tag 来引用时,应该确保该节点的唯一性。

总结

通过本篇教程,我们学习了如何在 Taurus.MVC WebMVC 中使用部分视图和页面片段来提高代码的复用性和可维护性。

我们了解了部分视图和页面片段的基本概念,并学习了如何创建和使用它们。

本系列入门教程,就到此结束,后续看有需要,再补充教程。

今天无聊,进行的C#的编码内容仍然在继续。这些天不断地在完善及编写C#的Winform相关的代码,并将其整理形成博文。这次带来的是关于窗体的显示及隐藏效果的代码段。上次有过一个代码,这次当做新代码进行更新了。

1、 项目目录;

2、 代码介绍;

1)这里是一个对窗体是否被覆盖进行的判断;

2)这里对窗体的显示进行判断;

3、 运行界面;

实际的使用效果请读着自己点击右下角托盘图标进行体验。

4、 使用介绍;

1) 将FormHelper这个操作类代码复制到项目中并进行引用;

2) 在托盘图标的点击或者其它需要的地方进行调用上面的代码;

5、 源码下载;

这里提供源码下载:

https://download.csdn.net/download/lzhdim/88896659

6、 其它建议;

经过笔者这几天的努力,对于这个托盘图标的窗体的显示及隐藏效果的代码已经编写得挺完善了,后续读者有什么问题都能够与笔者进行讨论,共同将该代码块进行完善。

上面是笔者进行编写的窗体的显示及隐藏效果的代码,需要的读者请自己下载源码进行复用即可。